authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 14:23:41-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 14:25:41-08:00
logdc530f7a7e21dac998126024b2b112fc893df404
treeb1adeb3432f8bd06c4d50905449197194dcd580e
parent79b8fd7b0096c3a75e69e877df1d5253daa7e1f8

keybindings


9 files changed, 557 insertions(+), 54 deletions(-)

apps/server/src/keybindings.test.ts+54-10
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 KeybindingsLive,12 KeybindingsLive,
13 ResolvedKeybindingFromConfig,13 ResolvedKeybindingFromConfig,
14 compileResolvedKeybindingRule,14 compileResolvedKeybindingRule,
15 compileResolvedKeybindingsConfig,
15 parseKeybindingShortcut,16 parseKeybindingShortcut,
16} from "./keybindings";17} from "./keybindings";
1718
...@@ -166,6 +167,55 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -166,6 +167,55 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
166 }).pipe(Effect.provide(makeKeybindingsLayer())),167 }).pipe(Effect.provide(makeKeybindingsLayer())),
167 );168 );
168169
170 it.effect("uses defaults in runtime when config is malformed without overriding file", () =>
171 Effect.gen(function* () {
172 const fs = yield* FileSystem.FileSystem;
173 const { keybindingsConfigPath } = yield* ServerConfig;
174 yield* fs.writeFileString(keybindingsConfigPath, "{ not-json");
175
176 const configState = yield* Effect.gen(function* () {
177 const keybindings = yield* Keybindings;
178 return yield* keybindings.loadConfigState;
179 });
180
181 assert.deepEqual(configState.keybindings, compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS));
182 assert.deepEqual(configState.issues, [
183 {
184 kind: "keybindings.malformed-config",
185 message: configState.issues[0]?.message ?? "",
186 },
187 ]);
188 assert.equal(yield* fs.readFileString(keybindingsConfigPath), "{ not-json");
189 }).pipe(Effect.provide(makeKeybindingsLayer())),
190 );
191
192 it.effect("ignores invalid entries in runtime and reports them as issues", () =>
193 Effect.gen(function* () {
194 const fs = yield* FileSystem.FileSystem;
195 const { keybindingsConfigPath } = yield* ServerConfig;
196 yield* fs.writeFileString(
197 keybindingsConfigPath,
198 JSON.stringify([
199 { key: "mod+j", command: "terminal.toggle" },
200 { key: "mod+shift+d+o", command: "terminal.new" },
201 { key: "mod+x", command: "invalid.command" },
202 ]),
203 );
204
205 const configState = yield* Effect.gen(function* () {
206 const keybindings = yield* Keybindings;
207 return yield* keybindings.loadConfigState;
208 });
209
210 assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle"));
211 assert.isFalse(configState.keybindings.some((entry) => entry.command === "invalid.command"));
212 assert.deepEqual(configState.issues, [
213 { kind: "keybindings.invalid-entry", index: 1, message: configState.issues[0]?.message ?? "" },
214 { kind: "keybindings.invalid-entry", index: 2, message: configState.issues[1]?.message ?? "" },
215 ]);
216 }).pipe(Effect.provide(makeKeybindingsLayer())),
217 );
218
169 it.effect(219 it.effect(
170 "upserts missing default keybindings on startup without overriding existing command rules",220 "upserts missing default keybindings on startup without overriding existing command rules",
171 () =>221 () =>
...@@ -365,23 +415,18 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -365,23 +415,18 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
365415
366 const [first, second] = yield* Effect.gen(function* () {416 const [first, second] = yield* Effect.gen(function* () {
367 const keybindings = yield* Keybindings;417 const keybindings = yield* Keybindings;
368 const firstLoad = yield* keybindings.loadResolvedKeybindingsConfig;418 const firstLoad = (yield* keybindings.loadConfigState).keybindings;
369 yield* writeKeybindingsConfig(keybindingsConfigPath, [419 const secondLoad = (yield* keybindings.loadConfigState).keybindings;
370 { key: "mod+x", command: "script.setup.run" },
371 ]);
372 const secondLoad = yield* keybindings.loadResolvedKeybindingsConfig;
373 return [firstLoad, secondLoad] as const;420 return [firstLoad, secondLoad] as const;
374 });421 });
375422
376 assert.deepEqual(first, second);423 assert.deepEqual(first, second);
377 assert.isTrue(second.some((entry) => entry.command === "terminal.toggle"));424 assert.isTrue(second.some((entry) => entry.command === "terminal.toggle"));
378 assert.isFalse(second.some((entry) => entry.command === "script.setup.run"));
379 }).pipe(Effect.provide(makeKeybindingsLayer())),425 }).pipe(Effect.provide(makeKeybindingsLayer())),
380 );426 );
381427
382 it.effect("updates cached resolved config after upsert", () =>428 it.effect("updates cached resolved config after upsert", () =>
383 Effect.gen(function* () {429 Effect.gen(function* () {
384 const fs = yield* FileSystem.FileSystem;
385 const { keybindingsConfigPath } = yield* ServerConfig;430 const { keybindingsConfigPath } = yield* ServerConfig;
386 yield* writeKeybindingsConfig(keybindingsConfigPath, [431 yield* writeKeybindingsConfig(keybindingsConfigPath, [
387 { key: "mod+j", command: "terminal.toggle" },432 { key: "mod+j", command: "terminal.toggle" },
...@@ -389,13 +434,12 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -389,13 +434,12 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
389434
390 const loadedAfterUpsert = yield* Effect.gen(function* () {435 const loadedAfterUpsert = yield* Effect.gen(function* () {
391 const keybindings = yield* Keybindings;436 const keybindings = yield* Keybindings;
392 yield* keybindings.loadResolvedKeybindingsConfig;437 yield* keybindings.loadConfigState;
393 yield* keybindings.upsertKeybindingRule({438 yield* keybindings.upsertKeybindingRule({
394 key: "mod+shift+r",439 key: "mod+shift+r",
395 command: "script.run-tests.run",440 command: "script.run-tests.run",
396 });441 });
397 yield* fs.writeFileString(keybindingsConfigPath, "{ not-json");442 return (yield* keybindings.loadConfigState).keybindings;
398 return yield* keybindings.loadResolvedKeybindingsConfig;
399 });443 });
400444
401 assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "script.run-tests.run"));445 assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "script.run-tests.run"));
apps/server/src/keybindings.ts+182-20
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 MAX_WHEN_EXPRESSION_DEPTH,15 MAX_WHEN_EXPRESSION_DEPTH,
16 ResolvedKeybindingRule,16 ResolvedKeybindingRule,
17 ResolvedKeybindingsConfig,17 ResolvedKeybindingsConfig,
18 type ServerConfigIssue,
18} from "@t3tools/contracts";19} from "@t3tools/contracts";
19import { Mutable } from "effect/Types";20import { Mutable } from "effect/Types";
20import {21import {
...@@ -27,11 +28,13 @@ import {...@@ -27,11 +28,13 @@ import {
27 Layer,28 Layer,
28 Option,29 Option,
29 Predicate,30 Predicate,
31 PubSub,
30 Schema,32 Schema,
31 SchemaGetter,33 SchemaGetter,
32 SchemaIssue,34 SchemaIssue,
33 SchemaTransformation,35 SchemaTransformation,
34 ServiceMap,36 ServiceMap,
37 Stream,
35} from "effect";38} from "effect";
36import * as Semaphore from "effect/Semaphore";39import * as Semaphore from "effect/Semaphore";
37import { ServerConfig } from "./config";40import { ServerConfig } from "./config";
...@@ -412,6 +415,35 @@ const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe(...@@ -412,6 +415,35 @@ const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe(
412 }),415 }),
413);416);
414417
418export interface KeybindingsConfigState {
419 readonly keybindings: ResolvedKeybindingsConfig;
420 readonly issues: readonly ServerConfigIssue[];
421}
422
423export interface KeybindingsChangeEvent {
424 readonly issues: readonly ServerConfigIssue[];
425}
426
427function trimIssueMessage(message: string): string {
428 const trimmed = message.trim();
429 return trimmed.length > 0 ? trimmed : "Invalid keybindings configuration.";
430}
431
432function malformedConfigIssue(detail: string): ServerConfigIssue {
433 return {
434 kind: "keybindings.malformed-config",
435 message: trimIssueMessage(detail),
436 };
437}
438
439function invalidEntryIssue(index: number, detail: string): ServerConfigIssue {
440 return {
441 kind: "keybindings.invalid-entry",
442 index,
443 message: trimIssueMessage(detail),
444 };
445}
446
415function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {447function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
416 if (custom.length === 0) {448 if (custom.length === 0) {
417 return [...DEFAULT_RESOLVED_KEYBINDINGS];449 return [...DEFAULT_RESOLVED_KEYBINDINGS];
...@@ -442,14 +474,14 @@ export interface KeybindingsShape {...@@ -442,14 +474,14 @@ export interface KeybindingsShape {
442 readonly syncDefaultKeybindingsOnStartup: Effect.Effect<void, KeybindingsConfigError>;474 readonly syncDefaultKeybindingsOnStartup: Effect.Effect<void, KeybindingsConfigError>;
443475
444 /**476 /**
445 * Load and resolve keybindings from disk merged with defaults.477 * Load runtime keybindings state along with non-fatal configuration issues.
446 *
447 * Returns an in-memory cached result after the first successful load.
448 */478 */
449 readonly loadResolvedKeybindingsConfig: Effect.Effect<479 readonly loadConfigState: Effect.Effect<KeybindingsConfigState, KeybindingsConfigError>;
450 readonly ResolvedKeybindingRule[],480
451 KeybindingsConfigError481 /**
452 >;482 * Stream keybindings config change events.
483 */
484 readonly changes: Stream.Stream<KeybindingsChangeEvent>;
453485
454 /**486 /**
455 * Upsert a keybinding rule and persist the resulting configuration.487 * Upsert a keybinding rule and persist the resulting configuration.
...@@ -475,6 +507,10 @@ const makeKeybindings = Effect.gen(function* () {...@@ -475,6 +507,10 @@ const makeKeybindings = Effect.gen(function* () {
475 const path = yield* Path.Path;507 const path = yield* Path.Path;
476 const upsertSemaphore = yield* Semaphore.make(1);508 const upsertSemaphore = yield* Semaphore.make(1);
477 const resolvedConfigCacheKey = "resolved" as const;509 const resolvedConfigCacheKey = "resolved" as const;
510 const changesPubSub = yield* PubSub.unbounded<KeybindingsChangeEvent>();
511
512 const emitChange = (issues: readonly ServerConfigIssue[]) =>
513 PubSub.publish(changesPubSub, { issues }).pipe(Effect.asVoid);
478514
479 const readConfigExists = fs.exists(keybindingsConfigPath).pipe(515 const readConfigExists = fs.exists(keybindingsConfigPath).pipe(
480 Effect.mapError(516 Effect.mapError(
...@@ -487,7 +523,18 @@ const makeKeybindings = Effect.gen(function* () {...@@ -487,7 +523,18 @@ const makeKeybindings = Effect.gen(function* () {
487 ),523 ),
488 );524 );
489525
490 const loadCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<526 const readRawConfig = fs.readFileString(keybindingsConfigPath).pipe(
527 Effect.mapError(
528 (cause) =>
529 new KeybindingsConfigError({
530 configPath: keybindingsConfigPath,
531 detail: "failed to read keybindings config",
532 cause,
533 }),
534 ),
535 );
536
537 const loadWritableCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<
491 readonly KeybindingRule[],538 readonly KeybindingRule[],
492 KeybindingsConfigError539 KeybindingsConfigError
493 > {540 > {
...@@ -495,7 +542,7 @@ const makeKeybindings = Effect.gen(function* () {...@@ -495,7 +542,7 @@ const makeKeybindings = Effect.gen(function* () {
495 return [];542 return [];
496 }543 }
497544
498 const rawConfig = yield* fs.readFileString(keybindingsConfigPath).pipe(545 const rawConfig = yield* readRawConfig.pipe(
499 Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)),546 Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)),
500 Effect.mapError(547 Effect.mapError(
501 (cause) =>548 (cause) =>
...@@ -532,6 +579,61 @@ const makeKeybindings = Effect.gen(function* () {...@@ -532,6 +579,61 @@ const makeKeybindings = Effect.gen(function* () {
532 ).pipe(Effect.map(Array.filter(Predicate.isNotNull)));579 ).pipe(Effect.map(Array.filter(Predicate.isNotNull)));
533 });580 });
534581
582 const loadRuntimeCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<
583 {
584 readonly keybindings: readonly KeybindingRule[];
585 readonly issues: readonly ServerConfigIssue[];
586 },
587 KeybindingsConfigError
588 > {
589 if (!(yield* readConfigExists)) {
590 return { keybindings: [], issues: [] };
591 }
592
593 const rawConfig = yield* readRawConfig;
594 const decodedEntries = Schema.decodeUnknownExit(RawKeybindingsEntries)(rawConfig);
595 if (decodedEntries._tag === "Failure") {
596 const detail = `expected JSON array (${Cause.pretty(decodedEntries.cause)})`;
597 return {
598 keybindings: [],
599 issues: [malformedConfigIssue(detail)],
600 };
601 }
602
603 const keybindings: KeybindingRule[] = [];
604 const issues: ServerConfigIssue[] = [];
605 for (const [index, entry] of decodedEntries.value.entries()) {
606 const decodedRule = Schema.decodeUnknownExit(KeybindingRule)(entry);
607 if (decodedRule._tag === "Failure") {
608 const detail = Cause.pretty(decodedRule.cause);
609 issues.push(invalidEntryIssue(index, detail));
610 yield* Effect.logWarning("ignoring invalid keybinding entry", {
611 path: keybindingsConfigPath,
612 index,
613 entry,
614 error: detail,
615 });
616 continue;
617 }
618
619 const resolvedRule = Schema.decodeExit(ResolvedKeybindingFromConfig)(decodedRule.value);
620 if (resolvedRule._tag === "Failure") {
621 const detail = Cause.pretty(resolvedRule.cause);
622 issues.push(invalidEntryIssue(index, detail));
623 yield* Effect.logWarning("ignoring invalid keybinding entry", {
624 path: keybindingsConfigPath,
625 index,
626 entry,
627 error: detail,
628 });
629 continue;
630 }
631 keybindings.push(decodedRule.value);
632 }
633
634 return { keybindings, issues };
635 });
636
535 const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {637 const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {
536 const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;638 const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;
537639
...@@ -551,20 +653,65 @@ const makeKeybindings = Effect.gen(function* () {...@@ -551,20 +653,65 @@ const makeKeybindings = Effect.gen(function* () {
551 );653 );
552 };654 };
553655
554 const loadResolvedFromDisk = loadCustomKeybindingsConfig().pipe(656 const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe(
555 Effect.map(compileResolvedKeybindingsConfig),657 Effect.map(({ keybindings, issues }) => ({
556 Effect.map(mergeWithDefaultKeybindings),658 keybindings: mergeWithDefaultKeybindings(compileResolvedKeybindingsConfig(keybindings)),
659 issues,
660 })),
557 );661 );
558662
559 const resolvedConfigCache = yield* Cache.make<663 const resolvedConfigCache = yield* Cache.make<
560 typeof resolvedConfigCacheKey,664 typeof resolvedConfigCacheKey,
561 ResolvedKeybindingsConfig,665 KeybindingsConfigState,
562 KeybindingsConfigError666 KeybindingsConfigError
563 >({667 >({
564 capacity: 1,668 capacity: 1,
565 lookup: () => loadResolvedFromDisk,669 lookup: () => loadConfigStateFromDisk,
566 });670 });
567671
672 const loadConfigStateFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey);
673
674 const revalidateAndEmit = upsertSemaphore.withPermits(1)(
675 Effect.gen(function* () {
676 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
677 const configState = yield* loadConfigStateFromCacheOrDisk;
678 yield* emitChange(configState.issues);
679 }),
680 );
681
682 const keybindingsConfigDir = path.dirname(keybindingsConfigPath);
683 const keybindingsConfigFile = path.basename(keybindingsConfigPath);
684 const keybindingsConfigPathResolved = path.resolve(keybindingsConfigPath);
685 yield* fs
686 .makeDirectory(keybindingsConfigDir, { recursive: true })
687 .pipe(Effect.orElseSucceed(() => undefined));
688 yield* Stream.runForEach(fs.watch(keybindingsConfigDir), (event) => {
689 const isTargetConfigEvent =
690 event.path === keybindingsConfigFile ||
691 event.path === keybindingsConfigPath ||
692 path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved;
693 if (!isTargetConfigEvent) {
694 return Effect.void;
695 }
696 return revalidateAndEmit.pipe(
697 Effect.catch((error) =>
698 Effect.logWarning("failed to revalidate keybindings config after file update", {
699 path: keybindingsConfigPath,
700 detail: error.detail,
701 cause: error.cause,
702 }),
703 ),
704 );
705 }).pipe(
706 Effect.catch((cause) =>
707 Effect.logWarning("keybindings config watcher stopped unexpectedly", {
708 path: keybindingsConfigPath,
709 cause,
710 }),
711 ),
712 Effect.forkScoped,
713 );
714
568 const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)(715 const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)(
569 Effect.gen(function* () {716 Effect.gen(function* () {
570 const configExists = yield* readConfigExists;717 const configExists = yield* readConfigExists;
...@@ -574,7 +721,19 @@ const makeKeybindings = Effect.gen(function* () {...@@ -574,7 +721,19 @@ const makeKeybindings = Effect.gen(function* () {
574 return;721 return;
575 }722 }
576723
577 const customConfig = yield* loadCustomKeybindingsConfig();724 const runtimeConfig = yield* loadRuntimeCustomKeybindingsConfig();
725 if (runtimeConfig.issues.length > 0) {
726 yield* Effect.logWarning(
727 "skipping startup keybindings default sync because config has issues",
728 {
729 path: keybindingsConfigPath,
730 issues: runtimeConfig.issues,
731 },
732 );
733 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
734 return;
735 }
736 const customConfig = runtimeConfig.keybindings;
578 const existingCommands = new Set(customConfig.map((entry) => entry.command));737 const existingCommands = new Set(customConfig.map((entry) => entry.command));
579 const missingDefaults: KeybindingRule[] = [];738 const missingDefaults: KeybindingRule[] = [];
580 const shortcutConflictWarnings: Array<{739 const shortcutConflictWarnings: Array<{
...@@ -643,15 +802,14 @@ const makeKeybindings = Effect.gen(function* () {...@@ -643,15 +802,14 @@ const makeKeybindings = Effect.gen(function* () {
643 }),802 }),
644 );803 );
645804
646 const loadResolvedFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey);
647
648 return {805 return {
649 syncDefaultKeybindingsOnStartup,806 syncDefaultKeybindingsOnStartup,
650 loadResolvedKeybindingsConfig: loadResolvedFromCacheOrDisk,807 loadConfigState: loadConfigStateFromCacheOrDisk,
808 changes: Stream.fromPubSub(changesPubSub),
651 upsertKeybindingRule: (rule) =>809 upsertKeybindingRule: (rule) =>
652 upsertSemaphore.withPermits(1)(810 upsertSemaphore.withPermits(1)(
653 Effect.gen(function* () {811 Effect.gen(function* () {
654 const customConfig = yield* loadCustomKeybindingsConfig();812 const customConfig = yield* loadWritableCustomKeybindingsConfig();
655 const nextConfig = [813 const nextConfig = [
656 ...customConfig.filter((entry) => entry.command !== rule.command),814 ...customConfig.filter((entry) => entry.command !== rule.command),
657 rule,815 rule,
...@@ -670,7 +828,11 @@ const makeKeybindings = Effect.gen(function* () {...@@ -670,7 +828,11 @@ const makeKeybindings = Effect.gen(function* () {
670 const nextResolved = mergeWithDefaultKeybindings(828 const nextResolved = mergeWithDefaultKeybindings(
671 compileResolvedKeybindingsConfig(cappedConfig),829 compileResolvedKeybindingsConfig(cappedConfig),
672 );830 );
673 yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, nextResolved);831 yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
832 keybindings: nextResolved,
833 issues: [],
834 });
835 yield* emitChange([]);
674 return nextResolved;836 return nextResolved;
675 }),837 }),
676 ),838 ),
apps/server/src/wsServer.test.ts+120
...@@ -517,6 +517,7 @@ describe("WebSocket Server", () => {...@@ -517,6 +517,7 @@ describe("WebSocket Server", () => {
517 cwd: "/my/workspace",517 cwd: "/my/workspace",
518 keybindingsConfigPath: keybindingsPath,518 keybindingsConfigPath: keybindingsPath,
519 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,519 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
520 issues: [],
520 });521 });
521 });522 });
522523
...@@ -539,12 +540,128 @@ describe("WebSocket Server", () => {...@@ -539,12 +540,128 @@ describe("WebSocket Server", () => {
539 cwd: "/my/workspace",540 cwd: "/my/workspace",
540 keybindingsConfigPath: keybindingsPath,541 keybindingsConfigPath: keybindingsPath,
541 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,542 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
543 issues: [],
542 });544 });
543545
544 const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig;546 const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig;
545 expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS);547 expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS);
546 });548 });
547549
550 it("falls back to defaults and reports malformed keybindings config issues", async () => {
551 const stateDir = makeTempDir("t3code-state-malformed-keybindings-");
552 const keybindingsPath = path.join(stateDir, "keybindings.json");
553 fs.writeFileSync(keybindingsPath, "{ not-json", "utf8");
554
555 server = await createTestServer({ cwd: "/my/workspace", stateDir });
556 const addr = server.address();
557 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
558
559 const ws = await connectWs(port);
560 connections.push(ws);
561 await waitForMessage(ws);
562
563 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
564 expect(response.error).toBeUndefined();
565 expect(response.result).toEqual({
566 cwd: "/my/workspace",
567 keybindingsConfigPath: keybindingsPath,
568 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
569 issues: [
570 {
571 kind: "keybindings.malformed-config",
572 message: expect.stringContaining("expected JSON array"),
573 },
574 ],
575 });
576 expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json");
577 });
578
579 it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => {
580 const stateDir = makeTempDir("t3code-state-partial-invalid-keybindings-");
581 const keybindingsPath = path.join(stateDir, "keybindings.json");
582 fs.writeFileSync(
583 keybindingsPath,
584 JSON.stringify([
585 { key: "mod+j", command: "terminal.toggle" },
586 { key: "mod+shift+d+o", command: "terminal.new" },
587 { key: "mod+x", command: "not-a-real-command" },
588 ]),
589 "utf8",
590 );
591
592 server = await createTestServer({ cwd: "/my/workspace", stateDir });
593 const addr = server.address();
594 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
595
596 const ws = await connectWs(port);
597 connections.push(ws);
598 await waitForMessage(ws);
599
600 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
601 expect(response.error).toBeUndefined();
602 const result = response.result as {
603 cwd: string;
604 keybindingsConfigPath: string;
605 keybindings: ResolvedKeybindingsConfig;
606 issues: Array<{ kind: string; index?: number; message: string }>;
607 };
608 expect(result.cwd).toBe("/my/workspace");
609 expect(result.keybindingsConfigPath).toBe(keybindingsPath);
610 expect(result.issues).toEqual([
611 {
612 kind: "keybindings.invalid-entry",
613 index: 1,
614 message: expect.any(String),
615 },
616 {
617 kind: "keybindings.invalid-entry",
618 index: 2,
619 message: expect.any(String),
620 },
621 ]);
622 expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length);
623 expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true);
624 expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true);
625 });
626
627 it("pushes server.configUpdated issues when keybindings file changes", async () => {
628 const stateDir = makeTempDir("t3code-state-keybindings-watch-");
629 const keybindingsPath = path.join(stateDir, "keybindings.json");
630 fs.writeFileSync(keybindingsPath, "[]", "utf8");
631
632 server = await createTestServer({ cwd: "/my/workspace", stateDir });
633 const addr = server.address();
634 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
635
636 const ws = await connectWs(port);
637 connections.push(ws);
638 await waitForMessage(ws);
639
640 fs.writeFileSync(keybindingsPath, "{ not-json", "utf8");
641 const malformedPush = await waitForPush(
642 ws,
643 WS_CHANNELS.serverConfigUpdated,
644 (push) =>
645 Array.isArray((push.data as { issues?: unknown[] }).issues) &&
646 Boolean((push.data as { issues: Array<{ kind: string }> }).issues[0]) &&
647 (push.data as { issues: Array<{ kind: string }> }).issues[0]!.kind ===
648 "keybindings.malformed-config",
649 );
650 expect(malformedPush.data).toEqual({
651 issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }],
652 });
653
654 fs.writeFileSync(keybindingsPath, "[]", "utf8");
655 const successPush = await waitForPush(
656 ws,
657 WS_CHANNELS.serverConfigUpdated,
658 (push) =>
659 Array.isArray((push.data as { issues?: unknown[] }).issues) &&
660 (push.data as { issues: unknown[] }).issues.length === 0,
661 );
662 expect(successPush.data).toEqual({ issues: [] });
663 });
664
548 it("routes shell.openInEditor through the injected open service", async () => {665 it("routes shell.openInEditor through the injected open service", async () => {
549 const openCalls: Array<{ cwd: string; editor: string }> = [];666 const openCalls: Array<{ cwd: string; editor: string }> = [];
550 const openService: OpenShape = {667 const openService: OpenShape = {
...@@ -599,6 +716,7 @@ describe("WebSocket Server", () => {...@@ -599,6 +716,7 @@ describe("WebSocket Server", () => {
599 cwd: "/my/workspace",716 cwd: "/my/workspace",
600 keybindingsConfigPath: keybindingsPath,717 keybindingsConfigPath: keybindingsPath,
601 keybindings: compileKeybindings(persistedConfig),718 keybindings: compileKeybindings(persistedConfig),
719 issues: [],
602 });720 });
603 });721 });
604722
...@@ -632,6 +750,7 @@ describe("WebSocket Server", () => {...@@ -632,6 +750,7 @@ describe("WebSocket Server", () => {
632 expect(persistedCommands.has("script.run-tests.run")).toBe(true);750 expect(persistedCommands.has("script.run-tests.run")).toBe(true);
633 expect(upsertResponse.result).toEqual({751 expect(upsertResponse.result).toEqual({
634 keybindings: compileKeybindings(persistedConfig),752 keybindings: compileKeybindings(persistedConfig),
753 issues: [],
635 });754 });
636755
637 const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);756 const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
...@@ -640,6 +759,7 @@ describe("WebSocket Server", () => {...@@ -640,6 +759,7 @@ describe("WebSocket Server", () => {
640 cwd: "/my/workspace",759 cwd: "/my/workspace",
641 keybindingsConfigPath: keybindingsPath,760 keybindingsConfigPath: keybindingsPath,
642 keybindings: compileKeybindings(persistedConfig),761 keybindings: compileKeybindings(persistedConfig),
762 issues: [],
643 });763 });
644 });764 });
645765
apps/server/src/wsServer.ts+31-21
...@@ -208,13 +208,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -208,13 +208,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
208 });208 });
209 }209 }
210210
211 const onTerminalEvent = Effect.fnUntraced(function* (event: TerminalEvent) {211 const encodePush = Schema.encodeEffect(Schema.fromJsonString(WsPush));
212 const push: WsPush = {212 const broadcastPush = Effect.fnUntraced(function* (push: WsPush) {
213 type: "push",213 const message = yield* encodePush(push);
214 channel: WS_CHANNELS.terminalEvent,
215 data: event,
216 };
217 const message = yield* Schema.encodeEffect(Schema.fromJsonString(WsPush))(push);
218 let recipients = 0;214 let recipients = 0;
219 for (const client of yield* Ref.get(clients)) {215 for (const client of yield* Ref.get(clients)) {
220 if (client.readyState === client.OPEN) {216 if (client.readyState === client.OPEN) {
...@@ -225,6 +221,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -225,6 +221,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
225 logOutgoingPush(push, recipients);221 logOutgoingPush(push, recipients);
226 });222 });
227223
224 const onTerminalEvent = Effect.fnUntraced(function* (event: TerminalEvent) {
225 yield* broadcastPush({
226 type: "push",
227 channel: WS_CHANNELS.terminalEvent,
228 data: event,
229 });
230 });
231
228 // HTTP server — serves static files or redirects to Vite dev server232 // HTTP server — serves static files or redirects to Vite dev server
229 const httpServer = http.createServer((req, res) => {233 const httpServer = http.createServer((req, res) => {
230 // In dev mode, redirect to Vite dev server234 // In dev mode, redirect to Vite dev server
...@@ -317,20 +321,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -317,20 +321,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
317321
318 yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>322 yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>
319 Effect.gen(function* () {323 Effect.gen(function* () {
320 const push: WsPush = {324 yield* broadcastPush({
321 type: "push",325 type: "push",
322 channel: ORCHESTRATION_WS_CHANNELS.domainEvent,326 channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
323 data: event,327 data: event,
324 };328 });
325 const message = yield* Schema.encodeEffect(Schema.fromJsonString(WsPush))(push);329 }),
326 let recipients = 0;330 ).pipe(Effect.forkIn(subscriptionsScope));
327 for (const client of yield* Ref.get(clients)) {331
328 if (client.readyState === client.OPEN) {332 yield* Stream.runForEach(keybindingsManager.changes, (event) =>
329 client.send(message);333 Effect.gen(function* () {
330 recipients += 1;334 yield* broadcastPush({
331 }335 type: "push",
332 }336 channel: WS_CHANNELS.serverConfigUpdated,
333 logOutgoingPush(push, recipients);337 data: event,
338 });
334 }),339 }),
335 ).pipe(Effect.forkIn(subscriptionsScope));340 ).pipe(Effect.forkIn(subscriptionsScope));
336341
...@@ -472,13 +477,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -472,13 +477,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
472 }477 }
473478
474 case WS_METHODS.serverGetConfig:479 case WS_METHODS.serverGetConfig:
475 const keybindingsConfig = yield* keybindingsManager.loadResolvedKeybindingsConfig;480 const keybindingsConfig = yield* keybindingsManager.loadConfigState;
476 return { cwd, keybindingsConfigPath, keybindings: keybindingsConfig };481 return {
482 cwd,
483 keybindingsConfigPath,
484 keybindings: keybindingsConfig.keybindings,
485 issues: keybindingsConfig.issues,
486 };
477487
478 case WS_METHODS.serverUpsertKeybinding: {488 case WS_METHODS.serverUpsertKeybinding: {
479 const body = stripRequestTag(request.body);489 const body = stripRequestTag(request.body);
480 const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body);490 const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body);
481 return { keybindings: keybindingsConfig };491 return { keybindings: keybindingsConfig, issues: [] };
482 }492 }
483493
484 default: {494 default: {
apps/web/src/routes/__root.tsx+52-3
...@@ -3,15 +3,17 @@ import {...@@ -3,15 +3,17 @@ import {
3 createRootRouteWithContext,3 createRootRouteWithContext,
4 type ErrorComponentProps,4 type ErrorComponentProps,
5} from "@tanstack/react-router";5} from "@tanstack/react-router";
6import { useEffect } from "react";6import { useEffect, useRef } from "react";
7import { QueryClient, useQueryClient } from "@tanstack/react-query";7import { QueryClient, useQueryClient } from "@tanstack/react-query";
88
9import { APP_DISPLAY_NAME } from "../branding";9import { APP_DISPLAY_NAME } from "../branding";
10import { Button } from "../components/ui/button";10import { Button } from "../components/ui/button";
11import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";11import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
12import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
12import { readNativeApi } from "../nativeApi";13import { readNativeApi } from "../nativeApi";
13import { useStore } from "../store";14import { useStore } from "../store";
14import { onServerWelcome } from "../wsNativeApi";15import { preferredTerminalEditor } from "../terminal-links";
16import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
15import { providerQueryKeys } from "../lib/providerReactQuery";17import { providerQueryKeys } from "../lib/providerReactQuery";
1618
17export const Route = createRootRouteWithContext<{19export const Route = createRootRouteWithContext<{
...@@ -119,6 +121,7 @@ function errorDetails(error: unknown): string {...@@ -119,6 +121,7 @@ function errorDetails(error: unknown): string {
119function EventRouter() {121function EventRouter() {
120 const { dispatch } = useStore();122 const { dispatch } = useStore();
121 const queryClient = useQueryClient();123 const queryClient = useQueryClient();
124 const lastConfigIssuesSignatureRef = useRef<string | null>(null);
122125
123 useEffect(() => {126 useEffect(() => {
124 const api = readNativeApi();127 const api = readNativeApi();
...@@ -170,10 +173,56 @@ function EventRouter() {...@@ -170,10 +173,56 @@ function EventRouter() {
170 const unsubWelcome = onServerWelcome(() => {173 const unsubWelcome = onServerWelcome(() => {
171 void syncSnapshot();174 void syncSnapshot();
172 });175 });
176 const unsubServerConfigUpdated = onServerConfigUpdated((payload) => {
177 const signature = JSON.stringify(payload.issues);
178 if (lastConfigIssuesSignatureRef.current === signature) {
179 return;
180 }
181 lastConfigIssuesSignatureRef.current = signature;
182
183 void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() });
184 const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings."));
185 if (!issue) {
186 toastManager.add({
187 type: "success",
188 title: "Keybindings updated",
189 description: "Keybindings configuration reloaded successfully.",
190 });
191 return;
192 }
193
194 toastManager.add({
195 type: "warning",
196 title: "Invalid keybindings configuration",
197 description: issue.message,
198 actionProps: {
199 children: "Open keybindings.json",
200 onClick: () => {
201 void queryClient
202 .ensureQueryData(serverConfigQueryOptions())
203 .then((config) =>
204 api.shell.openInEditor(
205 config.keybindingsConfigPath,
206 preferredTerminalEditor(),
207 ),
208 )
209 .catch((error) => {
210 toastManager.add({
211 type: "error",
212 title: "Unable to open keybindings file",
213 description:
214 error instanceof Error ? error.message : "Unknown error opening file.",
215 });
216 });
217 },
218 },
219 });
220 });
173 return () => {221 return () => {
174 disposed = true;222 disposed = true;
175 unsubDomainEvent();223 unsubDomainEvent();
176 unsubWelcome();224 unsubWelcome();
225 unsubServerConfigUpdated();
177 };226 };
178 }, [dispatch, queryClient]);227 }, [dispatch, queryClient]);
179228
apps/web/src/wsNativeApi.test.ts+51
...@@ -96,6 +96,57 @@ describe("wsNativeApi", () => {...@@ -96,6 +96,57 @@ describe("wsNativeApi", () => {
96 });96 });
97 });97 });
9898
99 it("delivers and caches valid server.configUpdated payloads", async () => {
100 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
101 const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi");
102
103 createWsNativeApi();
104 const listener = vi.fn();
105 onServerConfigUpdated(listener);
106
107 const payload = {
108 issues: [
109 {
110 kind: "keybindings.invalid-entry",
111 index: 1,
112 message: "Entry at index 1 is invalid.",
113 },
114 ],
115 } as const;
116 emitPush(WS_CHANNELS.serverConfigUpdated, payload);
117
118 expect(listener).toHaveBeenCalledTimes(1);
119 expect(listener).toHaveBeenCalledWith(payload);
120
121 const lateListener = vi.fn();
122 onServerConfigUpdated(lateListener);
123 expect(lateListener).toHaveBeenCalledTimes(1);
124 expect(lateListener).toHaveBeenCalledWith(payload);
125 expect(warnSpy).not.toHaveBeenCalled();
126 });
127
128 it("drops malformed server.configUpdated payloads", async () => {
129 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
130 const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi");
131
132 createWsNativeApi();
133 const listener = vi.fn();
134 onServerConfigUpdated(listener);
135
136 emitPush(WS_CHANNELS.serverConfigUpdated, {
137 issues: [{ kind: "keybindings.invalid-entry", message: "missing index" }],
138 });
139 emitPush(WS_CHANNELS.serverConfigUpdated, {
140 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
141 });
142
143 expect(listener).toHaveBeenCalledTimes(1);
144 expect(listener).toHaveBeenCalledWith({
145 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
146 });
147 expect(warnSpy).toHaveBeenCalledTimes(1);
148 });
149
99 it("forwards valid terminal and orchestration events", async () => {150 it("forwards valid terminal and orchestration events", async () => {
100 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});151 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
101 const { createWsNativeApi } = await import("./wsNativeApi");152 const { createWsNativeApi } = await import("./wsNativeApi");
apps/web/src/wsNativeApi.ts+37
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 ORCHESTRATION_WS_CHANNELS,3 ORCHESTRATION_WS_CHANNELS,
4 ORCHESTRATION_WS_METHODS,4 ORCHESTRATION_WS_METHODS,
5 type NativeApi,5 type NativeApi,
6 ServerConfigUpdatedPayload,
6 TerminalEvent,7 TerminalEvent,
7 WS_CHANNELS,8 WS_CHANNELS,
8 WS_METHODS,9 WS_METHODS,
...@@ -15,7 +16,9 @@ import { WsTransport } from "./wsTransport";...@@ -15,7 +16,9 @@ import { WsTransport } from "./wsTransport";
1516
16let instance: { api: NativeApi; transport: WsTransport } | null = null;17let instance: { api: NativeApi; transport: WsTransport } | null = null;
17const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();18const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
19const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();
18let lastWelcome: WsWelcomePayload | null = null;20let lastWelcome: WsWelcomePayload | null = null;
21let lastServerConfigUpdated: ServerConfigUpdatedPayload | null = null;
1922
20const decodeAndWarnOnFailure = <T>(23const decodeAndWarnOnFailure = <T>(
21 schema: Schema.Schema<T> & { readonly DecodingServices: never },24 schema: Schema.Schema<T> & { readonly DecodingServices: never },
...@@ -55,6 +58,28 @@ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void):...@@ -55,6 +58,28 @@ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void):
55 };58 };
56}59}
5760
61/**
62 * Subscribe to server config update events. Replays the latest update for
63 * late subscribers to avoid missing config validation feedback.
64 */
65export function onServerConfigUpdated(
66 listener: (payload: ServerConfigUpdatedPayload) => void,
67): () => void {
68 serverConfigUpdatedListeners.add(listener);
69
70 if (lastServerConfigUpdated) {
71 try {
72 listener(lastServerConfigUpdated);
73 } catch {
74 // Swallow listener errors
75 }
76 }
77
78 return () => {
79 serverConfigUpdatedListeners.delete(listener);
80 };
81}
82
58export function createWsNativeApi(): NativeApi {83export function createWsNativeApi(): NativeApi {
59 if (instance) return instance.api;84 if (instance) return instance.api;
6085
...@@ -74,6 +99,18 @@ export function createWsNativeApi(): NativeApi {...@@ -74,6 +99,18 @@ export function createWsNativeApi(): NativeApi {
74 }99 }
75 }100 }
76 });101 });
102 transport.subscribe(WS_CHANNELS.serverConfigUpdated, (data) => {
103 const payload = decodeAndWarnOnFailure(ServerConfigUpdatedPayload, data);
104 if (!payload) return;
105 lastServerConfigUpdated = payload;
106 for (const listener of serverConfigUpdatedListeners) {
107 try {
108 listener(payload);
109 } catch {
110 // Swallow listener errors
111 }
112 }
113 });
77114
78 const api: NativeApi = {115 const api: NativeApi = {
79 dialogs: {116 dialogs: {
packages/contracts/src/server.ts+29
...@@ -2,10 +2,33 @@ import { Schema } from "effect";...@@ -2,10 +2,33 @@ import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";2import { TrimmedNonEmptyString } from "./baseSchemas";
3import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";3import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
44
5export const KeybindingsMalformedConfigIssue = Schema.Struct({
6 kind: Schema.Literal("keybindings.malformed-config"),
7 message: TrimmedNonEmptyString,
8});
9export type KeybindingsMalformedConfigIssue = typeof KeybindingsMalformedConfigIssue.Type;
10
11export const KeybindingsInvalidEntryIssue = Schema.Struct({
12 kind: Schema.Literal("keybindings.invalid-entry"),
13 message: TrimmedNonEmptyString,
14 index: Schema.Number,
15});
16export type KeybindingsInvalidEntryIssue = typeof KeybindingsInvalidEntryIssue.Type;
17
18export const ServerConfigIssue = Schema.Union([
19 KeybindingsMalformedConfigIssue,
20 KeybindingsInvalidEntryIssue,
21]);
22export type ServerConfigIssue = typeof ServerConfigIssue.Type;
23
24export const ServerConfigIssues = Schema.Array(ServerConfigIssue);
25export type ServerConfigIssues = typeof ServerConfigIssues.Type;
26
5export const ServerConfig = Schema.Struct({27export const ServerConfig = Schema.Struct({
6 cwd: TrimmedNonEmptyString,28 cwd: TrimmedNonEmptyString,
7 keybindingsConfigPath: TrimmedNonEmptyString,29 keybindingsConfigPath: TrimmedNonEmptyString,
8 keybindings: ResolvedKeybindingsConfig,30 keybindings: ResolvedKeybindingsConfig,
31 issues: ServerConfigIssues,
9});32});
10export type ServerConfig = typeof ServerConfig.Type;33export type ServerConfig = typeof ServerConfig.Type;
1134
...@@ -14,5 +37,11 @@ export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Typ...@@ -14,5 +37,11 @@ export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Typ
1437
15export const ServerUpsertKeybindingResult = Schema.Struct({38export const ServerUpsertKeybindingResult = Schema.Struct({
16 keybindings: ResolvedKeybindingsConfig,39 keybindings: ResolvedKeybindingsConfig,
40 issues: ServerConfigIssues,
17});41});
18export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type;42export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type;
43
44export const ServerConfigUpdatedPayload = Schema.Struct({
45 issues: ServerConfigIssues,
46});
47export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;
packages/contracts/src/ws.ts+1
...@@ -73,6 +73,7 @@ export const WS_METHODS = {...@@ -73,6 +73,7 @@ export const WS_METHODS = {
73export const WS_CHANNELS = {73export const WS_CHANNELS = {
74 terminalEvent: "terminal.event",74 terminalEvent: "terminal.event",
75 serverWelcome: "server.welcome",75 serverWelcome: "server.welcome",
76 serverConfigUpdated: "server.configUpdated",
76} as const;77} as const;
7778
78// -- Tagged Union of all request body schemas ─────────────────────────79// -- Tagged Union of all request body schemas ─────────────────────────