authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 11:42:35-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log667c8c6166592f614ab8316756fc2f817e8b761e
tree8f6ce22e2d1554a8386560d54ae3e17ff3e607b0
parent8efbb31725f8500e1aff76655b04a3fab7b064fe

Bootstrap default keybindings and expose config path

- Sync and persist missing default keybindings on server startup without overriding existing command mappings - Return `keybindingsConfigPath` from `server.getConfig` and add Settings UI action to open `keybindings.json` - Update server/keybindings tests to cover bootstrapping, conflict handling, and config responses

6 files changed, 355 insertions(+), 53 deletions(-)

apps/server/src/keybindings.test.ts+86-1
......@@ -2,10 +2,11 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c
22import { NodeServices } from "@effect/platform-node";
33import { assert, it } from "@effect/vitest";
44import { assertFailure } from "@effect/vitest/utils";
5import { Effect, FileSystem, Layer, Path, Schema } from "effect";
5import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
66import { ServerConfig, type ServerConfigShape } from "./config";
77
88import {
9 DEFAULT_KEYBINDINGS,
910 Keybindings,
1011 KeybindingsConfigError,
1112 KeybindingsLive,
......@@ -149,6 +150,90 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
149150 }),
150151 );
151152
153 it.effect("bootstraps default keybindings when config file is missing", () =>
154 Effect.gen(function* () {
155 const fs = yield* FileSystem.FileSystem;
156 const { keybindingsConfigPath } = yield* ServerConfig;
157 assert.isFalse(yield* fs.exists(keybindingsConfigPath));
158
159 yield* Effect.gen(function* () {
160 const keybindings = yield* Keybindings;
161 yield* keybindings.syncDefaultKeybindingsOnStartup;
162 });
163
164 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
165 assert.deepEqual(persisted, DEFAULT_KEYBINDINGS);
166 }).pipe(Effect.provide(makeKeybindingsLayer())),
167 );
168
169 it.effect(
170 "upserts missing default keybindings on startup without overriding existing command rules",
171 () =>
172 Effect.gen(function* () {
173 const { keybindingsConfigPath } = yield* ServerConfig;
174 yield* writeKeybindingsConfig(keybindingsConfigPath, [
175 { key: "mod+shift+t", command: "terminal.toggle" },
176 { key: "mod+shift+r", command: "script.run-tests.run" },
177 ]);
178
179 yield* Effect.gen(function* () {
180 const keybindings = yield* Keybindings;
181 yield* keybindings.syncDefaultKeybindingsOnStartup;
182 });
183
184 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
185 const byCommand = new Map(persisted.map((entry) => [entry.command, entry]));
186
187 const persistedToggle = byCommand.get("terminal.toggle");
188 assert.isNotNull(persistedToggle);
189 assert.equal(persistedToggle?.key, "mod+shift+t");
190 assert.isFalse(
191 persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"),
192 );
193
194 for (const defaultRule of DEFAULT_KEYBINDINGS) {
195 assert.isTrue(byCommand.has(defaultRule.command), `expected ${defaultRule.command}`);
196 }
197 assert.isTrue(byCommand.has("script.run-tests.run"));
198 }).pipe(Effect.provide(makeKeybindingsLayer())),
199 );
200
201 it.effect("skips conflicting default keybindings on startup and logs a detailed warning", () => {
202 const messages: string[] = [];
203 const logger = Logger.make(({ message }) => {
204 messages.push(String(message));
205 });
206
207 return Effect.gen(function* () {
208 const { keybindingsConfigPath } = yield* ServerConfig;
209 yield* writeKeybindingsConfig(keybindingsConfigPath, [
210 { key: "mod+j", command: "script.custom-action.run" },
211 ]);
212
213 yield* Effect.gen(function* () {
214 const keybindings = yield* Keybindings;
215 yield* keybindings.syncDefaultKeybindingsOnStartup;
216 });
217
218 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
219 assert.isFalse(persisted.some((entry) => entry.command === "terminal.toggle"));
220 assert.isTrue(persisted.some((entry) => entry.command === "script.custom-action.run"));
221
222 assert.isTrue(
223 messages.some((message) =>
224 message.includes("skipping default keybinding due to shortcut conflict"),
225 ),
226 );
227 }).pipe(
228 Effect.provide(
229 Layer.mergeAll(
230 makeKeybindingsLayer(),
231 Logger.layer([logger], { mergeWithExisting: false }),
232 ),
233 ),
234 );
235 });
236
152237 it.effect("upserts custom keybindings to configured path", () =>
153238 Effect.gen(function* () {
154239 const { keybindingsConfigPath } = yield* ServerConfig;
apps/server/src/keybindings.ts+152-15
......@@ -19,6 +19,7 @@ import {
1919import { Mutable } from "effect/Types";
2020import {
2121 Array,
22 Cache,
2223 Cause,
2324 Effect,
2425 FileSystem,
......@@ -26,8 +27,8 @@ import {
2627 Layer,
2728 Option,
2829 Predicate,
29 Ref,
3030 Schema,
31 SchemaGetter,
3132 SchemaIssue,
3233 SchemaTransformation,
3334 ServiceMap,
......@@ -348,6 +349,29 @@ export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFrom
348349 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
349350);
350351
352function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): boolean {
353 return (
354 left.command === right.command &&
355 left.key === right.key &&
356 (left.when ?? undefined) === (right.when ?? undefined)
357 );
358}
359
360function keybindingShortcutContext(rule: KeybindingRule): string | null {
361 const parsed = parseKeybindingShortcut(rule.key);
362 if (!parsed) return null;
363 const encoded = encodeShortcut(parsed);
364 if (!encoded) return null;
365 return `${encoded}\u0000${rule.when ?? ""}`;
366}
367
368function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): boolean {
369 const leftContext = keybindingShortcutContext(left);
370 const rightContext = keybindingShortcutContext(right);
371 if (!leftContext || !rightContext) return false;
372 return leftContext === rightContext;
373}
374
351375function encodeShortcut(shortcut: KeybindingShortcut): string | null {
352376 const modifiers: string[] = [];
353377 if (shortcut.modKey) modifiers.push("mod");
......@@ -378,6 +402,15 @@ const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KE
378402
379403const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown));
380404const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
405const PrettyJsonString = SchemaGetter.parseJson<string>().compose(
406 SchemaGetter.stringifyJson({ space: 2 }),
407);
408const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe(
409 Schema.encode({
410 decode: PrettyJsonString,
411 encode: PrettyJsonString,
412 }),
413);
381414
382415function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
383416 if (custom.length === 0) {
......@@ -402,6 +435,12 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve
402435 * KeybindingsShape - Service API for keybinding configuration operations.
403436 */
404437export interface KeybindingsShape {
438 /**
439 * Ensure the on-disk keybindings file exists and includes all default
440 * commands so newly-added defaults are backfilled on startup.
441 */
442 readonly syncDefaultKeybindingsOnStartup: Effect.Effect<void, KeybindingsConfigError>;
443
405444 /**
406445 * Load and resolve keybindings from disk merged with defaults.
407446 *
......@@ -434,13 +473,28 @@ const makeKeybindings = Effect.gen(function* () {
434473 const { keybindingsConfigPath } = yield* ServerConfig;
435474 const fs = yield* FileSystem.FileSystem;
436475 const path = yield* Path.Path;
437 const cachedResolvedConfig = yield* Ref.make<ResolvedKeybindingsConfig | null>(null);
438476 const upsertSemaphore = yield* Semaphore.make(1);
477 const resolvedConfigCacheKey = "resolved" as const;
478
479 const readConfigExists = fs.exists(keybindingsConfigPath).pipe(
480 Effect.mapError(
481 (cause) =>
482 new KeybindingsConfigError({
483 configPath: keybindingsConfigPath,
484 detail: "failed to access keybindings config",
485 cause,
486 }),
487 ),
488 );
439489
440490 const loadCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<
441491 readonly KeybindingRule[],
442492 KeybindingsConfigError
443493 > {
494 if (!(yield* readConfigExists)) {
495 return [];
496 }
497
444498 const rawConfig = yield* fs.readFileString(keybindingsConfigPath).pipe(
445499 Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)),
446500 Effect.mapError(
......@@ -481,9 +535,8 @@ const makeKeybindings = Effect.gen(function* () {
481535 const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {
482536 const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;
483537
484 return Schema.encodeEffect(KeybindingsConfigJson)(
485 rules,
486 ).pipe(
538 return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe(
539 Effect.map((encoded) => `${encoded}\n`),
487540 Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })),
488541 Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),
489542 Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)),
......@@ -498,18 +551,102 @@ const makeKeybindings = Effect.gen(function* () {
498551 );
499552 };
500553
501 const loadResolvedFromCacheOrDisk = Effect.gen(function* () {
502 const cached = yield* Ref.get(cachedResolvedConfig);
503 if (cached) return cached;
504 const resolved = yield* loadCustomKeybindingsConfig().pipe(
505 Effect.map(compileResolvedKeybindingsConfig),
506 Effect.map(mergeWithDefaultKeybindings),
507 );
508 yield* Ref.set(cachedResolvedConfig, resolved);
509 return resolved;
554 const loadResolvedFromDisk = loadCustomKeybindingsConfig().pipe(
555 Effect.map(compileResolvedKeybindingsConfig),
556 Effect.map(mergeWithDefaultKeybindings),
557 );
558
559 const resolvedConfigCache = yield* Cache.make<
560 typeof resolvedConfigCacheKey,
561 ResolvedKeybindingsConfig,
562 KeybindingsConfigError
563 >({
564 capacity: 1,
565 lookup: () => loadResolvedFromDisk,
510566 });
511567
568 const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)(
569 Effect.gen(function* () {
570 const configExists = yield* readConfigExists;
571 if (!configExists) {
572 yield* writeConfigAtomically(DEFAULT_KEYBINDINGS);
573 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
574 return;
575 }
576
577 const customConfig = yield* loadCustomKeybindingsConfig();
578 const existingCommands = new Set(customConfig.map((entry) => entry.command));
579 const missingDefaults: KeybindingRule[] = [];
580 const shortcutConflictWarnings: Array<{
581 defaultCommand: KeybindingRule["command"];
582 conflictingCommand: KeybindingRule["command"];
583 key: string;
584 when: string | null;
585 }> = [];
586 for (const defaultRule of DEFAULT_KEYBINDINGS) {
587 if (existingCommands.has(defaultRule.command)) {
588 continue;
589 }
590 const conflictingEntry = customConfig.find((entry) =>
591 hasSameShortcutContext(entry, defaultRule),
592 );
593 if (conflictingEntry) {
594 shortcutConflictWarnings.push({
595 defaultCommand: defaultRule.command,
596 conflictingCommand: conflictingEntry.command,
597 key: defaultRule.key,
598 when: defaultRule.when ?? null,
599 });
600 continue;
601 }
602 missingDefaults.push(defaultRule);
603 }
604 for (const conflict of shortcutConflictWarnings) {
605 yield* Effect.logWarning("skipping default keybinding due to shortcut conflict", {
606 path: keybindingsConfigPath,
607 defaultCommand: conflict.defaultCommand,
608 conflictingCommand: conflict.conflictingCommand,
609 key: conflict.key,
610 when: conflict.when,
611 reason: "shortcut context already used by existing rule",
612 });
613 }
614 if (missingDefaults.length === 0) {
615 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
616 return;
617 }
618
619 const matchingDefaults = DEFAULT_KEYBINDINGS.filter((defaultRule) =>
620 customConfig.some((entry) => isSameKeybindingRule(entry, defaultRule)),
621 ).map((rule) => rule.command);
622 if (matchingDefaults.length > 0) {
623 yield* Effect.logWarning("default keybinding rule already defined in user config", {
624 path: keybindingsConfigPath,
625 commands: matchingDefaults,
626 });
627 }
628
629 const nextConfig = [...customConfig, ...missingDefaults];
630 const cappedConfig =
631 nextConfig.length > MAX_KEYBINDINGS_COUNT
632 ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT)
633 : nextConfig;
634 if (nextConfig.length > MAX_KEYBINDINGS_COUNT) {
635 yield* Effect.logWarning("truncating keybindings config to max entries", {
636 path: keybindingsConfigPath,
637 maxEntries: MAX_KEYBINDINGS_COUNT,
638 });
639 }
640
641 yield* writeConfigAtomically(cappedConfig);
642 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
643 }),
644 );
645
646 const loadResolvedFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey);
647
512648 return {
649 syncDefaultKeybindingsOnStartup,
513650 loadResolvedKeybindingsConfig: loadResolvedFromCacheOrDisk,
514651 upsertKeybindingRule: (rule) =>
515652 upsertSemaphore.withPermits(1)(
......@@ -533,7 +670,7 @@ const makeKeybindings = Effect.gen(function* () {
533670 const nextResolved = mergeWithDefaultKeybindings(
534671 compileResolvedKeybindingsConfig(cappedConfig),
535672 );
536 yield* Ref.set(cachedResolvedConfig, nextResolved);
673 yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, nextResolved);
537674 return nextResolved;
538675 }),
539676 ),
apps/server/src/wsServer.test.ts+42-36
......@@ -309,18 +309,6 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon
309309
310310const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);
311311
312function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybindingsConfig {
313 if (custom.length === 0) {
314 return DEFAULT_RESOLVED_KEYBINDINGS;
315 }
316
317 const overriddenCommands = new Set(custom.map((binding) => binding.command));
318 const retainedDefaults = DEFAULT_KEYBINDINGS.filter(
319 (binding) => !overriddenCommands.has(binding.command),
320 );
321 return compileKeybindings([...retainedDefaults, ...custom].slice(-256));
322}
323
324312describe("WebSocket Server", () => {
325313 let server: Http.Server | null = null;
326314 let serverScope: Scope.Closeable | null = null;
......@@ -510,7 +498,8 @@ describe("WebSocket Server", () => {
510498
511499 it("responds to server.getConfig", async () => {
512500 const stateDir = makeTempDir("t3code-state-get-config-");
513 fs.writeFileSync(path.join(stateDir, "keybindings.json"), "[]", "utf8");
501 const keybindingsPath = path.join(stateDir, "keybindings.json");
502 fs.writeFileSync(keybindingsPath, "[]", "utf8");
514503
515504 server = await createTestServer({ cwd: "/my/workspace", stateDir });
516505 const addr = server.address();
......@@ -526,8 +515,34 @@ describe("WebSocket Server", () => {
526515 expect(response.error).toBeUndefined();
527516 expect(response.result).toEqual({
528517 cwd: "/my/workspace",
518 keybindingsConfigPath: keybindingsPath,
519 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
520 });
521 });
522
523 it("bootstraps default keybindings file when missing", async () => {
524 const stateDir = makeTempDir("t3code-state-bootstrap-keybindings-");
525 const keybindingsPath = path.join(stateDir, "keybindings.json");
526 expect(fs.existsSync(keybindingsPath)).toBe(false);
527
528 server = await createTestServer({ cwd: "/my/workspace", stateDir });
529 const addr = server.address();
530 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
531
532 const ws = await connectWs(port);
533 connections.push(ws);
534 await waitForMessage(ws);
535
536 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
537 expect(response.error).toBeUndefined();
538 expect(response.result).toEqual({
539 cwd: "/my/workspace",
540 keybindingsConfigPath: keybindingsPath,
529541 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
530542 });
543
544 const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig;
545 expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS);
531546 });
532547
533548 it("routes shell.openInEditor through the injected open service", async () => {
......@@ -558,8 +573,9 @@ describe("WebSocket Server", () => {
558573
559574 it("reads keybindings from the configured state directory", async () => {
560575 const stateDir = makeTempDir("t3code-state-keybindings-");
576 const keybindingsPath = path.join(stateDir, "keybindings.json");
561577 fs.writeFileSync(
562 path.join(stateDir, "keybindings.json"),
578 keybindingsPath,
563579 JSON.stringify([
564580 { key: "cmd+j", command: "terminal.toggle" },
565581 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
......@@ -578,13 +594,11 @@ describe("WebSocket Server", () => {
578594
579595 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
580596 expect(response.error).toBeUndefined();
597 const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig;
581598 expect(response.result).toEqual({
582599 cwd: "/my/workspace",
583 keybindings: mergeWithDefaultsForTest([
584 { key: "cmd+j", command: "terminal.toggle" },
585 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
586 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
587 ]),
600 keybindingsConfigPath: keybindingsPath,
601 keybindings: compileKeybindings(persistedConfig),
588602 });
589603 });
590604
......@@ -610,30 +624,22 @@ describe("WebSocket Server", () => {
610624 command: "script.run-tests.run",
611625 });
612626 expect(upsertResponse.error).toBeUndefined();
627 const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig;
628 const persistedCommands = new Set(persistedConfig.map((entry) => entry.command));
629 for (const defaultRule of DEFAULT_KEYBINDINGS) {
630 expect(persistedCommands.has(defaultRule.command)).toBe(true);
631 }
632 expect(persistedCommands.has("script.run-tests.run")).toBe(true);
613633 expect(upsertResponse.result).toEqual({
614 keybindings: mergeWithDefaultsForTest([
615 { key: "mod+j", command: "terminal.toggle" },
616 { key: "mod+shift+r", command: "script.run-tests.run" },
617 ]),
634 keybindings: compileKeybindings(persistedConfig),
618635 });
619636
620 const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as Array<{
621 key: string;
622 command: string;
623 }>;
624 expect(persistedConfig).toEqual([
625 { key: "mod+j", command: "terminal.toggle" },
626 { key: "mod+shift+r", command: "script.run-tests.run" },
627 ]);
628
629637 const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
630638 expect(configResponse.error).toBeUndefined();
631639 expect(configResponse.result).toEqual({
632640 cwd: "/my/workspace",
633 keybindings: mergeWithDefaultsForTest([
634 { key: "mod+j", command: "terminal.toggle" },
635 { key: "mod+shift+r", command: "script.run-tests.run" },
636 ]),
641 keybindingsConfigPath: keybindingsPath,
642 keybindings: compileKeybindings(persistedConfig),
637643 });
638644 });
639645
apps/server/src/wsServer.ts+12-1
......@@ -172,6 +172,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
172172 const {
173173 port,
174174 cwd,
175 keybindingsConfigPath,
175176 staticDir,
176177 devUrl,
177178 authToken,
......@@ -185,6 +186,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
185186 const keybindingsManager = yield* Keybindings;
186187 const git = yield* GitCore;
187188
189 yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe(
190 Effect.catch((error) =>
191 Effect.logWarning("failed to sync keybindings defaults on startup", {
192 path: error.configPath,
193 detail: error.detail,
194 cause: error.cause,
195 }),
196 ),
197 );
198
188199 const clients = yield* Ref.make(new Set<WebSocket>());
189200 const logger = createLogger("ws");
190201
......@@ -462,7 +473,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
462473
463474 case WS_METHODS.serverGetConfig:
464475 const keybindingsConfig = yield* keybindingsManager.loadResolvedKeybindingsConfig;
465 return { cwd, keybindings: keybindingsConfig };
476 return { cwd, keybindingsConfigPath, keybindings: keybindingsConfig };
466477
467478 case WS_METHODS.serverUpsertKeybinding: {
468479 const body = stripRequestTag(request.body);
apps/web/src/routes/_chat.settings.tsx+62
......@@ -1,9 +1,14 @@
11import { createFileRoute } from "@tanstack/react-router";
2import { useQuery } from "@tanstack/react-query";
3import { useCallback, useState } from "react";
24
35import { useAppSettings } from "../appSettings";
46import { isElectron } from "../env";
57import { useTheme } from "../hooks/useTheme";
8import { serverConfigQueryOptions } from "../lib/serverReactQuery";
9import { ensureNativeApi } from "../nativeApi";
610import { useStore } from "../store";
11import { preferredTerminalEditor } from "../terminal-links";
712import { Button } from "../components/ui/button";
813import { Input } from "../components/ui/input";
914import { Switch } from "../components/ui/switch";
......@@ -44,9 +49,30 @@ function SettingsRouteView() {
4449 const { theme, setTheme, resolvedTheme } = useTheme();
4550 const { state, dispatch } = useStore();
4651 const { settings, defaults, updateSettings } = useAppSettings();
52 const serverConfigQuery = useQuery(serverConfigQueryOptions());
53 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
54 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);
4755
4856 const codexBinaryPath = settings.codexBinaryPath;
4957 const codexHomePath = settings.codexHomePath;
58 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
59
60 const openKeybindingsFile = useCallback(() => {
61 if (!keybindingsConfigPath) return;
62 setOpenKeybindingsError(null);
63 setIsOpeningKeybindings(true);
64 const api = ensureNativeApi();
65 void api.shell
66 .openInEditor(keybindingsConfigPath, preferredTerminalEditor())
67 .catch((error) => {
68 setOpenKeybindingsError(
69 error instanceof Error ? error.message : "Unable to open keybindings file.",
70 );
71 })
72 .finally(() => {
73 setIsOpeningKeybindings(false);
74 });
75 }, [keybindingsConfigPath]);
5076
5177 return (
5278 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
......@@ -211,6 +237,42 @@ function SettingsRouteView() {
211237 </div>
212238 </section>
213239
240 <section className="rounded-2xl border border-border bg-card p-5">
241 <div className="mb-4">
242 <h2 className="text-sm font-medium text-foreground">Keybindings</h2>
243 <p className="mt-1 text-xs text-muted-foreground">
244 Open the persisted <code>keybindings.json</code> file to edit advanced
245 bindings directly.
246 </p>
247 </div>
248
249 <div className="space-y-3">
250 <div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2">
251 <div className="min-w-0 flex-1">
252 <p className="text-xs font-medium text-foreground">Config file path</p>
253 <p className="mt-1 break-all font-mono text-[11px] text-muted-foreground">
254 {keybindingsConfigPath ?? "Resolving keybindings path..."}
255 </p>
256 </div>
257 <Button
258 size="xs"
259 variant="outline"
260 disabled={!keybindingsConfigPath || isOpeningKeybindings}
261 onClick={openKeybindingsFile}
262 >
263 {isOpeningKeybindings ? "Opening..." : "Open keybindings.json"}
264 </Button>
265 </div>
266
267 <p className="text-xs text-muted-foreground">
268 Opens in your preferred editor selection.
269 </p>
270 {openKeybindingsError ? (
271 <p className="text-xs text-destructive">{openKeybindingsError}</p>
272 ) : null}
273 </div>
274 </section>
275
214276 <section className="rounded-2xl border border-border bg-card p-5">
215277 <div className="mb-4">
216278 <h2 className="text-sm font-medium text-foreground">Safety</h2>
packages/contracts/src/server.ts+1
......@@ -4,6 +4,7 @@ import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
44
55export const ServerConfig = Schema.Struct({
66 cwd: TrimmedNonEmptyString,
7 keybindingsConfigPath: TrimmedNonEmptyString,
78 keybindings: ResolvedKeybindingsConfig,
89});
910export type ServerConfig = typeof ServerConfig.Type;