authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-05 17:08:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-06 00:08:52+00:00
log0e388706470fdf8df7b5b5ae37f12bbeea777e06
tree75b4aaff741c4eccc2db75e61ee8324306ac0917
parent9b604bcae1e2cfe0e0566bc2aff660850e276c6d
signature Signed by PGP key B5690EEEBB952194

Add keybindings settings editor (#2533)

Co-authored-by: codex <codex@users.noreply.github.com>

24 files changed, 2142 insertions(+), 111 deletions(-)

apps/server/src/keybindings.test.ts+50-1
......@@ -354,7 +354,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
354354 }).pipe(Effect.provide(makeKeybindingsLayer())),
355355 );
356356
357 it.effect("replaces existing custom keybinding for the same command", () =>
357 it.effect("appends additional custom keybindings for the same command", () =>
358358 Effect.gen(function* () {
359359 const { keybindingsConfigPath } = yield* ServerConfig;
360360 yield* writeKeybindingsConfig(keybindingsConfigPath, [
......@@ -368,6 +368,55 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
368368 });
369369 });
370370
371 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
372 const persistedView = persisted.map(({ key, command }) => ({ key, command }));
373 assert.deepEqual(persistedView, [
374 { key: "mod+r", command: "script.run-tests.run" },
375 { key: "mod+shift+r", command: "script.run-tests.run" },
376 ]);
377 }).pipe(Effect.provide(makeKeybindingsLayer())),
378 );
379
380 it.effect("replaces only the targeted custom keybinding", () =>
381 Effect.gen(function* () {
382 const { keybindingsConfigPath } = yield* ServerConfig;
383 yield* writeKeybindingsConfig(keybindingsConfigPath, [
384 { key: "mod+r", command: "script.run-tests.run" },
385 { key: "mod+shift+r", command: "script.run-tests.run" },
386 ]);
387 yield* Effect.gen(function* () {
388 const keybindings = yield* Keybindings;
389 return yield* keybindings.upsertKeybindingRule({
390 key: "mod+alt+r",
391 command: "script.run-tests.run",
392 replace: { key: "mod+r", command: "script.run-tests.run" },
393 });
394 });
395
396 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
397 const persistedView = persisted.map(({ key, command }) => ({ key, command }));
398 assert.deepEqual(persistedView, [
399 { key: "mod+shift+r", command: "script.run-tests.run" },
400 { key: "mod+alt+r", command: "script.run-tests.run" },
401 ]);
402 }).pipe(Effect.provide(makeKeybindingsLayer())),
403 );
404
405 it.effect("removes only the targeted custom keybinding", () =>
406 Effect.gen(function* () {
407 const { keybindingsConfigPath } = yield* ServerConfig;
408 yield* writeKeybindingsConfig(keybindingsConfigPath, [
409 { key: "mod+r", command: "script.run-tests.run" },
410 { key: "mod+shift+r", command: "script.run-tests.run" },
411 ]);
412 yield* Effect.gen(function* () {
413 const keybindings = yield* Keybindings;
414 return yield* keybindings.removeKeybindingRule({
415 key: "mod+r",
416 command: "script.run-tests.run",
417 });
418 });
419
371420 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
372421 const persistedView = persisted.map(({ key, command }) => ({ key, command }));
373422 assert.deepEqual(persistedView, [{ key: "mod+shift+r", command: "script.run-tests.run" }]);
apps/server/src/keybindings.ts+59-3
......@@ -15,6 +15,8 @@ import {
1515 MAX_KEYBINDINGS_COUNT,
1616 ResolvedKeybindingRule,
1717 ResolvedKeybindingsConfig,
18 type ServerRemoveKeybindingInput,
19 type ServerUpsertKeybindingInput,
1820 type ServerConfigIssue,
1921} from "@t3tools/contracts";
2022import {
......@@ -124,6 +126,25 @@ function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): bo
124126 return leftContext === rightContext;
125127}
126128
129function keybindingRuleFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule {
130 return input.when === undefined
131 ? { key: input.key, command: input.command }
132 : { key: input.key, command: input.command, when: input.when };
133}
134
135function replaceTargetFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule | null {
136 if (!input.replace) return null;
137 return input.replace.when === undefined
138 ? { key: input.replace.key, command: input.replace.command }
139 : { key: input.replace.key, command: input.replace.command, when: input.replace.when };
140}
141
142function keybindingRuleFromRemoveInput(input: ServerRemoveKeybindingInput): KeybindingRule {
143 return input.when === undefined
144 ? { key: input.key, command: input.command }
145 : { key: input.key, command: input.command, when: input.when };
146}
147
127148function encodeShortcut(shortcut: KeybindingShortcut): string | null {
128149 const modifiers: string[] = [];
129150 if (shortcut.modKey) modifiers.push("mod");
......@@ -259,7 +280,14 @@ export interface KeybindingsShape {
259280 * oldest entries when needed.
260281 */
261282 readonly upsertKeybindingRule: (
262 rule: KeybindingRule,
283 input: ServerUpsertKeybindingInput,
284 ) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>;
285
286 /**
287 * Remove a single persisted keybinding rule by exact key/command/when match.
288 */
289 readonly removeKeybindingRule: (
290 input: ServerRemoveKeybindingInput,
263291 ) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>;
264292}
265293
......@@ -616,12 +644,19 @@ const makeKeybindings = Effect.gen(function* () {
616644 get streamChanges() {
617645 return Stream.fromPubSub(changesPubSub);
618646 },
619 upsertKeybindingRule: (rule) =>
647 upsertKeybindingRule: (input) =>
620648 upsertSemaphore.withPermits(1)(
621649 Effect.gen(function* () {
622650 const customConfig = yield* loadWritableCustomKeybindingsConfig();
651 const rule = keybindingRuleFromUpsertInput(input);
652 const replaceTarget = replaceTargetFromUpsertInput(input);
623653 const nextConfig = [
624 ...customConfig.filter((entry) => entry.command !== rule.command),
654 ...customConfig.filter((entry) => {
655 if (replaceTarget) {
656 return !isSameKeybindingRule(entry, replaceTarget);
657 }
658 return !isSameKeybindingRule(entry, rule);
659 }),
625660 rule,
626661 ];
627662 const cappedConfig =
......@@ -649,6 +684,27 @@ const makeKeybindings = Effect.gen(function* () {
649684 return nextResolved;
650685 }),
651686 ),
687 removeKeybindingRule: (input) =>
688 upsertSemaphore.withPermits(1)(
689 Effect.gen(function* () {
690 const customConfig = yield* loadWritableCustomKeybindingsConfig();
691 const target = keybindingRuleFromRemoveInput(input);
692 const nextConfig = customConfig.filter((entry) => !isSameKeybindingRule(entry, target));
693 yield* writeConfigAtomically(nextConfig);
694 const nextResolved = mergeWithDefaultKeybindings(
695 compileResolvedKeybindingsConfig(nextConfig),
696 );
697 yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
698 keybindings: nextResolved,
699 issues: [],
700 });
701 yield* emitChange({
702 keybindings: nextResolved,
703 issues: [],
704 });
705 return nextResolved;
706 }),
707 ),
652708 } satisfies KeybindingsShape;
653709});
654710
apps/server/src/server.test.ts+36
......@@ -1918,6 +1918,42 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
19181918 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
19191919 );
19201920
1921 it.effect("routes websocket rpc server.removeKeybinding", () =>
1922 Effect.gen(function* () {
1923 const rule: KeybindingRule = {
1924 command: "terminal.toggle",
1925 key: "ctrl+k",
1926 };
1927 const resolved: ResolvedKeybindingRule = {
1928 command: "terminal.toggle",
1929 shortcut: {
1930 key: "j",
1931 metaKey: false,
1932 ctrlKey: false,
1933 shiftKey: false,
1934 altKey: false,
1935 modKey: true,
1936 },
1937 };
1938
1939 yield* buildAppUnderTest({
1940 layers: {
1941 keybindings: {
1942 removeKeybindingRule: () => Effect.succeed([resolved]),
1943 },
1944 },
1945 });
1946
1947 const wsUrl = yield* getWsServerUrl("/ws");
1948 const response = yield* Effect.scoped(
1949 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverRemoveKeybinding](rule)),
1950 );
1951
1952 assert.deepEqual(response.issues, []);
1953 assert.deepEqual(response.keybindings, [resolved]);
1954 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1955 );
1956
19211957 it.effect("rejects websocket rpc handshake when session authentication is missing", () =>
19221958 Effect.gen(function* () {
19231959 const fs = yield* FileSystem.FileSystem;
apps/server/src/ws.ts+9
......@@ -804,6 +804,15 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
804804 }),
805805 { "rpc.aggregate": "server" },
806806 ),
807 [WS_METHODS.serverRemoveKeybinding]: (rule) =>
808 observeRpcEffect(
809 WS_METHODS.serverRemoveKeybinding,
810 Effect.gen(function* () {
811 const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule);
812 return { keybindings: keybindingsConfig, issues: [] };
813 }),
814 { "rpc.aggregate": "server" },
815 ),
807816 [WS_METHODS.serverGetSettings]: (_input) =>
808817 observeRpcEffect(
809818 WS_METHODS.serverGetSettings,
apps/web/src/components/KeybindingsToast.browser.tsx+7-3
......@@ -532,16 +532,20 @@ describe("Keybindings update toast", () => {
532532 document.body.innerHTML = "";
533533 });
534534
535 it("shows a toast for each consecutive keybinding update with no issues", async () => {
535 it("coalesces rapid consecutive keybinding update toasts with no issues", async () => {
536536 const mounted = await mountApp();
537537
538538 try {
539539 sendServerConfigUpdatedPush([]);
540540 await waitForToast("Keybindings updated", 1);
541541
542 // Each server push represents a distinct file change, so it should produce its own toast.
542 // A single edit can produce several reload notifications as the direct update and
543 // filesystem watcher settle, so avoid stacking identical success toasts.
543544 sendServerConfigUpdatedPush([]);
544 await waitForToast("Keybindings updated", 2);
545 await new Promise((resolve) => setTimeout(resolve, 250));
546
547 const titles = queryToastTitles();
548 expect(titles.filter((title) => title === "Keybindings updated")).toHaveLength(1);
545549 } finally {
546550 await mounted.cleanup();
547551 }
apps/web/src/components/ProjectScriptsControl.tsx+2-53
......@@ -20,13 +20,13 @@ import {
2020 keybindingValueForCommand,
2121 decodeProjectScriptKeybindingRule,
2222} from "~/lib/projectScriptKeybindings";
23import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
2324import {
2425 commandForProjectScript,
2526 nextProjectScriptId,
2627 primaryProjectScript,
2728} from "~/projectScripts";
2829import { shortcutLabelForCommand } from "~/keybindings";
29import { isMacPlatform } from "~/lib/utils";
3030import {
3131 AlertDialog,
3232 AlertDialogClose,
......@@ -96,57 +96,6 @@ interface ProjectScriptsControlProps {
9696 onDeleteScript: (scriptId: string) => Promise<void> | void;
9797}
9898
99function normalizeShortcutKeyToken(key: string): string | null {
100 const normalized = key.toLowerCase();
101 if (
102 normalized === "meta" ||
103 normalized === "control" ||
104 normalized === "ctrl" ||
105 normalized === "shift" ||
106 normalized === "alt" ||
107 normalized === "option"
108 ) {
109 return null;
110 }
111 if (normalized === " ") return "space";
112 if (normalized === "escape") return "esc";
113 if (normalized === "arrowup") return "arrowup";
114 if (normalized === "arrowdown") return "arrowdown";
115 if (normalized === "arrowleft") return "arrowleft";
116 if (normalized === "arrowright") return "arrowright";
117 if (normalized.length === 1) return normalized;
118 if (normalized.startsWith("f") && normalized.length <= 3) return normalized;
119 if (normalized === "enter" || normalized === "tab" || normalized === "backspace") {
120 return normalized;
121 }
122 if (normalized === "delete" || normalized === "home" || normalized === "end") {
123 return normalized;
124 }
125 if (normalized === "pageup" || normalized === "pagedown") return normalized;
126 return null;
127}
128
129function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | null {
130 const keyToken = normalizeShortcutKeyToken(event.key);
131 if (!keyToken) return null;
132
133 const parts: string[] = [];
134 if (isMacPlatform(navigator.platform)) {
135 if (event.metaKey) parts.push("mod");
136 if (event.ctrlKey) parts.push("ctrl");
137 } else {
138 if (event.ctrlKey) parts.push("mod");
139 if (event.metaKey) parts.push("meta");
140 }
141 if (event.altKey) parts.push("alt");
142 if (event.shiftKey) parts.push("shift");
143 if (parts.length === 0) {
144 return null;
145 }
146 parts.push(keyToken);
147 return parts.join("+");
148}
149
15099export default function ProjectScriptsControl({
151100 scripts,
152101 keybindings,
......@@ -186,7 +135,7 @@ export default function ProjectScriptsControl({
186135 setKeybinding("");
187136 return;
188137 }
189 const next = keybindingFromEvent(event);
138 const next = keybindingFromKeyboardEvent(event, navigator.platform);
190139 if (!next) return;
191140 setKeybinding(next);
192141 };
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts created+248
......@@ -0,0 +1,248 @@
1import { describe, expect, it } from "vitest";
2import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
3
4import {
5 buildKeybindingRows,
6 buildKeybindingCommandOptions,
7 buildWhenVariableOptions,
8 commandLabel,
9 keybindingConflictLabels,
10 keybindingFromKeyboardEvent,
11 parseWhenExpressionDraft,
12 shortcutToKeybindingInput,
13 unknownWhenVariables,
14 whenAstToExpression,
15} from "./KeybindingsSettings.logic";
16
17describe("KeybindingsSettings.logic", () => {
18 it("builds searchable rows with readable key and when values", () => {
19 const rows = buildKeybindingRows(
20 [
21 {
22 command: "terminal.toggle",
23 shortcut: {
24 key: "j",
25 modKey: true,
26 metaKey: false,
27 ctrlKey: false,
28 altKey: false,
29 shiftKey: false,
30 },
31 whenAst: {
32 type: "not",
33 node: { type: "identifier", name: "terminalFocus" },
34 },
35 },
36 ] satisfies ResolvedKeybindingsConfig,
37 "terminal",
38 );
39
40 expect(rows).toEqual([
41 expect.objectContaining({
42 command: "terminal.toggle",
43 key: "mod+j",
44 when: "!terminalFocus",
45 defaultKey: "mod+j",
46 defaultWhen: "",
47 source: "Custom",
48 }),
49 ]);
50 });
51
52 it("captures platform-specific mod shortcuts", () => {
53 expect(
54 keybindingFromKeyboardEvent(
55 { key: "K", metaKey: true, ctrlKey: false, altKey: false, shiftKey: true },
56 "MacIntel",
57 ),
58 ).toBe("mod+shift+k");
59 expect(
60 keybindingFromKeyboardEvent(
61 { key: "K", metaKey: false, ctrlKey: true, altKey: false, shiftKey: true },
62 "Win32",
63 ),
64 ).toBe("mod+shift+k");
65 });
66
67 it("serializes shortcuts and when expressions for upserts", () => {
68 expect(
69 shortcutToKeybindingInput({
70 key: " ",
71 modKey: true,
72 metaKey: false,
73 ctrlKey: false,
74 altKey: true,
75 shiftKey: false,
76 }),
77 ).toBe("mod+alt+space");
78
79 expect(
80 whenAstToExpression({
81 type: "and",
82 left: { type: "identifier", name: "editorFocus" },
83 right: {
84 type: "not",
85 node: { type: "identifier", name: "terminalFocus" },
86 },
87 }),
88 ).toBe("editorFocus && !terminalFocus");
89
90 expect(parseWhenExpressionDraft("editorFocus && (!terminalFocus || modelPickerOpen)")).toEqual({
91 ok: true,
92 value: {
93 type: "and",
94 left: { type: "identifier", name: "editorFocus" },
95 right: {
96 type: "or",
97 left: {
98 type: "not",
99 node: { type: "identifier", name: "terminalFocus" },
100 },
101 right: { type: "identifier", name: "modelPickerOpen" },
102 },
103 },
104 });
105 expect(parseWhenExpressionDraft("editorFocus &&")).toEqual({
106 ok: false,
107 message: "Use variables with !, &&, ||, and parentheses.",
108 });
109
110 expect(parseWhenExpressionDraft("!(terminalFocus || modelPickerOpen)")).toEqual({
111 ok: true,
112 value: {
113 type: "not",
114 node: {
115 type: "or",
116 left: { type: "identifier", name: "terminalFocus" },
117 right: { type: "identifier", name: "modelPickerOpen" },
118 },
119 },
120 });
121 });
122
123 it("formats static and project script command labels", () => {
124 expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle");
125 expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db");
126 });
127
128 it("builds known when variable options from defaults without frontend labels", () => {
129 const options = buildWhenVariableOptions();
130
131 expect(options).toEqual(
132 expect.arrayContaining(["terminalFocus", "terminalOpen", "modelPickerOpen", "true", "false"]),
133 );
134 expect(options).not.toContain("customModeActive");
135 });
136
137 it("builds command options from defaults and resolved project bindings", () => {
138 const options = buildKeybindingCommandOptions([
139 {
140 command: "script.setup-db.run",
141 shortcut: {
142 key: "r",
143 modKey: true,
144 metaKey: false,
145 ctrlKey: false,
146 altKey: false,
147 shiftKey: false,
148 },
149 },
150 ] satisfies ResolvedKeybindingsConfig);
151
152 expect(options).toEqual(expect.arrayContaining(["chat.new", "script.setup-db.run"]));
153 });
154
155 it("reports unknown when variables without rejecting parseable expressions", () => {
156 const parsed = parseWhenExpressionDraft("!terminalFocus && terminalFoc");
157
158 expect(parsed.ok).toBe(true);
159 expect(unknownWhenVariables(parsed.ok ? parsed.value : undefined)).toEqual(["terminalFoc"]);
160 });
161
162 it("marks each default shortcut for multi-binding commands as default", () => {
163 const rows = buildKeybindingRows(
164 [
165 {
166 command: "chat.new",
167 shortcut: {
168 key: "n",
169 modKey: true,
170 metaKey: false,
171 ctrlKey: false,
172 altKey: false,
173 shiftKey: false,
174 },
175 whenAst: {
176 type: "not",
177 node: { type: "identifier", name: "terminalFocus" },
178 },
179 },
180 {
181 command: "chat.new",
182 shortcut: {
183 key: "o",
184 modKey: true,
185 metaKey: false,
186 ctrlKey: false,
187 altKey: false,
188 shiftKey: true,
189 },
190 whenAst: {
191 type: "not",
192 node: { type: "identifier", name: "terminalFocus" },
193 },
194 },
195 ] satisfies ResolvedKeybindingsConfig,
196 "",
197 );
198
199 expect(rows.map((row) => row.source)).toEqual(["Default", "Default"]);
200 });
201
202 it("reports conflicting shortcuts that share an active when context", () => {
203 const rows = buildKeybindingRows(
204 [
205 {
206 command: "chat.new",
207 shortcut: {
208 key: "n",
209 modKey: true,
210 metaKey: false,
211 ctrlKey: false,
212 altKey: false,
213 shiftKey: false,
214 },
215 whenAst: {
216 type: "not",
217 node: { type: "identifier", name: "terminalFocus" },
218 },
219 },
220 {
221 command: "chat.newLocal",
222 shortcut: {
223 key: "n",
224 modKey: true,
225 metaKey: false,
226 ctrlKey: false,
227 altKey: false,
228 shiftKey: false,
229 },
230 whenAst: {
231 type: "not",
232 node: { type: "identifier", name: "terminalFocus" },
233 },
234 },
235 ] satisfies ResolvedKeybindingsConfig,
236 "",
237 );
238
239 expect(rows[0]?.conflicts).toEqual(["Chat: New Local"]);
240 expect(
241 keybindingConflictLabels(rows, {
242 rowId: rows[0]?.id ?? "",
243 key: "mod+n",
244 when: "",
245 }),
246 ).toEqual(["Chat: New Local"]);
247 });
248});
apps/web/src/components/settings/KeybindingsSettings.logic.ts created+337
......@@ -0,0 +1,337 @@
1import {
2 type KeybindingCommand,
3 type KeybindingShortcut,
4 type KeybindingWhenNode,
5 type ResolvedKeybindingRule,
6 type ResolvedKeybindingsConfig,
7} from "@t3tools/contracts";
8import {
9 DEFAULT_RESOLVED_KEYBINDINGS,
10 parseKeybindingWhenExpression,
11} from "@t3tools/shared/keybindings";
12
13import { isMacPlatform } from "../../lib/utils";
14
15export type KeybindingSource = "Default" | "Custom" | "Project";
16
17export interface KeybindingRow {
18 readonly id: string;
19 readonly command: KeybindingCommand;
20 readonly key: string;
21 readonly when: string;
22 readonly source: KeybindingSource;
23 readonly defaultKey: string | null;
24 readonly defaultWhen: string;
25 readonly binding: ResolvedKeybindingRule;
26 readonly conflicts: ReadonlyArray<string>;
27}
28
29export type WhenVariableOption = string;
30export type KeybindingCommandOption = KeybindingCommand;
31
32const CORE_WHEN_VARIABLES = ["terminalFocus", "terminalOpen", "true", "false"] as const;
33
34const DEFAULT_WHEN_VARIABLES = new Set<string>(CORE_WHEN_VARIABLES);
35for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) {
36 collectWhenIdentifiersFromNode(binding.whenAst, DEFAULT_WHEN_VARIABLES);
37}
38
39export const DEFAULT_WHEN_VARIABLE =
40 [...DEFAULT_WHEN_VARIABLES].find(
41 (identifier) => identifier !== "true" && identifier !== "false",
42 ) ?? "terminalFocus";
43const KNOWN_WHEN_VARIABLES = new Set(DEFAULT_WHEN_VARIABLES);
44
45export function shortcutToKeybindingInput(shortcut: KeybindingShortcut): string {
46 const parts: string[] = [];
47 if (shortcut.modKey) parts.push("mod");
48 if (shortcut.metaKey) parts.push("meta");
49 if (shortcut.ctrlKey) parts.push("ctrl");
50 if (shortcut.altKey) parts.push("alt");
51 if (shortcut.shiftKey) parts.push("shift");
52 parts.push(shortcut.key === " " ? "space" : shortcut.key === "escape" ? "esc" : shortcut.key);
53 return parts.join("+");
54}
55
56export function whenAstToExpression(node: KeybindingWhenNode | undefined): string {
57 if (!node) return "";
58 switch (node.type) {
59 case "identifier":
60 return node.name;
61 case "not":
62 return `!${wrapWhenExpression(node.node)}`;
63 case "and":
64 return `${wrapWhenExpression(node.left)} && ${wrapWhenExpression(node.right)}`;
65 case "or":
66 return `${wrapWhenExpression(node.left)} || ${wrapWhenExpression(node.right)}`;
67 }
68}
69
70function wrapWhenExpression(node: KeybindingWhenNode): string {
71 if (node.type === "identifier" || node.type === "not") return whenAstToExpression(node);
72 return `(${whenAstToExpression(node)})`;
73}
74
75export function parseWhenExpressionDraft(
76 expression: string,
77): { ok: true; value: KeybindingWhenNode | undefined } | { ok: false; message: string } {
78 const trimmed = expression.trim();
79 if (trimmed.length === 0) return { ok: true, value: undefined };
80
81 const ast = parseKeybindingWhenExpression(trimmed);
82 if (!ast) {
83 return {
84 ok: false,
85 message: "Use variables with !, &&, ||, and parentheses.",
86 };
87 }
88
89 return { ok: true, value: ast };
90}
91
92function sourceForBinding(binding: ResolvedKeybindingRule): KeybindingSource {
93 if (String(binding.command).startsWith("script.")) {
94 return "Project";
95 }
96
97 const bindingKey = shortcutToKeybindingInput(binding.shortcut);
98 const bindingWhen = whenAstToExpression(binding.whenAst);
99 const isDefault = DEFAULT_RESOLVED_KEYBINDINGS.some(
100 (entry) =>
101 entry.command === binding.command &&
102 shortcutToKeybindingInput(entry.shortcut) === bindingKey &&
103 whenAstToExpression(entry.whenAst) === bindingWhen,
104 );
105
106 return isDefault ? "Default" : "Custom";
107}
108
109function defaultBindingForBinding(
110 binding: ResolvedKeybindingRule,
111): ResolvedKeybindingRule | undefined {
112 const bindingKey = shortcutToKeybindingInput(binding.shortcut);
113 const bindingWhen = whenAstToExpression(binding.whenAst);
114
115 return (
116 DEFAULT_RESOLVED_KEYBINDINGS.find(
117 (entry) =>
118 entry.command === binding.command &&
119 shortcutToKeybindingInput(entry.shortcut) === bindingKey &&
120 whenAstToExpression(entry.whenAst) === bindingWhen,
121 ) ??
122 DEFAULT_RESOLVED_KEYBINDINGS.find(
123 (entry) =>
124 entry.command === binding.command && whenAstToExpression(entry.whenAst) === bindingWhen,
125 ) ??
126 DEFAULT_RESOLVED_KEYBINDINGS.find((entry) => entry.command === binding.command)
127 );
128}
129
130function keybindingRowId(command: KeybindingCommand, key: string, when: string): string {
131 return `${command}\u0000${key}\u0000${when}`;
132}
133
134function conflictsWithWhen(leftWhen: string, rightWhen: string): boolean {
135 return leftWhen.length === 0 || rightWhen.length === 0 || leftWhen === rightWhen;
136}
137
138export function keybindingConflictLabels(
139 rows: ReadonlyArray<KeybindingRow>,
140 input: { readonly rowId: string; readonly key: string; readonly when: string },
141): ReadonlyArray<string> {
142 if (input.key.trim().length === 0) return [];
143 const conflicts = rows
144 .filter(
145 (candidate) =>
146 candidate.id !== input.rowId &&
147 candidate.key === input.key &&
148 conflictsWithWhen(candidate.when, input.when),
149 )
150 .map((candidate) => commandLabel(candidate.command));
151 return [...new Set(conflicts)].toSorted();
152}
153
154export function buildKeybindingRows(
155 keybindings: ResolvedKeybindingsConfig,
156 query: string,
157): ReadonlyArray<KeybindingRow> {
158 const normalizedQuery = query.trim().toLowerCase();
159 const rows = keybindings.map((binding, index) => {
160 const defaultBinding = defaultBindingForBinding(binding);
161 const key = shortcutToKeybindingInput(binding.shortcut);
162 const when = whenAstToExpression(binding.whenAst);
163 return {
164 id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`,
165 command: binding.command,
166 key,
167 when,
168 source: sourceForBinding(binding),
169 defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null,
170 defaultWhen: whenAstToExpression(defaultBinding?.whenAst),
171 binding,
172 conflicts: [],
173 } satisfies KeybindingRow;
174 });
175
176 const rowsWithConflicts = rows.map((row) => {
177 const conflicts = keybindingConflictLabels(rows, {
178 rowId: row.id,
179 key: row.key,
180 when: row.when,
181 });
182 return conflicts.length > 0
183 ? Object.assign({}, row, { conflicts: [...new Set(conflicts)].toSorted() })
184 : row;
185 });
186
187 rowsWithConflicts.sort((left, right) => {
188 const commandCompare = left.command.localeCompare(right.command);
189 if (commandCompare !== 0) return commandCompare;
190 return left.key.localeCompare(right.key);
191 });
192
193 if (normalizedQuery.length === 0) {
194 return rowsWithConflicts;
195 }
196
197 return rowsWithConflicts.filter((row) => {
198 return (
199 row.command.toLowerCase().includes(normalizedQuery) ||
200 row.key.toLowerCase().includes(normalizedQuery) ||
201 row.when.toLowerCase().includes(normalizedQuery) ||
202 row.source.toLowerCase().includes(normalizedQuery)
203 );
204 });
205}
206
207function collectWhenIdentifiersFromNode(
208 node: KeybindingWhenNode | undefined,
209 identifiers: Set<string>,
210): void {
211 if (!node) return;
212 switch (node.type) {
213 case "identifier":
214 identifiers.add(node.name);
215 return;
216 case "not":
217 collectWhenIdentifiersFromNode(node.node, identifiers);
218 return;
219 case "and":
220 case "or":
221 collectWhenIdentifiersFromNode(node.left, identifiers);
222 collectWhenIdentifiersFromNode(node.right, identifiers);
223 return;
224 }
225}
226
227export function isKnownWhenVariable(identifier: string): boolean {
228 return KNOWN_WHEN_VARIABLES.has(identifier);
229}
230
231export function unknownWhenVariables(node: KeybindingWhenNode | undefined): ReadonlyArray<string> {
232 const identifiers = new Set<string>();
233 collectWhenIdentifiersFromNode(node, identifiers);
234 return [...identifiers].filter((identifier) => !isKnownWhenVariable(identifier)).toSorted();
235}
236
237export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> {
238 return [...KNOWN_WHEN_VARIABLES].toSorted((left, right) => {
239 const leftCoreIndex = CORE_WHEN_VARIABLES.indexOf(left as (typeof CORE_WHEN_VARIABLES)[number]);
240 const rightCoreIndex = CORE_WHEN_VARIABLES.indexOf(
241 right as (typeof CORE_WHEN_VARIABLES)[number],
242 );
243 if (leftCoreIndex !== -1 || rightCoreIndex !== -1) {
244 return (
245 (leftCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : leftCoreIndex) -
246 (rightCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : rightCoreIndex)
247 );
248 }
249 return left.localeCompare(right);
250 });
251}
252
253export function buildKeybindingCommandOptions(
254 keybindings: ResolvedKeybindingsConfig,
255): ReadonlyArray<KeybindingCommandOption> {
256 const commands = new Set<KeybindingCommand>();
257 for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) {
258 commands.add(binding.command);
259 }
260 for (const binding of keybindings) {
261 commands.add(binding.command);
262 }
263 return [...commands].toSorted((left, right) =>
264 commandLabel(left).localeCompare(commandLabel(right)),
265 );
266}
267
268export function commandLabel(command: KeybindingCommand): string {
269 const raw = String(command);
270 if (raw.startsWith("script.") && raw.endsWith(".run")) {
271 return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`;
272 }
273 return raw.split(".").map(titleCaseCommandSegment).join(": ");
274}
275
276function titleCaseCommandSegment(segment: string): string {
277 return segment
278 .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
279 .split(/[-_\s]+/)
280 .filter(Boolean)
281 .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
282 .join(" ");
283}
284
285export function normalizeShortcutKeyToken(key: string): string | null {
286 const normalized = key.toLowerCase();
287 if (
288 normalized === "meta" ||
289 normalized === "control" ||
290 normalized === "ctrl" ||
291 normalized === "shift" ||
292 normalized === "alt" ||
293 normalized === "option"
294 ) {
295 return null;
296 }
297 if (normalized === " ") return "space";
298 if (normalized === "escape") return "esc";
299 if (normalized === "arrowup") return "arrowup";
300 if (normalized === "arrowdown") return "arrowdown";
301 if (normalized === "arrowleft") return "arrowleft";
302 if (normalized === "arrowright") return "arrowright";
303 if (normalized.length === 1) return normalized;
304 if (/^f\d{1,2}$/.test(normalized)) return normalized;
305 if (normalized === "enter" || normalized === "tab" || normalized === "backspace") {
306 return normalized;
307 }
308 if (normalized === "delete" || normalized === "home" || normalized === "end") {
309 return normalized;
310 }
311 if (normalized === "pageup" || normalized === "pagedown") return normalized;
312 return null;
313}
314
315export function keybindingFromKeyboardEvent(
316 event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">,
317 platform: string,
318): string | null {
319 const keyToken = normalizeShortcutKeyToken(event.key);
320 if (!keyToken) return null;
321
322 const parts: string[] = [];
323 if (isMacPlatform(platform)) {
324 if (event.metaKey) parts.push("mod");
325 if (event.ctrlKey) parts.push("ctrl");
326 } else {
327 if (event.ctrlKey) parts.push("mod");
328 if (event.metaKey) parts.push("meta");
329 }
330 if (event.altKey) parts.push("alt");
331 if (event.shiftKey) parts.push("shift");
332 if (parts.length === 0) {
333 return null;
334 }
335 parts.push(keyToken);
336 return parts.join("+");
337}
apps/web/src/components/settings/KeybindingsSettings.tsx created+1282
......@@ -0,0 +1,1282 @@
1import {
2 ChevronDownIcon,
3 CircleXIcon,
4 EllipsisIcon,
5 FileJsonIcon,
6 InfoIcon,
7 MinusIcon,
8 PlusIcon,
9 SearchIcon,
10 TriangleAlertIcon,
11 XIcon,
12} from "lucide-react";
13import {
14 type KeyboardEvent,
15 type ReactNode,
16 type RefObject,
17 useCallback,
18 useEffect,
19 useMemo,
20 useReducer,
21 useRef,
22 useState,
23} from "react";
24import {
25 type KeybindingCommand,
26 type KeybindingWhenNode,
27 type ServerRemoveKeybindingInput,
28 type ServerUpsertKeybindingInput,
29} from "@t3tools/contracts";
30
31import { isElectron } from "../../env";
32import { openInPreferredEditor } from "../../editorPreferences";
33import { formatShortcutLabel } from "../../keybindings";
34import { cn } from "../../lib/utils";
35import { ensureLocalApi } from "../../localApi";
36import { useServerKeybindings, useServerKeybindingsConfigPath } from "../../rpc/serverState";
37import { Button } from "../ui/button";
38import { Input } from "../ui/input";
39import { Kbd, KbdGroup } from "../ui/kbd";
40import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
41import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
42import { ScrollArea } from "../ui/scroll-area";
43import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
44import { Toggle } from "../ui/toggle";
45import { toastManager } from "../ui/toast";
46import {
47 buildKeybindingRows,
48 buildKeybindingCommandOptions,
49 buildWhenVariableOptions,
50 commandLabel,
51 DEFAULT_WHEN_VARIABLE,
52 isKnownWhenVariable,
53 keybindingConflictLabels,
54 keybindingFromKeyboardEvent,
55 parseWhenExpressionDraft,
56 type KeybindingCommandOption,
57 type KeybindingRow,
58 type WhenVariableOption,
59 unknownWhenVariables,
60 whenAstToExpression,
61} from "./KeybindingsSettings.logic";
62import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
63import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
64
65function KeybindingPill({ value }: { value: string }) {
66 const parts = value.split("+");
67 return (
68 <KbdGroup className="bg-transparent p-0 shadow-none">
69 {parts.map((part) => (
70 <Kbd key={part} className="min-w-6 justify-center px-1.5">
71 {part === "mod"
72 ? navigator.platform.toLowerCase().includes("mac")
73 ? "⌘"
74 : "Ctrl"
75 : part === "shift"
76 ? "⇧"
77 : part === "alt"
78 ? navigator.platform.toLowerCase().includes("mac")
79 ? "⌥"
80 : "Alt"
81 : part === "ctrl"
82 ? "⌃"
83 : part.length === 1
84 ? part.toUpperCase()
85 : part}
86 </Kbd>
87 ))}
88 </KbdGroup>
89 );
90}
91
92function ExpandableHeaderSearch({
93 query,
94 onChange,
95 isOpen,
96 onOpenChange,
97 inputRef,
98 collapsedAccessory,
99}: {
100 query: string;
101 onChange: (next: string) => void;
102 isOpen: boolean;
103 onOpenChange: (next: boolean) => void;
104 inputRef?: RefObject<HTMLInputElement | null>;
105 collapsedAccessory?: ReactNode;
106}) {
107 if (!isOpen) {
108 return (
109 <>
110 {collapsedAccessory}
111 <Tooltip>
112 <TooltipTrigger
113 render={
114 <Button
115 type="button"
116 size="icon-xs"
117 variant="ghost"
118 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
119 onClick={() => onOpenChange(true)}
120 aria-label="Search keybindings"
121 >
122 <SearchIcon className="size-3" />
123 </Button>
124 }
125 />
126 <TooltipPopup side="top">Search keybindings</TooltipPopup>
127 </Tooltip>
128 </>
129 );
130 }
131
132 return (
133 <div className="relative">
134 <SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3 -translate-y-1/2 text-muted-foreground" />
135 <input
136 ref={inputRef}
137 autoFocus
138 type="text"
139 value={query}
140 onChange={(event) => onChange(event.currentTarget.value)}
141 onBlur={() => {
142 if (query.length === 0) onOpenChange(false);
143 }}
144 onKeyDown={(event) => {
145 if (event.key === "Escape") {
146 event.preventDefault();
147 onChange("");
148 onOpenChange(false);
149 }
150 }}
151 placeholder="Search keybindings"
152 aria-label="Search keybindings"
153 className="h-6 w-44 rounded-md border border-input bg-background pl-7 pr-2 text-[11px] text-foreground outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24"
154 />
155 </div>
156 );
157}
158
159type BooleanOperator = "and" | "or";
160
161function flattenWhenChildren(
162 node: KeybindingWhenNode,
163 operator: BooleanOperator,
164): KeybindingWhenNode[] {
165 if (node.type !== operator) return [node];
166 return [
167 ...flattenWhenChildren(node.left, operator),
168 ...flattenWhenChildren(node.right, operator),
169 ];
170}
171
172function buildWhenExpressionGroup(
173 children: readonly KeybindingWhenNode[],
174 operator: BooleanOperator,
175): KeybindingWhenNode | undefined {
176 const first = children[0];
177 if (!first) return undefined;
178 return children.slice(1).reduce<KeybindingWhenNode>(
179 (left, right) => ({
180 type: operator,
181 left,
182 right,
183 }),
184 first,
185 );
186}
187
188function conditionParts(node: KeybindingWhenNode): { identifier: string; negated: boolean } | null {
189 if (node.type === "identifier") return { identifier: node.name, negated: false };
190 if (node.type === "not" && node.node.type === "identifier") {
191 return { identifier: node.node.name, negated: true };
192 }
193 return null;
194}
195
196function setConditionIdentifier(node: KeybindingWhenNode, identifier: string): KeybindingWhenNode {
197 const parts = conditionParts(node);
198 if (!parts) return node;
199 const next: KeybindingWhenNode = { type: "identifier", name: identifier };
200 return parts.negated ? { type: "not", node: next } : next;
201}
202
203function setConditionNegated(node: KeybindingWhenNode, negated: boolean): KeybindingWhenNode {
204 const parts = conditionParts(node);
205 if (!parts) return negated ? { type: "not", node } : node;
206 const identifier: KeybindingWhenNode = { type: "identifier", name: parts.identifier };
207 return negated ? { type: "not", node: identifier } : identifier;
208}
209
210function defaultWhenCondition(): KeybindingWhenNode {
211 return { type: "identifier", name: DEFAULT_WHEN_VARIABLE };
212}
213
214function defaultWhenGroup(operator: BooleanOperator = "and"): KeybindingWhenNode {
215 return {
216 type: operator,
217 left: defaultWhenCondition(),
218 right: { type: "not", node: defaultWhenCondition() },
219 };
220}
221
222function UnknownWhenVariableWarning({
223 identifiers,
224 focusable = true,
225}: {
226 identifiers: ReadonlyArray<string>;
227 focusable?: boolean;
228}) {
229 if (identifiers.length === 0) return null;
230 const label =
231 identifiers.length === 1
232 ? `Unknown condition: ${identifiers[0]}`
233 : `Unknown conditions: ${identifiers.join(", ")}`;
234
235 return (
236 <Tooltip>
237 <TooltipTrigger
238 render={
239 <span
240 tabIndex={focusable ? 0 : undefined}
241 aria-label={label}
242 className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-sm text-warning outline-none transition-colors hover:bg-warning/10 focus-visible:ring-[3px] focus-visible:ring-warning/25"
243 >
244 <TriangleAlertIcon className="size-3.5" />
245 </span>
246 }
247 />
248 <TooltipPopup side="top" className="max-w-72 whitespace-normal leading-relaxed">
249 T3 Code does not recognize this condition yet. It can still be saved, but it may not match
250 unless the runtime provides it.
251 </TooltipPopup>
252 </Tooltip>
253 );
254}
255
256function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray<string> }) {
257 if (labels.length === 0) return null;
258 const description =
259 labels.length === 1
260 ? `Conflicts with ${labels[0]}.`
261 : `Conflicts with ${labels.slice(0, 3).join(", ")}${labels.length > 3 ? ", and more" : ""}.`;
262
263 return (
264 <Tooltip>
265 <TooltipTrigger
266 render={
267 <span
268 tabIndex={0}
269 aria-label={description}
270 className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-warning outline-none transition-colors hover:bg-warning/10 focus-visible:ring-[3px] focus-visible:ring-warning/25"
271 >
272 <TriangleAlertIcon className="size-3.5" />
273 </span>
274 }
275 />
276 <TooltipPopup side="top" className="max-w-72 whitespace-normal leading-relaxed">
277 {description} The most recent matching binding wins when both conditions can apply.
278 </TooltipPopup>
279 </Tooltip>
280 );
281}
282
283function WhenVariableSelect({
284 value,
285 variables,
286 unknownIdentifiers,
287 onChange,
288}: {
289 value: string;
290 variables: ReadonlyArray<WhenVariableOption>;
291 unknownIdentifiers?: ReadonlyArray<string>;
292 onChange: (value: string) => void;
293}) {
294 const selected = variables.find((option) => option === value);
295 const options =
296 selected || variables.some((option) => option === value) ? variables : [value, ...variables];
297
298 return (
299 <Select value={value} onValueChange={(nextValue) => nextValue && onChange(nextValue)}>
300 <SelectTrigger
301 size="xs"
302 className="h-7 min-h-7 min-w-0 flex-1 rounded-md font-mono text-xs sm:h-7"
303 >
304 <SelectValue placeholder="Condition" className="leading-7" />
305 {unknownIdentifiers && unknownIdentifiers.length > 0 ? (
306 <UnknownWhenVariableWarning identifiers={unknownIdentifiers} focusable={false} />
307 ) : null}
308 </SelectTrigger>
309 <SelectContent
310 alignItemWithTrigger={false}
311 matchTriggerWidth={false}
312 popupClassName="w-fit"
313 className="max-h-72 w-fit min-w-44"
314 >
315 {options.map((option) => (
316 <SelectItem
317 key={option}
318 value={option}
319 className="min-h-7 w-full py-1 font-mono text-[12px]"
320 >
321 <span className="truncate">{option}</span>
322 </SelectItem>
323 ))}
324 </SelectContent>
325 </Select>
326 );
327}
328
329function WhenExpressionNodeEditor({
330 node,
331 variables,
332 depth = 0,
333 onChange,
334 onRemove,
335}: {
336 node: KeybindingWhenNode;
337 variables: ReadonlyArray<WhenVariableOption>;
338 depth?: number;
339 onChange: (node: KeybindingWhenNode) => void;
340 onRemove?: () => void;
341}) {
342 const condition = conditionParts(node);
343
344 if (condition) {
345 const unknownIdentifiers = isKnownWhenVariable(condition.identifier)
346 ? []
347 : [condition.identifier];
348
349 return (
350 <div className="flex items-center gap-2 rounded-md border border-border/70 bg-background/60 px-2 py-2">
351 <Toggle
352 pressed={condition.negated}
353 onPressedChange={(pressed) => onChange(setConditionNegated(node, pressed))}
354 aria-label={`Negate ${condition.identifier}`}
355 variant="outline"
356 size="xs"
357 className="h-7 min-w-10 px-2 text-[11px] sm:h-7"
358 >
359 Not
360 </Toggle>
361 <WhenVariableSelect
362 value={condition.identifier}
363 variables={variables}
364 unknownIdentifiers={unknownIdentifiers}
365 onChange={(value) => onChange(setConditionIdentifier(node, value))}
366 />
367 {onRemove ? (
368 <Button
369 type="button"
370 variant="ghost"
371 size="icon-sm"
372 className="size-7 sm:size-7"
373 aria-label="Remove condition"
374 onClick={onRemove}
375 >
376 <MinusIcon className="size-3.5" />
377 </Button>
378 ) : null}
379 </div>
380 );
381 }
382
383 if (node.type === "not") {
384 return (
385 <div
386 className={cn(
387 "space-y-2 rounded-lg border border-border/70 bg-muted/20 p-2",
388 depth > 0 && "border-border/50 bg-background/50",
389 )}
390 >
391 <div className="flex items-center gap-2">
392 <Toggle
393 pressed
394 onPressedChange={(pressed) => onChange(pressed ? node : node.node)}
395 aria-label="Negate group"
396 variant="outline"
397 size="xs"
398 className="h-7 min-w-10 px-2 text-[11px] sm:h-7"
399 >
400 Not
401 </Toggle>
402 {onRemove ? (
403 <Button
404 type="button"
405 variant="ghost"
406 size="icon-sm"
407 className="ml-auto size-7 sm:size-7"
408 aria-label="Remove negated group"
409 onClick={onRemove}
410 >
411 <MinusIcon className="size-3.5" />
412 </Button>
413 ) : null}
414 </div>
415 <div className="relative pl-4">
416 <span className="absolute top-0 bottom-0 left-1.5 w-px bg-border/70" aria-hidden />
417 <span className="absolute top-4 left-1.5 h-px w-2.5 bg-border/70" aria-hidden />
418 <WhenExpressionNodeEditor
419 node={node.node}
420 variables={variables}
421 depth={depth + 1}
422 onChange={(next) => onChange({ type: "not", node: next })}
423 />
424 </div>
425 </div>
426 );
427 }
428
429 const operator: BooleanOperator = node.type === "or" ? "or" : "and";
430 const children = flattenWhenChildren(node, operator);
431 const childKeyCounts = new Map<string, number>();
432 const childEntries = children.map((child) => {
433 const baseKey = `${child.type}-${whenAstToExpression(child)}`;
434 const count = childKeyCounts.get(baseKey) ?? 0;
435 childKeyCounts.set(baseKey, count + 1);
436 return { child, key: count === 0 ? baseKey : `${baseKey}-${count}` };
437 });
438
439 const updateChild = (target: KeybindingWhenNode, next: KeybindingWhenNode) => {
440 let didUpdate = false;
441 const nextChildren = children.map((child) => {
442 if (!didUpdate && child === target) {
443 didUpdate = true;
444 return next;
445 }
446 return child;
447 });
448 const nextNode = buildWhenExpressionGroup(nextChildren, operator);
449 if (nextNode) onChange(nextNode);
450 };
451
452 const removeChild = (target: KeybindingWhenNode) => {
453 let didRemove = false;
454 const nextChildren = children.filter((child) => {
455 if (!didRemove && child === target) {
456 didRemove = true;
457 return false;
458 }
459 return true;
460 });
461 const nextNode = buildWhenExpressionGroup(nextChildren, operator);
462 if (nextNode) {
463 onChange(nextNode);
464 } else {
465 onChange(defaultWhenCondition());
466 }
467 };
468
469 const setOperator = (nextOperator: BooleanOperator) => {
470 if (nextOperator === operator) return;
471 const nextNode = buildWhenExpressionGroup(children, nextOperator);
472 if (nextNode) onChange(nextNode);
473 };
474
475 const addCondition = () => {
476 const nextNode = buildWhenExpressionGroup([...children, defaultWhenCondition()], operator);
477 if (nextNode) onChange(nextNode);
478 };
479
480 const addGroup = () => {
481 const nestedOperator: BooleanOperator = operator === "and" ? "or" : "and";
482 const group: KeybindingWhenNode = {
483 type: nestedOperator,
484 left: defaultWhenCondition(),
485 right: { type: "not", node: defaultWhenCondition() },
486 };
487 const nextNode = buildWhenExpressionGroup([...children, group], operator);
488 if (nextNode) onChange(nextNode);
489 };
490
491 return (
492 <div
493 className={cn(
494 "space-y-2 rounded-lg border border-border/60 bg-muted/10 p-2",
495 depth > 0 && "border-border/70 bg-background/55",
496 )}
497 >
498 <div className="flex flex-wrap items-center gap-2">
499 <Select value={operator} onValueChange={(value) => setOperator(value as BooleanOperator)}>
500 <SelectTrigger size="xs" className="h-7 min-h-7 w-24 rounded-md text-xs sm:h-7">
501 <SelectValue />
502 </SelectTrigger>
503 <SelectContent
504 alignItemWithTrigger={false}
505 matchTriggerWidth={false}
506 popupClassName="w-fit"
507 className="w-fit min-w-24"
508 >
509 <SelectItem value="and" className="min-h-7 py-1 font-mono text-[12px]">
510 and
511 </SelectItem>
512 <SelectItem value="or" className="min-h-7 py-1 font-mono text-[12px]">
513 or
514 </SelectItem>
515 </SelectContent>
516 </Select>
517 <Button
518 type="button"
519 variant="outline"
520 size="xs"
521 className="h-7 sm:h-7"
522 onClick={addCondition}
523 >
524 <PlusIcon className="size-3.5" />
525 Condition
526 </Button>
527 <Button type="button" variant="outline" size="xs" className="h-7 sm:h-7" onClick={addGroup}>
528 <PlusIcon className="size-3.5" />
529 Group
530 </Button>
531 {onRemove ? (
532 <Button
533 type="button"
534 variant="ghost"
535 size="icon-sm"
536 className="ml-auto size-7 sm:size-7"
537 aria-label="Remove group"
538 onClick={onRemove}
539 >
540 <MinusIcon className="size-3.5" />
541 </Button>
542 ) : null}
543 </div>
544 <div className="space-y-2">
545 {childEntries.map(({ child, key }) => (
546 <div key={key} className="relative pl-4">
547 <span
548 className={cn(
549 "absolute top-0 bottom-0 left-1.5 w-px",
550 depth === 0 ? "bg-border" : "bg-border/70",
551 )}
552 aria-hidden
553 />
554 <span
555 className={cn(
556 "absolute top-4 left-1.5 h-px w-2.5",
557 depth === 0 ? "bg-border" : "bg-border/70",
558 )}
559 aria-hidden
560 />
561 <WhenExpressionNodeEditor
562 node={child}
563 variables={variables}
564 depth={depth + 1}
565 onChange={(next) => updateChild(child, next)}
566 onRemove={() => removeChild(child)}
567 />
568 </div>
569 ))}
570 </div>
571 </div>
572 );
573}
574
575function WhenExpressionBuilder({
576 value,
577 variables,
578 onChange,
579 onValidityChange,
580}: {
581 value: KeybindingWhenNode | undefined;
582 variables: ReadonlyArray<WhenVariableOption>;
583 onChange: (value: KeybindingWhenNode | undefined) => void;
584 onValidityChange?: (valid: boolean) => void;
585}) {
586 const expression = whenAstToExpression(value);
587 const [expressionDraft, setExpressionDraft] = useState(expression);
588 const parseResult = useMemo(() => parseWhenExpressionDraft(expressionDraft), [expressionDraft]);
589 const parseError = parseResult.ok ? null : parseResult.message;
590 const unknownIdentifiers = parseResult.ok ? unknownWhenVariables(parseResult.value) : [];
591
592 const updateExpressionDraft = (nextExpression: string) => {
593 setExpressionDraft(nextExpression);
594 const nextResult = parseWhenExpressionDraft(nextExpression);
595 onValidityChange?.(nextResult.ok);
596 if (nextResult.ok) {
597 onChange(nextResult.value);
598 }
599 };
600
601 const updateExpressionValue = (nextValue: KeybindingWhenNode | undefined) => {
602 setExpressionDraft(whenAstToExpression(nextValue));
603 onValidityChange?.(true);
604 onChange(nextValue);
605 };
606
607 const addRootCondition = () => {
608 if (!value) {
609 updateExpressionValue(defaultWhenCondition());
610 return;
611 }
612 updateExpressionValue({ type: "and", left: value, right: defaultWhenCondition() });
613 };
614
615 const addRootGroup = () => {
616 const group = defaultWhenGroup("or");
617 if (!value) {
618 updateExpressionValue(group);
619 return;
620 }
621 updateExpressionValue({ type: "and", left: value, right: group });
622 };
623
624 return (
625 <div className="w-[min(34rem,calc(100vw-2rem))] space-y-3">
626 <div className="flex items-start justify-between gap-3">
627 <div className="min-w-0">
628 <div className="text-sm font-medium text-foreground">When</div>
629 </div>
630 <div className="flex shrink-0 items-center gap-2">
631 <Button
632 type="button"
633 variant="outline"
634 size="xs"
635 className="h-7 sm:h-7"
636 onClick={addRootCondition}
637 >
638 <PlusIcon className="size-3.5" />
639 Condition
640 </Button>
641 <Button
642 type="button"
643 variant="outline"
644 size="xs"
645 className="h-7 sm:h-7"
646 onClick={addRootGroup}
647 >
648 <PlusIcon className="size-3.5" />
649 Group
650 </Button>
651 </div>
652 </div>
653
654 <div className="space-y-1.5">
655 <div className="relative">
656 <Input
657 value={expressionDraft}
658 onChange={(event) => updateExpressionDraft(event.currentTarget.value)}
659 placeholder="Always"
660 aria-invalid={Boolean(parseError)}
661 aria-label="When expression"
662 className={cn(
663 "h-7 rounded-md font-mono text-[12px] leading-7 sm:h-7 sm:leading-7",
664 unknownIdentifiers.length > 0 && "pr-9",
665 parseError && "border-destructive/70 focus-visible:border-destructive",
666 )}
667 />
668 {unknownIdentifiers.length > 0 ? (
669 <span className="absolute inset-y-0 right-2 flex items-center">
670 <UnknownWhenVariableWarning identifiers={unknownIdentifiers} />
671 </span>
672 ) : null}
673 </div>
674 {parseError ? (
675 <div className="flex items-center gap-1.5 text-[11px] text-destructive">
676 <CircleXIcon className="size-3.5" />
677 {parseError}
678 </div>
679 ) : null}
680 </div>
681
682 <div className="relative">
683 {value ? (
684 <WhenExpressionNodeEditor
685 node={value}
686 variables={variables}
687 onChange={updateExpressionValue}
688 onRemove={() => updateExpressionValue(undefined)}
689 />
690 ) : (
691 <div className="rounded-md border border-dashed border-border/80 bg-muted/15 p-3">
692 <div className="flex flex-wrap gap-2">
693 <Button type="button" size="xs" className="h-7 sm:h-7" onClick={addRootCondition}>
694 <PlusIcon className="size-3.5" />
695 Condition
696 </Button>
697 <Button
698 type="button"
699 variant="outline"
700 size="xs"
701 className="h-7 sm:h-7"
702 onClick={addRootGroup}
703 >
704 <PlusIcon className="size-3.5" />
705 Group
706 </Button>
707 </div>
708 </div>
709 )}
710 {parseError ? (
711 <div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-lg border border-destructive/30 bg-background/75 p-4 text-center text-xs text-destructive backdrop-blur-[1px]">
712 Fix the expression above to continue editing visually.
713 </div>
714 ) : null}
715 </div>
716 </div>
717 );
718}
719
720type KeybindingRowDraftState = {
721 keyDraft: string;
722 whenDraft: KeybindingWhenNode | undefined;
723 isRecording: boolean;
724 isWhenDraftValid: boolean;
725};
726
727function createKeybindingRowDraft(row: KeybindingRow): KeybindingRowDraftState {
728 return {
729 keyDraft: row.key,
730 whenDraft: row.binding.whenAst,
731 isRecording: false,
732 isWhenDraftValid: true,
733 };
734}
735
736function keybindingRowDraftReducer(
737 state: KeybindingRowDraftState,
738 patch: Partial<KeybindingRowDraftState>,
739): KeybindingRowDraftState {
740 return { ...state, ...patch };
741}
742
743function rowKeybindingTarget(row: KeybindingRow): ServerRemoveKeybindingInput {
744 return {
745 command: row.command,
746 key: row.key,
747 ...(row.when.trim().length > 0 ? { when: row.when } : {}),
748 };
749}
750
751function KeybindingTableRow({
752 row,
753 allRows,
754 variables,
755 isSaving,
756 onSave,
757 onReset,
758 onRemove,
759}: {
760 row: KeybindingRow;
761 allRows: ReadonlyArray<KeybindingRow>;
762 variables: ReadonlyArray<WhenVariableOption>;
763 isSaving: boolean;
764 onSave: (input: ServerUpsertKeybindingInput) => void;
765 onReset: (row: KeybindingRow) => void;
766 onRemove: (row: KeybindingRow) => void;
767}) {
768 const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft);
769 const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft;
770 const whenDraftExpression = whenAstToExpression(whenDraft);
771 const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when;
772 const displayShortcut = formatShortcutLabel(row.binding.shortcut);
773 const canReset = row.source === "Custom" && row.defaultKey !== null;
774 const canRemove = row.source !== "Default";
775 const hasRowActions = canReset || canRemove;
776 const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty;
777 const conflictLabels = keybindingConflictLabels(allRows, {
778 rowId: row.id,
779 key: keyDraft,
780 when: whenDraftExpression,
781 });
782
783 const save = () => {
784 onSave({
785 command: row.command,
786 key: keyDraft,
787 when: whenDraftExpression.trim().length > 0 ? whenDraftExpression : undefined,
788 replace: rowKeybindingTarget(row),
789 });
790 };
791
792 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
793 if (event.key === "Tab") return;
794 event.preventDefault();
795 if (event.key === "Escape") {
796 setDraft({ keyDraft: row.key, isRecording: false });
797 return;
798 }
799 const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform);
800 if (!next) return;
801 setDraft({ keyDraft: next, isRecording: false });
802 };
803
804 return (
805 <div className="grid grid-cols-[minmax(190px,1.1fr)_minmax(220px,0.85fr)_minmax(210px,1fr)_60px] items-center px-4 py-1.5 text-sm even:bg-muted/15 hover:bg-accent/40">
806 <div className="min-w-0 pr-4">
807 <div className="flex min-w-0 items-center gap-1.5">
808 <div className="truncate text-[13px] font-medium text-foreground" title={row.command}>
809 {commandLabel(row.command)}
810 </div>
811 </div>
812 </div>
813 <div className="flex min-w-0 items-center gap-2 pr-4">
814 {showPill ? (
815 <button
816 type="button"
817 onClick={() => setDraft({ isRecording: true })}
818 aria-label={`Edit shortcut for ${commandLabel(row.command)}`}
819 className="group inline-flex h-7 items-center gap-1.5 rounded-md border border-transparent px-1.5 outline-none transition-colors hover:border-border/70 hover:bg-background focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24"
820 >
821 <KeybindingPill value={row.key} />
822 <span className="text-[10px] uppercase tracking-[0.08em] text-muted-foreground/0 transition-opacity group-hover:text-muted-foreground/70 group-focus-visible:text-muted-foreground/70">
823 Edit
824 </span>
825 </button>
826 ) : (
827 <Input
828 autoFocus={isRecording}
829 aria-label={`Keybinding for ${commandLabel(row.command)}`}
830 value={isRecording ? "" : keyDraft}
831 placeholder={isRecording ? "Press shortcut" : "Unassigned"}
832 className={cn(
833 "h-7 w-44 rounded-md font-mono text-[12px] sm:h-7",
834 isRecording && "border-primary/70 bg-primary/5",
835 )}
836 onFocus={() => setDraft({ isRecording: true })}
837 onBlur={() => setDraft({ isRecording: false })}
838 onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })}
839 onKeyDown={captureKeybinding}
840 />
841 )}
842 {isDirty ? (
843 <Button
844 size="xs"
845 className="h-7 sm:h-7"
846 disabled={isSaving || keyDraft.trim().length === 0 || !isWhenDraftValid}
847 onClick={save}
848 >
849 {isSaving ? "Saving" : "Save"}
850 </Button>
851 ) : null}
852 </div>
853 <div className="pr-4">
854 <Popover>
855 <PopoverTrigger
856 className={cn(
857 "inline-flex h-7 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-2.5 text-left font-mono text-[12px] text-foreground shadow-xs/5 outline-none transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24",
858 !whenDraftExpression && "text-muted-foreground",
859 )}
860 aria-label={`Edit when clause for ${commandLabel(row.command)}`}
861 >
862 <span className="truncate">{whenDraftExpression || "Always"}</span>
863 <ChevronDownIcon className="size-3.5 shrink-0 opacity-60" />
864 </PopoverTrigger>
865 <PopoverContent align="start" sideOffset={6}>
866 <WhenExpressionBuilder
867 value={whenDraft}
868 variables={variables}
869 onChange={(nextWhenDraft) => setDraft({ whenDraft: nextWhenDraft })}
870 onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })}
871 />
872 </PopoverContent>
873 </Popover>
874 </div>
875 <div className="flex items-center justify-end gap-1">
876 <KeybindingConflictWarning labels={conflictLabels} />
877 {hasRowActions ? (
878 <Menu>
879 <MenuTrigger
880 render={
881 <Button
882 type="button"
883 variant="ghost"
884 size="icon-sm"
885 className="size-7 text-muted-foreground hover:text-foreground sm:size-7"
886 disabled={isSaving}
887 aria-label={`Actions for ${commandLabel(row.command)}`}
888 />
889 }
890 >
891 <EllipsisIcon className="size-3.5" />
892 </MenuTrigger>
893 <MenuPopup align="end" className="min-w-36">
894 {canReset ? (
895 <MenuItem disabled={isSaving} onClick={() => onReset(row)}>
896 Reset to default
897 </MenuItem>
898 ) : null}
899 {canRemove ? (
900 <MenuItem variant="destructive" disabled={isSaving} onClick={() => onRemove(row)}>
901 Remove
902 </MenuItem>
903 ) : null}
904 </MenuPopup>
905 </Menu>
906 ) : null}
907 <span className="sr-only">{displayShortcut}</span>
908 </div>
909 </div>
910 );
911}
912
913function NewKeybindingTableRow({
914 commandOptions,
915 allRows,
916 variables,
917 isSaving,
918 onSave,
919 onCancel,
920}: {
921 commandOptions: ReadonlyArray<KeybindingCommandOption>;
922 allRows: ReadonlyArray<KeybindingRow>;
923 variables: ReadonlyArray<WhenVariableOption>;
924 isSaving: boolean;
925 onSave: (input: ServerUpsertKeybindingInput) => void;
926 onCancel: () => void;
927}) {
928 const [commandDraft, setCommandDraft] = useState<KeybindingCommand | "">("");
929 const [draft, setDraft] = useReducer(keybindingRowDraftReducer, {
930 keyDraft: "",
931 whenDraft: undefined,
932 isRecording: false,
933 isWhenDraftValid: true,
934 });
935 const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft;
936 const whenDraftExpression = whenAstToExpression(whenDraft);
937 const conflictLabels = keybindingConflictLabels(allRows, {
938 rowId: "new",
939 key: keyDraft,
940 when: whenDraftExpression,
941 });
942 const commandLabelText = commandDraft ? commandLabel(commandDraft) : "new keybinding";
943
944 const save = () => {
945 if (!commandDraft) return;
946 onSave({
947 command: commandDraft,
948 key: keyDraft,
949 ...(whenDraftExpression.trim().length > 0 ? { when: whenDraftExpression } : {}),
950 });
951 };
952
953 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
954 if (event.key === "Tab") return;
955 event.preventDefault();
956 if (event.key === "Escape") {
957 setDraft({ keyDraft: "", isRecording: false });
958 return;
959 }
960 const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform);
961 if (!next) return;
962 setDraft({ keyDraft: next, isRecording: false });
963 };
964
965 return (
966 <div className="grid grid-cols-[minmax(190px,1.1fr)_minmax(220px,0.85fr)_minmax(210px,1fr)_60px] items-center px-4 py-1.5 text-sm even:bg-muted/15 hover:bg-accent/40">
967 <div className="min-w-0 pr-4">
968 <Select
969 value={commandDraft}
970 onValueChange={(value) => setCommandDraft(value as KeybindingCommand)}
971 >
972 <SelectTrigger
973 size="xs"
974 className="h-7 min-h-7 w-full max-w-60 rounded-md text-xs sm:h-7"
975 >
976 <SelectValue placeholder="Command" />
977 </SelectTrigger>
978 <SelectContent
979 alignItemWithTrigger={false}
980 matchTriggerWidth={false}
981 className="max-h-72 w-fit min-w-56"
982 >
983 {commandOptions.map((command) => (
984 <SelectItem key={command} value={command} className="min-h-7 w-full py-1 text-[12px]">
985 <span className="truncate">{commandLabel(command)}</span>
986 </SelectItem>
987 ))}
988 </SelectContent>
989 </Select>
990 </div>
991 <div className="flex min-w-0 items-center gap-2 pr-4">
992 <Input
993 aria-label={`Keybinding for ${commandLabelText}`}
994 value={isRecording ? "" : keyDraft}
995 placeholder={isRecording ? "Press shortcut" : "Unassigned"}
996 className={cn(
997 "h-7 w-44 rounded-md font-mono text-[12px] sm:h-7",
998 isRecording && "border-primary/70 bg-primary/5",
999 )}
1000 onFocus={() => setDraft({ isRecording: true })}
1001 onBlur={() => setDraft({ isRecording: false })}
1002 onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })}
1003 onKeyDown={captureKeybinding}
1004 />
1005 <Button
1006 size="xs"
1007 className="h-7 sm:h-7"
1008 disabled={isSaving || !commandDraft || keyDraft.trim().length === 0 || !isWhenDraftValid}
1009 onClick={save}
1010 >
1011 {isSaving ? "Saving" : "Save"}
1012 </Button>
1013 </div>
1014 <div className="pr-4">
1015 <Popover>
1016 <PopoverTrigger
1017 className={cn(
1018 "inline-flex h-7 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-2.5 text-left font-mono text-[12px] text-foreground shadow-xs/5 outline-none transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24",
1019 !whenDraftExpression && "text-muted-foreground",
1020 )}
1021 aria-label={`Edit when clause for ${commandLabelText}`}
1022 >
1023 <span className="truncate">{whenDraftExpression || "Always"}</span>
1024 <ChevronDownIcon className="size-3.5 shrink-0 opacity-60" />
1025 </PopoverTrigger>
1026 <PopoverContent align="start" sideOffset={6}>
1027 <WhenExpressionBuilder
1028 value={whenDraft}
1029 variables={variables}
1030 onChange={(nextWhenDraft) => setDraft({ whenDraft: nextWhenDraft })}
1031 onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })}
1032 />
1033 </PopoverContent>
1034 </Popover>
1035 </div>
1036 <div className="flex items-center justify-end gap-1">
1037 <KeybindingConflictWarning labels={conflictLabels} />
1038 <Tooltip>
1039 <TooltipTrigger
1040 render={
1041 <Button
1042 type="button"
1043 variant="ghost"
1044 size="icon-sm"
1045 className="size-7 text-muted-foreground hover:text-foreground sm:size-7"
1046 disabled={isSaving}
1047 aria-label="Cancel new keybinding"
1048 onClick={onCancel}
1049 />
1050 }
1051 >
1052 <XIcon className="size-3.5" />
1053 </TooltipTrigger>
1054 <TooltipPopup side="top">Cancel</TooltipPopup>
1055 </Tooltip>
1056 </div>
1057 </div>
1058 );
1059}
1060
1061export function KeybindingsSettingsPanel() {
1062 const keybindings = useServerKeybindings();
1063 const keybindingsConfigPath = useServerKeybindingsConfigPath();
1064 const [query, setQuery] = useState("");
1065 const [isSearchOpen, setIsSearchOpen] = useState(false);
1066 const searchInputRef = useRef<HTMLInputElement>(null);
1067 const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null);
1068 const [isAddingBinding, setIsAddingBinding] = useState(false);
1069 const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]);
1070 const commandOptions = useMemo(() => buildKeybindingCommandOptions(keybindings), [keybindings]);
1071 const whenVariables = useMemo(() => buildWhenVariableOptions(), []);
1072
1073 useEffect(() => {
1074 const handleKeyDown = (event: globalThis.KeyboardEvent) => {
1075 const isMod = event.metaKey || event.ctrlKey;
1076 if (!isMod || event.altKey || event.key.toLowerCase() !== "f") return;
1077
1078 const target = event.target;
1079 if (
1080 target !== searchInputRef.current &&
1081 target instanceof HTMLElement &&
1082 (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)
1083 ) {
1084 return;
1085 }
1086
1087 event.preventDefault();
1088 setIsSearchOpen(true);
1089 requestAnimationFrame(() => {
1090 searchInputRef.current?.focus();
1091 searchInputRef.current?.select();
1092 });
1093 };
1094 window.addEventListener("keydown", handleKeyDown);
1095 return () => window.removeEventListener("keydown", handleKeyDown);
1096 }, []);
1097
1098 const openKeybindingsFile = useCallback(() => {
1099 if (!keybindingsConfigPath) return;
1100 void openInPreferredEditor(ensureLocalApi(), keybindingsConfigPath).catch((error: unknown) => {
1101 toastManager.add({
1102 title: "Unable to open keybindings file",
1103 description:
1104 error instanceof Error ? error.message : "The keybindings file was not opened.",
1105 type: "error",
1106 });
1107 });
1108 }, [keybindingsConfigPath]);
1109
1110 const saveKeybinding = useCallback((input: ServerUpsertKeybindingInput) => {
1111 setSavingCommand(input.command);
1112 const payload: ServerUpsertKeybindingInput = {
1113 command: input.command,
1114 key: input.key.trim(),
1115 ...(input.when?.trim() ? { when: input.when.trim() } : {}),
1116 ...(input.replace ? { replace: input.replace } : {}),
1117 };
1118 void ensureLocalApi()
1119 .server.upsertKeybinding(payload)
1120 .then(() => {
1121 setIsAddingBinding(false);
1122 })
1123 .catch((error: unknown) => {
1124 toastManager.add({
1125 title: "Unable to save keybinding",
1126 description: error instanceof Error ? error.message : "The keybinding was not saved.",
1127 type: "error",
1128 });
1129 })
1130 .finally(() => {
1131 setSavingCommand(null);
1132 });
1133 }, []);
1134
1135 const removeKeybinding = useCallback((row: KeybindingRow) => {
1136 setSavingCommand(row.command);
1137 void ensureLocalApi()
1138 .server.removeKeybinding(rowKeybindingTarget(row))
1139 .catch((error: unknown) => {
1140 toastManager.add({
1141 title: "Unable to remove keybinding",
1142 description: error instanceof Error ? error.message : "The keybinding was not removed.",
1143 type: "error",
1144 });
1145 })
1146 .finally(() => {
1147 setSavingCommand(null);
1148 });
1149 }, []);
1150
1151 const resetKeybinding = useCallback(
1152 (row: KeybindingRow) => {
1153 if (!row.defaultKey) return;
1154 saveKeybinding({
1155 command: row.command,
1156 key: row.defaultKey,
1157 when: row.defaultWhen.trim().length > 0 ? row.defaultWhen : undefined,
1158 replace: {
1159 command: row.command,
1160 key: row.key,
1161 ...(row.when.trim().length > 0 ? { when: row.when } : {}),
1162 },
1163 });
1164 },
1165 [saveKeybinding],
1166 );
1167
1168 const bindingsCount = (
1169 <span className="text-[11px] text-muted-foreground">
1170 {rows.length + (isAddingBinding ? 1 : 0)}{" "}
1171 {rows.length + (isAddingBinding ? 1 : 0) === 1 ? "binding" : "bindings"}
1172 </span>
1173 );
1174
1175 return (
1176 <SettingsPageContainer className="max-w-5xl">
1177 <SettingsSection
1178 title="Keybindings"
1179 headerAction={
1180 <div className="flex items-center gap-1.5">
1181 <ExpandableHeaderSearch
1182 query={query}
1183 onChange={setQuery}
1184 isOpen={isSearchOpen}
1185 onOpenChange={setIsSearchOpen}
1186 inputRef={searchInputRef}
1187 collapsedAccessory={bindingsCount}
1188 />
1189 <Tooltip>
1190 <TooltipTrigger
1191 render={
1192 <Button
1193 type="button"
1194 size="icon-xs"
1195 variant="ghost"
1196 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
1197 onClick={() => setIsAddingBinding(true)}
1198 aria-label="Add keybinding"
1199 >
1200 <PlusIcon className="size-3" />
1201 </Button>
1202 }
1203 />
1204 <TooltipPopup side="top">Add keybinding</TooltipPopup>
1205 </Tooltip>
1206 <Tooltip>
1207 <TooltipTrigger
1208 render={
1209 <Button
1210 type="button"
1211 size="icon-xs"
1212 variant="ghost"
1213 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
1214 disabled={!keybindingsConfigPath}
1215 onClick={openKeybindingsFile}
1216 aria-label="Open keybindings.json"
1217 >
1218 <FileJsonIcon className="size-3" />
1219 </Button>
1220 }
1221 />
1222 <TooltipPopup side="top">Open keybindings.json</TooltipPopup>
1223 </Tooltip>
1224 </div>
1225 }
1226 >
1227 {!isElectron ? (
1228 <div className="flex items-start gap-2 border-b border-warning/20 bg-warning/5 px-3 py-2.5 text-[12px] leading-relaxed text-muted-foreground sm:px-4">
1229 <InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" />
1230 <p>
1231 Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop
1232 app for better keybinding support.
1233 </p>
1234 </div>
1235 ) : null}
1236
1237 <ScrollArea
1238 chainVerticalScroll
1239 scrollFade
1240 hideScrollbars
1241 className="w-full max-w-full rounded-none"
1242 >
1243 <div className="grid min-w-[680px] grid-cols-[minmax(190px,1.1fr)_minmax(220px,0.85fr)_minmax(210px,1fr)_60px] border-b border-border/70 bg-muted/25 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">
1244 <div>Command</div>
1245 <div>Keybinding</div>
1246 <div>When</div>
1247 <div>Status</div>
1248 </div>
1249 <div className="min-w-[680px] divide-y divide-border/60">
1250 {isAddingBinding ? (
1251 <NewKeybindingTableRow
1252 commandOptions={commandOptions}
1253 allRows={rows}
1254 variables={whenVariables}
1255 isSaving={savingCommand !== null}
1256 onSave={saveKeybinding}
1257 onCancel={() => setIsAddingBinding(false)}
1258 />
1259 ) : null}
1260 {rows.map((row) => (
1261 <KeybindingTableRow
1262 key={row.id}
1263 row={row}
1264 allRows={rows}
1265 variables={whenVariables}
1266 isSaving={savingCommand === row.command}
1267 onSave={saveKeybinding}
1268 onReset={resetKeybinding}
1269 onRemove={removeKeybinding}
1270 />
1271 ))}
1272 {rows.length === 0 && !isAddingBinding ? (
1273 <div className="px-4 py-12 text-center text-sm text-muted-foreground">
1274 No keybindings match your search.
1275 </div>
1276 ) : null}
1277 </div>
1278 </ScrollArea>
1279 </SettingsSection>
1280 </SettingsPageContainer>
1281 );
1282}
apps/web/src/components/settings/SettingsPanels.tsx+2-40
......@@ -77,7 +77,6 @@ import {
7777import { ProjectFavicon } from "../ProjectFavicon";
7878import {
7979 useServerAvailableEditors,
80 useServerKeybindingsConfigPath,
8180 useServerObservability,
8281 useServerProviders,
8382} from "../../rpc/serverState";
......@@ -444,13 +443,11 @@ export function GeneralSettingsPanel() {
444443 const settings = useSettings();
445444 const { updateSettings } = useUpdateSettings();
446445 const [openingPathByTarget, setOpeningPathByTarget] = useState({
447 keybindings: false,
448446 logsDirectory: false,
449447 });
450448 const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState<
451 Partial<Record<"keybindings" | "logsDirectory", string | null>>
449 Partial<Record<"logsDirectory", string | null>>
452450 >({});
453 const keybindingsConfigPath = useServerKeybindingsConfigPath();
454451 const availableEditors = useServerAvailableEditors();
455452 const observability = useServerObservability();
456453 const serverProviders = useServerProviders();
......@@ -491,7 +488,7 @@ export function GeneralSettingsPanel() {
491488 );
492489
493490 const openInPreferredEditor = useCallback(
494 (target: "keybindings" | "logsDirectory", path: string | null, failureMessage: string) => {
491 (target: "logsDirectory", path: string | null, failureMessage: string) => {
495492 if (!path) return;
496493 setOpenPathErrorByTarget((existing) => ({ ...existing, [target]: null }));
497494 setOpeningPathByTarget((existing) => ({ ...existing, [target]: true }));
......@@ -521,17 +518,11 @@ export function GeneralSettingsPanel() {
521518 [availableEditors],
522519 );
523520
524 const openKeybindingsFile = useCallback(() => {
525 openInPreferredEditor("keybindings", keybindingsConfigPath, "Unable to open keybindings file.");
526 }, [keybindingsConfigPath, openInPreferredEditor]);
527
528521 const openLogsDirectory = useCallback(() => {
529522 openInPreferredEditor("logsDirectory", logsDirectoryPath, "Unable to open logs folder.");
530523 }, [logsDirectoryPath, openInPreferredEditor]);
531524
532 const openKeybindingsError = openPathErrorByTarget.keybindings ?? null;
533525 const openDiagnosticsError = openPathErrorByTarget.logsDirectory ?? null;
534 const isOpeningKeybindings = openingPathByTarget.keybindings;
535526 const isOpeningLogsDirectory = openingPathByTarget.logsDirectory;
536527
537528 return (
......@@ -911,35 +902,6 @@ export function GeneralSettingsPanel() {
911902 />
912903 </SettingsSection>
913904
914 <SettingsSection title="Advanced">
915 <SettingsRow
916 title="Keybindings"
917 description="Open the persisted `keybindings.json` file to edit advanced bindings directly."
918 status={
919 <>
920 <span className="block break-all font-mono text-[11px] text-foreground">
921 {keybindingsConfigPath ?? "Resolving keybindings path..."}
922 </span>
923 {openKeybindingsError ? (
924 <span className="mt-1 block text-destructive">{openKeybindingsError}</span>
925 ) : (
926 <span className="mt-1 block">Opens in your preferred editor.</span>
927 )}
928 </>
929 }
930 control={
931 <Button
932 size="xs"
933 variant="outline"
934 disabled={!keybindingsConfigPath || isOpeningKeybindings}
935 onClick={openKeybindingsFile}
936 >
937 {isOpeningKeybindings ? "Opening..." : "Open file"}
938 </Button>
939 }
940 />
941 </SettingsSection>
942
943905 <SettingsSection title="About">
944906 {isElectron ? (
945907 <AboutVersionSection />
apps/web/src/components/settings/SettingsSidebarNav.tsx+3
......@@ -4,6 +4,7 @@ import {
44 ArrowLeftIcon,
55 BotIcon,
66 GitBranchIcon,
7 KeyboardIcon,
78 Link2Icon,
89 Settings2Icon,
910} from "lucide-react";
......@@ -22,6 +23,7 @@ import {
2223
2324export type SettingsSectionPath =
2425 | "/settings/general"
26 | "/settings/keybindings"
2527 | "/settings/providers"
2628 | "/settings/source-control"
2729 | "/settings/connections"
......@@ -33,6 +35,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
3335 icon: ComponentType<{ className?: string }>;
3436}> = [
3537 { label: "General", to: "/settings/general", icon: Settings2Icon },
38 { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon },
3639 { label: "Providers", to: "/settings/providers", icon: BotIcon },
3740 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },
3841 { label: "Connections", to: "/settings/connections", icon: Link2Icon },
apps/web/src/components/settings/settingsLayout.tsx+10-2
......@@ -115,10 +115,18 @@ export function SettingResetButton({ label, onClick }: { label: string; onClick:
115115 );
116116}
117117
118export function SettingsPageContainer({ children }: { children: ReactNode }) {
118export function SettingsPageContainer({
119 children,
120 className,
121}: {
122 children: ReactNode;
123 className?: string;
124}) {
119125 return (
120126 <div className="flex-1 overflow-y-auto p-6 sm:p-8">
121 <div className="mx-auto flex w-full max-w-3xl flex-col gap-8">{children}</div>
127 <div className={cn("mx-auto flex w-full max-w-3xl flex-col gap-8", className)}>
128 {children}
129 </div>
122130 </div>
123131 );
124132}
apps/web/src/components/ui/scroll-area.tsx+4-1
......@@ -10,11 +10,13 @@ function ScrollArea({
1010 scrollFade = false,
1111 scrollbarGutter = false,
1212 hideScrollbars = false,
13 chainVerticalScroll = false,
1314 ...props
1415}: ScrollAreaPrimitive.Root.Props & {
1516 scrollFade?: boolean;
1617 scrollbarGutter?: boolean;
1718 hideScrollbars?: boolean;
19 chainVerticalScroll?: boolean;
1820}) {
1921 return (
2022 <ScrollAreaPrimitive.Root
......@@ -23,7 +25,8 @@ function ScrollArea({
2325 >
2426 <ScrollAreaPrimitive.Viewport
2527 className={cn(
26 "h-full max-h-[inherit] overflow-y-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain",
28 "h-full max-h-[inherit] overflow-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain",
29 chainVerticalScroll && "overscroll-y-auto",
2730 scrollFade &&
2831 "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",
2932 scrollbarGutter && "[scrollbar-gutter:stable]",
apps/web/src/components/ui/select.tsx+11-1
......@@ -105,20 +105,24 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
105105
106106function SelectPopup({
107107 className,
108 popupClassName,
108109 children,
109110 side = "bottom",
110111 sideOffset = 4,
111112 align = "start",
112113 alignOffset = 0,
113114 alignItemWithTrigger = true,
115 matchTriggerWidth = true,
114116 anchor,
115117 ...props
116118}: SelectPrimitive.Popup.Props & {
119 popupClassName?: string;
117120 side?: SelectPrimitive.Positioner.Props["side"];
118121 sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"];
119122 align?: SelectPrimitive.Positioner.Props["align"];
120123 alignOffset?: SelectPrimitive.Positioner.Props["alignOffset"];
121124 alignItemWithTrigger?: SelectPrimitive.Positioner.Props["alignItemWithTrigger"];
125 matchTriggerWidth?: boolean;
122126 anchor?: SelectPrimitive.Positioner.Props["anchor"];
123127}) {
124128 return (
......@@ -144,7 +148,13 @@ function SelectPopup({
144148 >
145149 <ChevronUpIcon className="relative size-4.5 sm:size-4" />
146150 </SelectPrimitive.ScrollUpArrow>
147 <div className="relative h-full min-w-(--anchor-width) rounded-lg border bg-popover not-dark:bg-clip-padding shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]">
151 <div
152 className={cn(
153 "relative h-full rounded-lg border bg-popover not-dark:bg-clip-padding shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
154 matchTriggerWidth && "min-w-(--anchor-width)",
155 popupClassName,
156 )}
157 >
148158 <SelectPrimitive.List
149159 className={cn("max-h-(--available-height) overflow-y-auto p-1", className)}
150160 data-slot="select-list"
apps/web/src/localApi.ts+4
......@@ -133,6 +133,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi {
133133 rpcClient
134134 ? rpcClient.server.upsertKeybinding(input)
135135 : Promise.reject(unavailableLocalBackendError()),
136 removeKeybinding: (input) =>
137 rpcClient
138 ? rpcClient.server.removeKeybinding(input)
139 : Promise.reject(unavailableLocalBackendError()),
136140 getSettings: () =>
137141 rpcClient ? rpcClient.server.getSettings() : Promise.reject(unavailableLocalBackendError()),
138142 updateSettings: (patch) =>
apps/web/src/routeTree.gen.ts+21
......@@ -15,6 +15,7 @@ import { Route as ChatRouteImport } from './routes/_chat'
1515import { Route as ChatIndexRouteImport } from './routes/_chat.index'
1616import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
1717import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
18import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'
1819import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
1920import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
2021import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
......@@ -50,6 +51,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({
5051 path: '/providers',
5152 getParentRoute: () => SettingsRoute,
5253} as any)
54const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({
55 id: '/keybindings',
56 path: '/keybindings',
57 getParentRoute: () => SettingsRoute,
58} as any)
5359const SettingsGeneralRoute = SettingsGeneralRouteImport.update({
5460 id: '/general',
5561 path: '/general',
......@@ -84,6 +90,7 @@ export interface FileRoutesByFullPath {
8490 '/settings/archived': typeof SettingsArchivedRoute
8591 '/settings/connections': typeof SettingsConnectionsRoute
8692 '/settings/general': typeof SettingsGeneralRoute
93 '/settings/keybindings': typeof SettingsKeybindingsRoute
8794 '/settings/providers': typeof SettingsProvidersRoute
8895 '/settings/source-control': typeof SettingsSourceControlRoute
8996 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
......@@ -95,6 +102,7 @@ export interface FileRoutesByTo {
95102 '/settings/archived': typeof SettingsArchivedRoute
96103 '/settings/connections': typeof SettingsConnectionsRoute
97104 '/settings/general': typeof SettingsGeneralRoute
105 '/settings/keybindings': typeof SettingsKeybindingsRoute
98106 '/settings/providers': typeof SettingsProvidersRoute
99107 '/settings/source-control': typeof SettingsSourceControlRoute
100108 '/': typeof ChatIndexRoute
......@@ -109,6 +117,7 @@ export interface FileRoutesById {
109117 '/settings/archived': typeof SettingsArchivedRoute
110118 '/settings/connections': typeof SettingsConnectionsRoute
111119 '/settings/general': typeof SettingsGeneralRoute
120 '/settings/keybindings': typeof SettingsKeybindingsRoute
112121 '/settings/providers': typeof SettingsProvidersRoute
113122 '/settings/source-control': typeof SettingsSourceControlRoute
114123 '/_chat/': typeof ChatIndexRoute
......@@ -124,6 +133,7 @@ export interface FileRouteTypes {
124133 | '/settings/archived'
125134 | '/settings/connections'
126135 | '/settings/general'
136 | '/settings/keybindings'
127137 | '/settings/providers'
128138 | '/settings/source-control'
129139 | '/$environmentId/$threadId'
......@@ -135,6 +145,7 @@ export interface FileRouteTypes {
135145 | '/settings/archived'
136146 | '/settings/connections'
137147 | '/settings/general'
148 | '/settings/keybindings'
138149 | '/settings/providers'
139150 | '/settings/source-control'
140151 | '/'
......@@ -148,6 +159,7 @@ export interface FileRouteTypes {
148159 | '/settings/archived'
149160 | '/settings/connections'
150161 | '/settings/general'
162 | '/settings/keybindings'
151163 | '/settings/providers'
152164 | '/settings/source-control'
153165 | '/_chat/'
......@@ -205,6 +217,13 @@ declare module '@tanstack/react-router' {
205217 preLoaderRoute: typeof SettingsProvidersRouteImport
206218 parentRoute: typeof SettingsRoute
207219 }
220 '/settings/keybindings': {
221 id: '/settings/keybindings'
222 path: '/keybindings'
223 fullPath: '/settings/keybindings'
224 preLoaderRoute: typeof SettingsKeybindingsRouteImport
225 parentRoute: typeof SettingsRoute
226 }
208227 '/settings/general': {
209228 id: '/settings/general'
210229 path: '/general'
......@@ -261,6 +280,7 @@ interface SettingsRouteChildren {
261280 SettingsArchivedRoute: typeof SettingsArchivedRoute
262281 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
263282 SettingsGeneralRoute: typeof SettingsGeneralRoute
283 SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute
264284 SettingsProvidersRoute: typeof SettingsProvidersRoute
265285 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
266286}
......@@ -269,6 +289,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
269289 SettingsArchivedRoute: SettingsArchivedRoute,
270290 SettingsConnectionsRoute: SettingsConnectionsRoute,
271291 SettingsGeneralRoute: SettingsGeneralRoute,
292 SettingsKeybindingsRoute: SettingsKeybindingsRoute,
272293 SettingsProvidersRoute: SettingsProvidersRoute,
273294 SettingsSourceControlRoute: SettingsSourceControlRoute,
274295}
apps/web/src/routes/__root.tsx+6
......@@ -288,6 +288,7 @@ function EventRouter() {
288288 const readPathname = useEffectEvent(() => pathname);
289289 const handledBootstrapThreadIdRef = useRef<string | null>(null);
290290 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);
291 const lastKeybindingsSuccessToastAtRef = useRef(0);
291292 const disposedRef = useRef(false);
292293 const serverConfig = useServerConfig();
293294
......@@ -354,6 +355,11 @@ function EventRouter() {
354355
355356 const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings."));
356357 if (!issue) {
358 const now = Date.now();
359 if (now - lastKeybindingsSuccessToastAtRef.current < 2_000) {
360 return;
361 }
362 lastKeybindingsSuccessToastAtRef.current = now;
357363 toastManager.add({
358364 type: "success",
359365 title: "Keybindings updated",
apps/web/src/routes/settings.keybindings.tsx created+7
......@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { KeybindingsSettingsPanel } from "../components/settings/KeybindingsSettings";
4
5export const Route = createFileRoute("/settings/keybindings")({
6 component: KeybindingsSettingsPanel,
7});
apps/web/src/rpc/wsRpcClient.ts+3
......@@ -121,6 +121,7 @@ export interface WsRpcClient {
121121 ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>;
122122 readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>;
123123 readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>;
124 readonly removeKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverRemoveKeybinding>;
124125 readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>;
125126 readonly updateSettings: (
126127 patch: ServerSettingsPatch,
......@@ -239,6 +240,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
239240 transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)),
240241 upsertKeybinding: (input) =>
241242 transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)),
243 removeKeybinding: (input) =>
244 transport.request((client) => client[WS_METHODS.serverRemoveKeybinding](input)),
242245 getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),
243246 updateSettings: (patch) =>
244247 transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })),
packages/contracts/src/ipc.ts+3-1
......@@ -30,6 +30,7 @@ import type {
3030 ServerConfig,
3131 ServerProviderUpdateInput,
3232 ServerProviderUpdatedPayload,
33 ServerRemoveKeybindingResult,
3334 ServerUpsertKeybindingResult,
3435} from "./server.ts";
3536import type {
......@@ -42,7 +43,7 @@ import type {
4243 TerminalSessionSnapshot,
4344 TerminalWriteInput,
4445} from "./terminal.ts";
45import type { ServerUpsertKeybindingInput } from "./server.ts";
46import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts";
4647import type {
4748 ClientOrchestrationCommand,
4849 OrchestrationGetFullThreadDiffInput,
......@@ -299,6 +300,7 @@ export interface LocalApi {
299300 }) => Promise<ServerProviderUpdatedPayload>;
300301 updateProvider: (input: ServerProviderUpdateInput) => Promise<ServerProviderUpdatedPayload>;
301302 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
303 removeKeybinding: (input: ServerRemoveKeybindingInput) => Promise<ServerRemoveKeybindingResult>;
302304 getSettings: () => Promise<ServerSettings>;
303305 updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>;
304306 discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;
packages/contracts/src/keybindings.ts+3-3
......@@ -2,7 +2,7 @@ import { Schema } from "effect";
22import { TrimmedString } from "./baseSchemas.ts";
33
44export const MAX_KEYBINDING_VALUE_LENGTH = 64;
5const MAX_KEYBINDING_WHEN_LENGTH = 256;
5export const MAX_KEYBINDING_WHEN_LENGTH = 256;
66export const MAX_WHEN_EXPRESSION_DEPTH = 64;
77export const MAX_SCRIPT_ID_LENGTH = 24;
88export const MAX_KEYBINDINGS_COUNT = 256;
......@@ -76,12 +76,12 @@ export const KeybindingCommand = Schema.Union([
7676]);
7777export type KeybindingCommand = typeof KeybindingCommand.Type;
7878
79const KeybindingValue = TrimmedString.check(
79export const KeybindingValue = TrimmedString.check(
8080 Schema.isMinLength(1),
8181 Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH),
8282);
8383
84const KeybindingWhen = TrimmedString.check(
84export const KeybindingWhen = TrimmedString.check(
8585 Schema.isMinLength(1),
8686 Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH),
8787);
packages/contracts/src/rpc.ts+10
......@@ -74,6 +74,8 @@ import {
7474 ServerProviderUpdateError,
7575 ServerProviderUpdateInput,
7676 ServerLifecycleStreamEvent,
77 ServerRemoveKeybindingInput,
78 ServerRemoveKeybindingResult,
7779 ServerProviderUpdatedPayload,
7880 ServerUpsertKeybindingInput,
7981 ServerUpsertKeybindingResult,
......@@ -133,6 +135,7 @@ export const WS_METHODS = {
133135 serverRefreshProviders: "server.refreshProviders",
134136 serverUpdateProvider: "server.updateProvider",
135137 serverUpsertKeybinding: "server.upsertKeybinding",
138 serverRemoveKeybinding: "server.removeKeybinding",
136139 serverGetSettings: "server.getSettings",
137140 serverUpdateSettings: "server.updateSettings",
138141 serverDiscoverSourceControl: "server.discoverSourceControl",
......@@ -156,6 +159,12 @@ export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybi
156159 error: KeybindingsConfigError,
157160});
158161
162export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, {
163 payload: ServerRemoveKeybindingInput,
164 success: ServerRemoveKeybindingResult,
165 error: KeybindingsConfigError,
166});
167
159168export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, {
160169 payload: Schema.Struct({}),
161170 success: ServerConfig,
......@@ -426,6 +435,7 @@ export const WsRpcGroup = RpcGroup.make(
426435 WsServerRefreshProvidersRpc,
427436 WsServerUpdateProviderRpc,
428437 WsServerUpsertKeybindingRpc,
438 WsServerRemoveKeybindingRpc,
429439 WsServerGetSettingsRpc,
430440 WsServerUpdateSettingsRpc,
431441 WsServerDiscoverSourceControlRpc,
packages/contracts/src/server.ts+24-2
......@@ -8,7 +8,12 @@ import {
88 ThreadId,
99 TrimmedNonEmptyString,
1010} from "./baseSchemas.ts";
11import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts";
11import {
12 KeybindingCommand,
13 KeybindingValue,
14 KeybindingWhen,
15 ResolvedKeybindingsConfig,
16} from "./keybindings.ts";
1217import { EditorId } from "./editor.ts";
1318import { ModelCapabilities } from "./model.ts";
1419import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
......@@ -220,15 +225,32 @@ export const ServerConfig = Schema.Struct({
220225});
221226export type ServerConfig = typeof ServerConfig.Type;
222227
223export const ServerUpsertKeybindingInput = KeybindingRule;
228const ServerUpsertKeybindingReplaceTarget = Schema.Struct({
229 key: KeybindingValue,
230 command: KeybindingCommand,
231 when: Schema.optional(KeybindingWhen),
232});
233
234export const ServerUpsertKeybindingInput = Schema.Struct({
235 key: KeybindingValue,
236 command: KeybindingCommand,
237 when: Schema.optional(KeybindingWhen),
238 replace: Schema.optional(ServerUpsertKeybindingReplaceTarget),
239});
224240export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Type;
225241
242export const ServerRemoveKeybindingInput = ServerUpsertKeybindingReplaceTarget;
243export type ServerRemoveKeybindingInput = typeof ServerRemoveKeybindingInput.Type;
244
226245export const ServerUpsertKeybindingResult = Schema.Struct({
227246 keybindings: ResolvedKeybindingsConfig,
228247 issues: ServerConfigIssues,
229248});
230249export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type;
231250
251export const ServerRemoveKeybindingResult = ServerUpsertKeybindingResult;
252export type ServerRemoveKeybindingResult = typeof ServerRemoveKeybindingResult.Type;
253
232254export const ServerConfigUpdatedPayload = Schema.Struct({
233255 issues: ServerConfigIssues,
234256 providers: ServerProviders,
packages/shared/src/keybindings.ts+1-1
......@@ -162,7 +162,7 @@ function tokenizeWhenExpression(expression: string): WhenToken[] | null {
162162 return tokens;
163163}
164164
165function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {
165export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {
166166 const tokens = tokenizeWhenExpression(expression);
167167 if (!tokens || tokens.length === 0) return null;
168168 let index = 0;