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 {
1212 KeybindingsLive,
1313 ResolvedKeybindingFromConfig,
1414 compileResolvedKeybindingRule,
15 compileResolvedKeybindingsConfig,
1516 parseKeybindingShortcut,
1617} from "./keybindings";
1718
......@@ -166,6 +167,55 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
166167 }).pipe(Effect.provide(makeKeybindingsLayer())),
167168 );
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
169219 it.effect(
170220 "upserts missing default keybindings on startup without overriding existing command rules",
171221 () =>
......@@ -365,23 +415,18 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
365415
366416 const [first, second] = yield* Effect.gen(function* () {
367417 const keybindings = yield* Keybindings;
368 const firstLoad = yield* keybindings.loadResolvedKeybindingsConfig;
369 yield* writeKeybindingsConfig(keybindingsConfigPath, [
370 { key: "mod+x", command: "script.setup.run" },
371 ]);
372 const secondLoad = yield* keybindings.loadResolvedKeybindingsConfig;
418 const firstLoad = (yield* keybindings.loadConfigState).keybindings;
419 const secondLoad = (yield* keybindings.loadConfigState).keybindings;
373420 return [firstLoad, secondLoad] as const;
374421 });
375422
376423 assert.deepEqual(first, second);
377424 assert.isTrue(second.some((entry) => entry.command === "terminal.toggle"));
378 assert.isFalse(second.some((entry) => entry.command === "script.setup.run"));
379425 }).pipe(Effect.provide(makeKeybindingsLayer())),
380426 );
381427
382428 it.effect("updates cached resolved config after upsert", () =>
383429 Effect.gen(function* () {
384 const fs = yield* FileSystem.FileSystem;
385430 const { keybindingsConfigPath } = yield* ServerConfig;
386431 yield* writeKeybindingsConfig(keybindingsConfigPath, [
387432 { key: "mod+j", command: "terminal.toggle" },
......@@ -389,13 +434,12 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
389434
390435 const loadedAfterUpsert = yield* Effect.gen(function* () {
391436 const keybindings = yield* Keybindings;
392 yield* keybindings.loadResolvedKeybindingsConfig;
437 yield* keybindings.loadConfigState;
393438 yield* keybindings.upsertKeybindingRule({
394439 key: "mod+shift+r",
395440 command: "script.run-tests.run",
396441 });
397 yield* fs.writeFileString(keybindingsConfigPath, "{ not-json");
398 return yield* keybindings.loadResolvedKeybindingsConfig;
442 return (yield* keybindings.loadConfigState).keybindings;
399443 });
400444
401445 assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "script.run-tests.run"));
apps/server/src/keybindings.ts+182-20
......@@ -15,6 +15,7 @@ import {
1515 MAX_WHEN_EXPRESSION_DEPTH,
1616 ResolvedKeybindingRule,
1717 ResolvedKeybindingsConfig,
18 type ServerConfigIssue,
1819} from "@t3tools/contracts";
1920import { Mutable } from "effect/Types";
2021import {
......@@ -27,11 +28,13 @@ import {
2728 Layer,
2829 Option,
2930 Predicate,
31 PubSub,
3032 Schema,
3133 SchemaGetter,
3234 SchemaIssue,
3335 SchemaTransformation,
3436 ServiceMap,
37 Stream,
3538} from "effect";
3639import * as Semaphore from "effect/Semaphore";
3740import { ServerConfig } from "./config";
......@@ -412,6 +415,35 @@ const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe(
412415 }),
413416);
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
415447function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
416448 if (custom.length === 0) {
417449 return [...DEFAULT_RESOLVED_KEYBINDINGS];
......@@ -442,14 +474,14 @@ export interface KeybindingsShape {
442474 readonly syncDefaultKeybindingsOnStartup: Effect.Effect<void, KeybindingsConfigError>;
443475
444476 /**
445 * Load and resolve keybindings from disk merged with defaults.
446 *
447 * Returns an in-memory cached result after the first successful load.
477 * Load runtime keybindings state along with non-fatal configuration issues.
448478 */
449 readonly loadResolvedKeybindingsConfig: Effect.Effect<
450 readonly ResolvedKeybindingRule[],
451 KeybindingsConfigError
452 >;
479 readonly loadConfigState: Effect.Effect<KeybindingsConfigState, KeybindingsConfigError>;
480
481 /**
482 * Stream keybindings config change events.
483 */
484 readonly changes: Stream.Stream<KeybindingsChangeEvent>;
453485
454486 /**
455487 * Upsert a keybinding rule and persist the resulting configuration.
......@@ -475,6 +507,10 @@ const makeKeybindings = Effect.gen(function* () {
475507 const path = yield* Path.Path;
476508 const upsertSemaphore = yield* Semaphore.make(1);
477509 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
479515 const readConfigExists = fs.exists(keybindingsConfigPath).pipe(
480516 Effect.mapError(
......@@ -487,7 +523,18 @@ const makeKeybindings = Effect.gen(function* () {
487523 ),
488524 );
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<
491538 readonly KeybindingRule[],
492539 KeybindingsConfigError
493540 > {
......@@ -495,7 +542,7 @@ const makeKeybindings = Effect.gen(function* () {
495542 return [];
496543 }
497544
498 const rawConfig = yield* fs.readFileString(keybindingsConfigPath).pipe(
545 const rawConfig = yield* readRawConfig.pipe(
499546 Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)),
500547 Effect.mapError(
501548 (cause) =>
......@@ -532,6 +579,61 @@ const makeKeybindings = Effect.gen(function* () {
532579 ).pipe(Effect.map(Array.filter(Predicate.isNotNull)));
533580 });
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
535637 const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {
536638 const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;
537639
......@@ -551,20 +653,65 @@ const makeKeybindings = Effect.gen(function* () {
551653 );
552654 };
553655
554 const loadResolvedFromDisk = loadCustomKeybindingsConfig().pipe(
555 Effect.map(compileResolvedKeybindingsConfig),
556 Effect.map(mergeWithDefaultKeybindings),
656 const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe(
657 Effect.map(({ keybindings, issues }) => ({
658 keybindings: mergeWithDefaultKeybindings(compileResolvedKeybindingsConfig(keybindings)),
659 issues,
660 })),
557661 );
558662
559663 const resolvedConfigCache = yield* Cache.make<
560664 typeof resolvedConfigCacheKey,
561 ResolvedKeybindingsConfig,
665 KeybindingsConfigState,
562666 KeybindingsConfigError
563667 >({
564668 capacity: 1,
565 lookup: () => loadResolvedFromDisk,
669 lookup: () => loadConfigStateFromDisk,
566670 });
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
568715 const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)(
569716 Effect.gen(function* () {
570717 const configExists = yield* readConfigExists;
......@@ -574,7 +721,19 @@ const makeKeybindings = Effect.gen(function* () {
574721 return;
575722 }
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;
578737 const existingCommands = new Set(customConfig.map((entry) => entry.command));
579738 const missingDefaults: KeybindingRule[] = [];
580739 const shortcutConflictWarnings: Array<{
......@@ -643,15 +802,14 @@ const makeKeybindings = Effect.gen(function* () {
643802 }),
644803 );
645804
646 const loadResolvedFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey);
647
648805 return {
649806 syncDefaultKeybindingsOnStartup,
650 loadResolvedKeybindingsConfig: loadResolvedFromCacheOrDisk,
807 loadConfigState: loadConfigStateFromCacheOrDisk,
808 changes: Stream.fromPubSub(changesPubSub),
651809 upsertKeybindingRule: (rule) =>
652810 upsertSemaphore.withPermits(1)(
653811 Effect.gen(function* () {
654 const customConfig = yield* loadCustomKeybindingsConfig();
812 const customConfig = yield* loadWritableCustomKeybindingsConfig();
655813 const nextConfig = [
656814 ...customConfig.filter((entry) => entry.command !== rule.command),
657815 rule,
......@@ -670,7 +828,11 @@ const makeKeybindings = Effect.gen(function* () {
670828 const nextResolved = mergeWithDefaultKeybindings(
671829 compileResolvedKeybindingsConfig(cappedConfig),
672830 );
673 yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, nextResolved);
831 yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
832 keybindings: nextResolved,
833 issues: [],
834 });
835 yield* emitChange([]);
674836 return nextResolved;
675837 }),
676838 ),
apps/server/src/wsServer.test.ts+120
......@@ -517,6 +517,7 @@ describe("WebSocket Server", () => {
517517 cwd: "/my/workspace",
518518 keybindingsConfigPath: keybindingsPath,
519519 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
520 issues: [],
520521 });
521522 });
522523
......@@ -539,12 +540,128 @@ describe("WebSocket Server", () => {
539540 cwd: "/my/workspace",
540541 keybindingsConfigPath: keybindingsPath,
541542 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
543 issues: [],
542544 });
543545
544546 const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig;
545547 expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS);
546548 });
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
548665 it("routes shell.openInEditor through the injected open service", async () => {
549666 const openCalls: Array<{ cwd: string; editor: string }> = [];
550667 const openService: OpenShape = {
......@@ -599,6 +716,7 @@ describe("WebSocket Server", () => {
599716 cwd: "/my/workspace",
600717 keybindingsConfigPath: keybindingsPath,
601718 keybindings: compileKeybindings(persistedConfig),
719 issues: [],
602720 });
603721 });
604722
......@@ -632,6 +750,7 @@ describe("WebSocket Server", () => {
632750 expect(persistedCommands.has("script.run-tests.run")).toBe(true);
633751 expect(upsertResponse.result).toEqual({
634752 keybindings: compileKeybindings(persistedConfig),
753 issues: [],
635754 });
636755
637756 const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
......@@ -640,6 +759,7 @@ describe("WebSocket Server", () => {
640759 cwd: "/my/workspace",
641760 keybindingsConfigPath: keybindingsPath,
642761 keybindings: compileKeybindings(persistedConfig),
762 issues: [],
643763 });
644764 });
645765
apps/server/src/wsServer.ts+31-21
......@@ -208,13 +208,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
208208 });
209209 }
210210
211 const onTerminalEvent = Effect.fnUntraced(function* (event: TerminalEvent) {
212 const push: WsPush = {
213 type: "push",
214 channel: WS_CHANNELS.terminalEvent,
215 data: event,
216 };
217 const message = yield* Schema.encodeEffect(Schema.fromJsonString(WsPush))(push);
211 const encodePush = Schema.encodeEffect(Schema.fromJsonString(WsPush));
212 const broadcastPush = Effect.fnUntraced(function* (push: WsPush) {
213 const message = yield* encodePush(push);
218214 let recipients = 0;
219215 for (const client of yield* Ref.get(clients)) {
220216 if (client.readyState === client.OPEN) {
......@@ -225,6 +221,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
225221 logOutgoingPush(push, recipients);
226222 });
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
228232 // HTTP server — serves static files or redirects to Vite dev server
229233 const httpServer = http.createServer((req, res) => {
230234 // In dev mode, redirect to Vite dev server
......@@ -317,20 +321,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
317321
318322 yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>
319323 Effect.gen(function* () {
320 const push: WsPush = {
324 yield* broadcastPush({
321325 type: "push",
322326 channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
323327 data: event,
324 };
325 const message = yield* Schema.encodeEffect(Schema.fromJsonString(WsPush))(push);
326 let recipients = 0;
327 for (const client of yield* Ref.get(clients)) {
328 if (client.readyState === client.OPEN) {
329 client.send(message);
330 recipients += 1;
331 }
332 }
333 logOutgoingPush(push, recipients);
328 });
329 }),
330 ).pipe(Effect.forkIn(subscriptionsScope));
331
332 yield* Stream.runForEach(keybindingsManager.changes, (event) =>
333 Effect.gen(function* () {
334 yield* broadcastPush({
335 type: "push",
336 channel: WS_CHANNELS.serverConfigUpdated,
337 data: event,
338 });
334339 }),
335340 ).pipe(Effect.forkIn(subscriptionsScope));
336341
......@@ -472,13 +477,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
472477 }
473478
474479 case WS_METHODS.serverGetConfig:
475 const keybindingsConfig = yield* keybindingsManager.loadResolvedKeybindingsConfig;
476 return { cwd, keybindingsConfigPath, keybindings: keybindingsConfig };
480 const keybindingsConfig = yield* keybindingsManager.loadConfigState;
481 return {
482 cwd,
483 keybindingsConfigPath,
484 keybindings: keybindingsConfig.keybindings,
485 issues: keybindingsConfig.issues,
486 };
477487
478488 case WS_METHODS.serverUpsertKeybinding: {
479489 const body = stripRequestTag(request.body);
480490 const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body);
481 return { keybindings: keybindingsConfig };
491 return { keybindings: keybindingsConfig, issues: [] };
482492 }
483493
484494 default: {
apps/web/src/routes/__root.tsx+52-3
......@@ -3,15 +3,17 @@ import {
33 createRootRouteWithContext,
44 type ErrorComponentProps,
55} from "@tanstack/react-router";
6import { useEffect } from "react";
6import { useEffect, useRef } from "react";
77import { QueryClient, useQueryClient } from "@tanstack/react-query";
88
99import { APP_DISPLAY_NAME } from "../branding";
1010import { 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";
1213import { readNativeApi } from "../nativeApi";
1314import { useStore } from "../store";
14import { onServerWelcome } from "../wsNativeApi";
15import { preferredTerminalEditor } from "../terminal-links";
16import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
1517import { providerQueryKeys } from "../lib/providerReactQuery";
1618
1719export const Route = createRootRouteWithContext<{
......@@ -119,6 +121,7 @@ function errorDetails(error: unknown): string {
119121function EventRouter() {
120122 const { dispatch } = useStore();
121123 const queryClient = useQueryClient();
124 const lastConfigIssuesSignatureRef = useRef<string | null>(null);
122125
123126 useEffect(() => {
124127 const api = readNativeApi();
......@@ -170,10 +173,56 @@ function EventRouter() {
170173 const unsubWelcome = onServerWelcome(() => {
171174 void syncSnapshot();
172175 });
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 });
173221 return () => {
174222 disposed = true;
175223 unsubDomainEvent();
176224 unsubWelcome();
225 unsubServerConfigUpdated();
177226 };
178227 }, [dispatch, queryClient]);
179228
apps/web/src/wsNativeApi.test.ts+51
......@@ -96,6 +96,57 @@ describe("wsNativeApi", () => {
9696 });
9797 });
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
99150 it("forwards valid terminal and orchestration events", async () => {
100151 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
101152 const { createWsNativeApi } = await import("./wsNativeApi");
apps/web/src/wsNativeApi.ts+37
......@@ -3,6 +3,7 @@ import {
33 ORCHESTRATION_WS_CHANNELS,
44 ORCHESTRATION_WS_METHODS,
55 type NativeApi,
6 ServerConfigUpdatedPayload,
67 TerminalEvent,
78 WS_CHANNELS,
89 WS_METHODS,
......@@ -15,7 +16,9 @@ import { WsTransport } from "./wsTransport";
1516
1617let instance: { api: NativeApi; transport: WsTransport } | null = null;
1718const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
19const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();
1820let lastWelcome: WsWelcomePayload | null = null;
21let lastServerConfigUpdated: ServerConfigUpdatedPayload | null = null;
1922
2023const decodeAndWarnOnFailure = <T>(
2124 schema: Schema.Schema<T> & { readonly DecodingServices: never },
......@@ -55,6 +58,28 @@ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void):
5558 };
5659}
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
5883export function createWsNativeApi(): NativeApi {
5984 if (instance) return instance.api;
6085
......@@ -74,6 +99,18 @@ export function createWsNativeApi(): NativeApi {
7499 }
75100 }
76101 });
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
78115 const api: NativeApi = {
79116 dialogs: {
packages/contracts/src/server.ts+29
......@@ -2,10 +2,33 @@ import { Schema } from "effect";
22import { TrimmedNonEmptyString } from "./baseSchemas";
33import { 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
527export const ServerConfig = Schema.Struct({
628 cwd: TrimmedNonEmptyString,
729 keybindingsConfigPath: TrimmedNonEmptyString,
830 keybindings: ResolvedKeybindingsConfig,
31 issues: ServerConfigIssues,
932});
1033export type ServerConfig = typeof ServerConfig.Type;
1134
......@@ -14,5 +37,11 @@ export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Typ
1437
1538export const ServerUpsertKeybindingResult = Schema.Struct({
1639 keybindings: ResolvedKeybindingsConfig,
40 issues: ServerConfigIssues,
1741});
1842export 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 = {
7373export const WS_CHANNELS = {
7474 terminalEvent: "terminal.event",
7575 serverWelcome: "server.welcome",
76 serverConfigUpdated: "server.configUpdated",
7677} as const;
7778
7879// -- Tagged Union of all request body schemas ─────────────────────────