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...@@ -2,10 +2,11 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c
2import { NodeServices } from "@effect/platform-node";2import { NodeServices } from "@effect/platform-node";
3import { assert, it } from "@effect/vitest";3import { assert, it } from "@effect/vitest";
4import { assertFailure } from "@effect/vitest/utils";4import { assertFailure } from "@effect/vitest/utils";
5import { Effect, FileSystem, Layer, Path, Schema } from "effect";5import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
6import { ServerConfig, type ServerConfigShape } from "./config";6import { ServerConfig, type ServerConfigShape } from "./config";
77
8import {8import {
9 DEFAULT_KEYBINDINGS,
9 Keybindings,10 Keybindings,
10 KeybindingsConfigError,11 KeybindingsConfigError,
11 KeybindingsLive,12 KeybindingsLive,
...@@ -149,6 +150,90 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -149,6 +150,90 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
149 }),150 }),
150 );151 );
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
152 it.effect("upserts custom keybindings to configured path", () =>237 it.effect("upserts custom keybindings to configured path", () =>
153 Effect.gen(function* () {238 Effect.gen(function* () {
154 const { keybindingsConfigPath } = yield* ServerConfig;239 const { keybindingsConfigPath } = yield* ServerConfig;
apps/server/src/keybindings.ts+152-15
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19import { Mutable } from "effect/Types";19import { Mutable } from "effect/Types";
20import {20import {
21 Array,21 Array,
22 Cache,
22 Cause,23 Cause,
23 Effect,24 Effect,
24 FileSystem,25 FileSystem,
...@@ -26,8 +27,8 @@ import {...@@ -26,8 +27,8 @@ import {
26 Layer,27 Layer,
27 Option,28 Option,
28 Predicate,29 Predicate,
29 Ref,
30 Schema,30 Schema,
31 SchemaGetter,
31 SchemaIssue,32 SchemaIssue,
32 SchemaTransformation,33 SchemaTransformation,
33 ServiceMap,34 ServiceMap,
...@@ -348,6 +349,29 @@ export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFrom...@@ -348,6 +349,29 @@ export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFrom
348 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),349 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
349);350);
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
351function encodeShortcut(shortcut: KeybindingShortcut): string | null {375function encodeShortcut(shortcut: KeybindingShortcut): string | null {
352 const modifiers: string[] = [];376 const modifiers: string[] = [];
353 if (shortcut.modKey) modifiers.push("mod");377 if (shortcut.modKey) modifiers.push("mod");
...@@ -378,6 +402,15 @@ const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KE...@@ -378,6 +402,15 @@ const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KE
378402
379const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown));403const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown));
380const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);404const 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
382function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {415function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
383 if (custom.length === 0) {416 if (custom.length === 0) {
...@@ -402,6 +435,12 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve...@@ -402,6 +435,12 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve
402 * KeybindingsShape - Service API for keybinding configuration operations.435 * KeybindingsShape - Service API for keybinding configuration operations.
403 */436 */
404export interface KeybindingsShape {437export 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
405 /**444 /**
406 * Load and resolve keybindings from disk merged with defaults.445 * Load and resolve keybindings from disk merged with defaults.
407 *446 *
...@@ -434,13 +473,28 @@ const makeKeybindings = Effect.gen(function* () {...@@ -434,13 +473,28 @@ const makeKeybindings = Effect.gen(function* () {
434 const { keybindingsConfigPath } = yield* ServerConfig;473 const { keybindingsConfigPath } = yield* ServerConfig;
435 const fs = yield* FileSystem.FileSystem;474 const fs = yield* FileSystem.FileSystem;
436 const path = yield* Path.Path;475 const path = yield* Path.Path;
437 const cachedResolvedConfig = yield* Ref.make<ResolvedKeybindingsConfig | null>(null);
438 const upsertSemaphore = yield* Semaphore.make(1);476 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
440 const loadCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<490 const loadCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<
441 readonly KeybindingRule[],491 readonly KeybindingRule[],
442 KeybindingsConfigError492 KeybindingsConfigError
443 > {493 > {
494 if (!(yield* readConfigExists)) {
495 return [];
496 }
497
444 const rawConfig = yield* fs.readFileString(keybindingsConfigPath).pipe(498 const rawConfig = yield* fs.readFileString(keybindingsConfigPath).pipe(
445 Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)),499 Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)),
446 Effect.mapError(500 Effect.mapError(
...@@ -481,9 +535,8 @@ const makeKeybindings = Effect.gen(function* () {...@@ -481,9 +535,8 @@ const makeKeybindings = Effect.gen(function* () {
481 const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {535 const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {
482 const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;536 const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;
483537
484 return Schema.encodeEffect(KeybindingsConfigJson)(538 return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe(
485 rules,539 Effect.map((encoded) => `${encoded}\n`),
486 ).pipe(
487 Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })),540 Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })),
488 Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),541 Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),
489 Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)),542 Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)),
...@@ -498,18 +551,102 @@ const makeKeybindings = Effect.gen(function* () {...@@ -498,18 +551,102 @@ const makeKeybindings = Effect.gen(function* () {
498 );551 );
499 };552 };
500553
501 const loadResolvedFromCacheOrDisk = Effect.gen(function* () {554 const loadResolvedFromDisk = loadCustomKeybindingsConfig().pipe(
502 const cached = yield* Ref.get(cachedResolvedConfig);555 Effect.map(compileResolvedKeybindingsConfig),
503 if (cached) return cached;556 Effect.map(mergeWithDefaultKeybindings),
504 const resolved = yield* loadCustomKeybindingsConfig().pipe(557 );
505 Effect.map(compileResolvedKeybindingsConfig),558
506 Effect.map(mergeWithDefaultKeybindings),559 const resolvedConfigCache = yield* Cache.make<
507 );560 typeof resolvedConfigCacheKey,
508 yield* Ref.set(cachedResolvedConfig, resolved);561 ResolvedKeybindingsConfig,
509 return resolved;562 KeybindingsConfigError
563 >({
564 capacity: 1,
565 lookup: () => loadResolvedFromDisk,
510 });566 });
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
512 return {648 return {
649 syncDefaultKeybindingsOnStartup,
513 loadResolvedKeybindingsConfig: loadResolvedFromCacheOrDisk,650 loadResolvedKeybindingsConfig: loadResolvedFromCacheOrDisk,
514 upsertKeybindingRule: (rule) =>651 upsertKeybindingRule: (rule) =>
515 upsertSemaphore.withPermits(1)(652 upsertSemaphore.withPermits(1)(
...@@ -533,7 +670,7 @@ const makeKeybindings = Effect.gen(function* () {...@@ -533,7 +670,7 @@ const makeKeybindings = Effect.gen(function* () {
533 const nextResolved = mergeWithDefaultKeybindings(670 const nextResolved = mergeWithDefaultKeybindings(
534 compileResolvedKeybindingsConfig(cappedConfig),671 compileResolvedKeybindingsConfig(cappedConfig),
535 );672 );
536 yield* Ref.set(cachedResolvedConfig, nextResolved);673 yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, nextResolved);
537 return nextResolved;674 return nextResolved;
538 }),675 }),
539 ),676 ),
apps/server/src/wsServer.test.ts+42-36
...@@ -309,18 +309,6 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon...@@ -309,18 +309,6 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon
309309
310const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);310const 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
324describe("WebSocket Server", () => {312describe("WebSocket Server", () => {
325 let server: Http.Server | null = null;313 let server: Http.Server | null = null;
326 let serverScope: Scope.Closeable | null = null;314 let serverScope: Scope.Closeable | null = null;
...@@ -510,7 +498,8 @@ describe("WebSocket Server", () => {...@@ -510,7 +498,8 @@ describe("WebSocket Server", () => {
510498
511 it("responds to server.getConfig", async () => {499 it("responds to server.getConfig", async () => {
512 const stateDir = makeTempDir("t3code-state-get-config-");500 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
515 server = await createTestServer({ cwd: "/my/workspace", stateDir });504 server = await createTestServer({ cwd: "/my/workspace", stateDir });
516 const addr = server.address();505 const addr = server.address();
...@@ -526,8 +515,34 @@ describe("WebSocket Server", () => {...@@ -526,8 +515,34 @@ describe("WebSocket Server", () => {
526 expect(response.error).toBeUndefined();515 expect(response.error).toBeUndefined();
527 expect(response.result).toEqual({516 expect(response.result).toEqual({
528 cwd: "/my/workspace",517 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,
529 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,541 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
530 });542 });
543
544 const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig;
545 expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS);
531 });546 });
532547
533 it("routes shell.openInEditor through the injected open service", async () => {548 it("routes shell.openInEditor through the injected open service", async () => {
...@@ -558,8 +573,9 @@ describe("WebSocket Server", () => {...@@ -558,8 +573,9 @@ describe("WebSocket Server", () => {
558573
559 it("reads keybindings from the configured state directory", async () => {574 it("reads keybindings from the configured state directory", async () => {
560 const stateDir = makeTempDir("t3code-state-keybindings-");575 const stateDir = makeTempDir("t3code-state-keybindings-");
576 const keybindingsPath = path.join(stateDir, "keybindings.json");
561 fs.writeFileSync(577 fs.writeFileSync(
562 path.join(stateDir, "keybindings.json"),578 keybindingsPath,
563 JSON.stringify([579 JSON.stringify([
564 { key: "cmd+j", command: "terminal.toggle" },580 { key: "cmd+j", command: "terminal.toggle" },
565 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },581 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
...@@ -578,13 +594,11 @@ describe("WebSocket Server", () => {...@@ -578,13 +594,11 @@ describe("WebSocket Server", () => {
578594
579 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);595 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
580 expect(response.error).toBeUndefined();596 expect(response.error).toBeUndefined();
597 const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig;
581 expect(response.result).toEqual({598 expect(response.result).toEqual({
582 cwd: "/my/workspace",599 cwd: "/my/workspace",
583 keybindings: mergeWithDefaultsForTest([600 keybindingsConfigPath: keybindingsPath,
584 { key: "cmd+j", command: "terminal.toggle" },601 keybindings: compileKeybindings(persistedConfig),
585 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
586 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
587 ]),
588 });602 });
589 });603 });
590604
...@@ -610,30 +624,22 @@ describe("WebSocket Server", () => {...@@ -610,30 +624,22 @@ describe("WebSocket Server", () => {
610 command: "script.run-tests.run",624 command: "script.run-tests.run",
611 });625 });
612 expect(upsertResponse.error).toBeUndefined();626 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);
613 expect(upsertResponse.result).toEqual({633 expect(upsertResponse.result).toEqual({
614 keybindings: mergeWithDefaultsForTest([634 keybindings: compileKeybindings(persistedConfig),
615 { key: "mod+j", command: "terminal.toggle" },
616 { key: "mod+shift+r", command: "script.run-tests.run" },
617 ]),
618 });635 });
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
629 const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);637 const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
630 expect(configResponse.error).toBeUndefined();638 expect(configResponse.error).toBeUndefined();
631 expect(configResponse.result).toEqual({639 expect(configResponse.result).toEqual({
632 cwd: "/my/workspace",640 cwd: "/my/workspace",
633 keybindings: mergeWithDefaultsForTest([641 keybindingsConfigPath: keybindingsPath,
634 { key: "mod+j", command: "terminal.toggle" },642 keybindings: compileKeybindings(persistedConfig),
635 { key: "mod+shift+r", command: "script.run-tests.run" },
636 ]),
637 });643 });
638 });644 });
639645
apps/server/src/wsServer.ts+12-1
...@@ -172,6 +172,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -172,6 +172,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
172 const {172 const {
173 port,173 port,
174 cwd,174 cwd,
175 keybindingsConfigPath,
175 staticDir,176 staticDir,
176 devUrl,177 devUrl,
177 authToken,178 authToken,
...@@ -185,6 +186,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -185,6 +186,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
185 const keybindingsManager = yield* Keybindings;186 const keybindingsManager = yield* Keybindings;
186 const git = yield* GitCore;187 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
188 const clients = yield* Ref.make(new Set<WebSocket>());199 const clients = yield* Ref.make(new Set<WebSocket>());
189 const logger = createLogger("ws");200 const logger = createLogger("ws");
190201
...@@ -462,7 +473,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -462,7 +473,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
462473
463 case WS_METHODS.serverGetConfig:474 case WS_METHODS.serverGetConfig:
464 const keybindingsConfig = yield* keybindingsManager.loadResolvedKeybindingsConfig;475 const keybindingsConfig = yield* keybindingsManager.loadResolvedKeybindingsConfig;
465 return { cwd, keybindings: keybindingsConfig };476 return { cwd, keybindingsConfigPath, keybindings: keybindingsConfig };
466477
467 case WS_METHODS.serverUpsertKeybinding: {478 case WS_METHODS.serverUpsertKeybinding: {
468 const body = stripRequestTag(request.body);479 const body = stripRequestTag(request.body);
apps/web/src/routes/_chat.settings.tsx+62
...@@ -1,9 +1,14 @@...@@ -1,9 +1,14 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { useQuery } from "@tanstack/react-query";
3import { useCallback, useState } from "react";
24
3import { useAppSettings } from "../appSettings";5import { useAppSettings } from "../appSettings";
4import { isElectron } from "../env";6import { isElectron } from "../env";
5import { useTheme } from "../hooks/useTheme";7import { useTheme } from "../hooks/useTheme";
8import { serverConfigQueryOptions } from "../lib/serverReactQuery";
9import { ensureNativeApi } from "../nativeApi";
6import { useStore } from "../store";10import { useStore } from "../store";
11import { preferredTerminalEditor } from "../terminal-links";
7import { Button } from "../components/ui/button";12import { Button } from "../components/ui/button";
8import { Input } from "../components/ui/input";13import { Input } from "../components/ui/input";
9import { Switch } from "../components/ui/switch";14import { Switch } from "../components/ui/switch";
...@@ -44,9 +49,30 @@ function SettingsRouteView() {...@@ -44,9 +49,30 @@ function SettingsRouteView() {
44 const { theme, setTheme, resolvedTheme } = useTheme();49 const { theme, setTheme, resolvedTheme } = useTheme();
45 const { state, dispatch } = useStore();50 const { state, dispatch } = useStore();
46 const { settings, defaults, updateSettings } = useAppSettings();51 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
48 const codexBinaryPath = settings.codexBinaryPath;56 const codexBinaryPath = settings.codexBinaryPath;
49 const codexHomePath = settings.codexHomePath;57 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
51 return (77 return (
52 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">78 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
...@@ -211,6 +237,42 @@ function SettingsRouteView() {...@@ -211,6 +237,42 @@ function SettingsRouteView() {
211 </div>237 </div>
212 </section>238 </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
214 <section className="rounded-2xl border border-border bg-card p-5">276 <section className="rounded-2xl border border-border bg-card p-5">
215 <div className="mb-4">277 <div className="mb-4">
216 <h2 className="text-sm font-medium text-foreground">Safety</h2>278 <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";...@@ -4,6 +4,7 @@ import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
44
5export const ServerConfig = Schema.Struct({5export const ServerConfig = Schema.Struct({
6 cwd: TrimmedNonEmptyString,6 cwd: TrimmedNonEmptyString,
7 keybindingsConfigPath: TrimmedNonEmptyString,
7 keybindings: ResolvedKeybindingsConfig,8 keybindings: ResolvedKeybindingsConfig,
8});9});
9export type ServerConfig = typeof ServerConfig.Type;10export type ServerConfig = typeof ServerConfig.Type;