authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 14:05:07-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 15:51:05-08:00
log41ba1ec7a8965b42a7a7bf48065e5a800d46dc9e
tree342ae2f640f996d131d5851d08572d4aace3a917
parent3db020a683398511a169c411513a945a1cd2f384

Extract keybindings config loading into keybindings module

- Move default keybindings, config parsing, validation, and merge logic out of `wsServer` - Add `loadResolvedKeybindingsConfig(logger)` in `apps/server/src/keybindings.ts` - Update server wiring docs to point to `server.getConfig` integration

3 files changed, 117 insertions(+), 108 deletions(-)

KEYBINDINGS.md+3
......@@ -12,6 +12,9 @@ Schema source of truth:
1212Server-side parsing/default resolution/merging:
1313
1414- [`apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts)
15
16Server wiring (`server.getConfig`):
17
1518- [`apps/server/src/wsServer.ts`](apps/server/src/wsServer.ts)
1619
1720The file must be a JSON array of rules:
apps/server/src/keybindings.ts+112
......@@ -1,3 +1,7 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
15import type {
26 KeybindingRule,
37 KeybindingsConfig,
......@@ -6,6 +10,11 @@ import type {
610 ResolvedKeybindingRule,
711 ResolvedKeybindingsConfig,
812} from "@t3tools/contracts";
13import { keybindingRuleSchema } from "@t3tools/contracts";
14
15interface KeybindingsLogger {
16 warn(message: string, context?: Record<string, unknown>): void;
17}
918
1019type WhenToken =
1120 | { type: "identifier"; value: string }
......@@ -15,6 +24,14 @@ type WhenToken =
1524 | { type: "lparen" }
1625 | { type: "rparen" };
1726
27const DEFAULT_KEYBINDINGS: KeybindingsConfig = [
28 { key: "mod+j", command: "terminal.toggle" },
29 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
30 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
31 { key: "mod+shift+o", command: "chat.new" },
32 { key: "mod+o", command: "editor.openFavorite" },
33];
34
1835function normalizeKeyToken(token: string): string {
1936 if (token === "space") return " ";
2037 if (token === "esc") return "escape";
......@@ -239,3 +256,98 @@ export function compileResolvedKeybindingsConfig(
239256 }
240257 return compiled;
241258}
259
260function compileDefaultKeybindings(): ResolvedKeybindingsConfig {
261 const resolved: ResolvedKeybindingsConfig = [];
262 for (const rule of DEFAULT_KEYBINDINGS) {
263 const compiled = compileResolvedKeybindingRule(rule);
264 if (!compiled) {
265 throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`);
266 }
267 resolved.push(compiled);
268 }
269 return resolved;
270}
271
272const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();
273
274function mergeWithDefaultKeybindings(
275 custom: ResolvedKeybindingsConfig,
276): ResolvedKeybindingsConfig {
277 if (custom.length === 0) {
278 return [...DEFAULT_RESOLVED_KEYBINDINGS];
279 }
280
281 const overriddenCommands = new Set(custom.map((binding) => binding.command));
282 const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(
283 (binding) => !overriddenCommands.has(binding.command),
284 );
285 const merged = [...retainedDefaults, ...custom];
286
287 if (merged.length <= 256) {
288 return merged;
289 }
290
291 // Keep the latest rules when the config exceeds max size; later rules have higher precedence.
292 return merged.slice(-256);
293}
294
295export function loadResolvedKeybindingsConfig(
296 logger: KeybindingsLogger,
297): ResolvedKeybindingsConfig {
298 const configPath = path.join(os.homedir(), ".t3", "keybindings.json");
299 try {
300 const raw = fs.readFileSync(configPath, "utf8");
301 const parsed = JSON.parse(raw) as unknown;
302 if (!Array.isArray(parsed)) {
303 logger.warn("ignoring keybindings config with unsupported format; expected array", {
304 path: configPath,
305 });
306 return [...DEFAULT_RESOLVED_KEYBINDINGS];
307 }
308
309 const sanitized: ResolvedKeybindingsConfig = [];
310 let invalidEntries = 0;
311 for (const entry of parsed) {
312 const result = keybindingRuleSchema.safeParse(entry);
313 if (result.success) {
314 const compiled = compileResolvedKeybindingRule(result.data);
315 if (!compiled) {
316 invalidEntries += 1;
317 continue;
318 }
319 sanitized.push(compiled);
320 continue;
321 }
322 invalidEntries += 1;
323 }
324 if (invalidEntries > 0) {
325 logger.warn("ignoring invalid keybinding entries", {
326 path: configPath,
327 invalidEntries,
328 totalEntries: parsed.length,
329 });
330 }
331 const overriddenCommands = new Set(sanitized.map((entry) => entry.command));
332 const mergedBeforeCapLength =
333 DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command))
334 .length + sanitized.length;
335 const merged = mergeWithDefaultKeybindings(sanitized);
336 if (mergedBeforeCapLength > 256) {
337 logger.warn("truncating merged keybindings config to max entries", {
338 path: configPath,
339 maxEntries: 256,
340 });
341 }
342 return merged;
343 } catch (error) {
344 if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
345 return [...DEFAULT_RESOLVED_KEYBINDINGS];
346 }
347 logger.warn("ignoring malformed keybindings config", {
348 path: configPath,
349 error: error instanceof Error ? error.message : String(error),
350 });
351 }
352 return [...DEFAULT_RESOLVED_KEYBINDINGS];
353}
apps/server/src/wsServer.ts+2-108
......@@ -9,9 +9,6 @@ import {
99 EDITORS,
1010 WS_CHANNELS,
1111 WS_METHODS,
12 keybindingRuleSchema,
13 type KeybindingsConfig,
14 type ResolvedKeybindingsConfig,
1512 type TerminalEvent,
1613 type WsPush,
1714 type WsRequest,
......@@ -34,7 +31,7 @@ import {
3431 removeGitWorktree,
3532} from "./git";
3633import { TerminalManager } from "./terminalManager";
37import { compileResolvedKeybindingRule } from "./keybindings";
34import { loadResolvedKeybindingsConfig } from "./keybindings";
3835
3936const MIME_TYPES: Record<string, string> = {
4037 ".html": "text/html; charset=utf-8",
......@@ -72,109 +69,6 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
7269 return undefined;
7370}
7471
75const DEFAULT_KEYBINDINGS: KeybindingsConfig = [
76 { key: "mod+j", command: "terminal.toggle" },
77 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
78 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
79 { key: "mod+shift+o", command: "chat.new" },
80 { key: "mod+o", command: "editor.openFavorite" },
81];
82
83function compileDefaultKeybindings(): ResolvedKeybindingsConfig {
84 const resolved: ResolvedKeybindingsConfig = [];
85 for (const rule of DEFAULT_KEYBINDINGS) {
86 const compiled = compileResolvedKeybindingRule(rule);
87 if (!compiled) {
88 throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`);
89 }
90 resolved.push(compiled);
91 }
92 return resolved;
93}
94
95const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();
96
97function mergeWithDefaultKeybindings(
98 custom: ResolvedKeybindingsConfig,
99): ResolvedKeybindingsConfig {
100 if (custom.length === 0) {
101 return [...DEFAULT_RESOLVED_KEYBINDINGS];
102 }
103
104 const overriddenCommands = new Set(custom.map((binding) => binding.command));
105 const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(
106 (binding) => !overriddenCommands.has(binding.command),
107 );
108 const merged = [...retainedDefaults, ...custom];
109
110 if (merged.length <= 256) {
111 return merged;
112 }
113
114 // Keep the latest rules when the config exceeds max size; later rules have higher precedence.
115 return merged.slice(-256);
116}
117
118function readKeybindingsConfig(
119 logger: ReturnType<typeof createLogger>,
120): ResolvedKeybindingsConfig {
121 const configPath = path.join(os.homedir(), ".t3", "keybindings.json");
122 try {
123 const raw = fs.readFileSync(configPath, "utf8");
124 const parsed = JSON.parse(raw) as unknown;
125 if (!Array.isArray(parsed)) {
126 logger.warn("ignoring keybindings config with unsupported format; expected array", {
127 path: configPath,
128 });
129 return [...DEFAULT_RESOLVED_KEYBINDINGS];
130 }
131
132 const sanitized: ResolvedKeybindingsConfig = [];
133 let invalidEntries = 0;
134 for (const entry of parsed) {
135 const result = keybindingRuleSchema.safeParse(entry);
136 if (result.success) {
137 const compiled = compileResolvedKeybindingRule(result.data);
138 if (!compiled) {
139 invalidEntries += 1;
140 continue;
141 }
142 sanitized.push(compiled);
143 continue;
144 }
145 invalidEntries += 1;
146 }
147 if (invalidEntries > 0) {
148 logger.warn("ignoring invalid keybinding entries", {
149 path: configPath,
150 invalidEntries,
151 totalEntries: parsed.length,
152 });
153 }
154 const overriddenCommands = new Set(sanitized.map((entry) => entry.command));
155 const mergedBeforeCapLength =
156 DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command))
157 .length + sanitized.length;
158 const merged = mergeWithDefaultKeybindings(sanitized);
159 if (mergedBeforeCapLength > 256) {
160 logger.warn("truncating merged keybindings config to max entries", {
161 path: configPath,
162 maxEntries: 256,
163 });
164 }
165 return merged;
166 } catch (error) {
167 if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
168 return [...DEFAULT_RESOLVED_KEYBINDINGS];
169 }
170 logger.warn("ignoring malformed keybindings config", {
171 path: configPath,
172 error: error instanceof Error ? error.message : String(error),
173 });
174 }
175 return [...DEFAULT_RESOLVED_KEYBINDINGS];
176}
177
17872export function createServer(options: ServerOptions) {
17973 const {
18074 port,
......@@ -197,7 +91,7 @@ export function createServer(options: ServerOptions) {
19791 const logger = createLogger("ws");
19892 const logWebSocketEvents =
19993 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);
200 const keybindingsConfig = readKeybindingsConfig(logger);
94 const keybindingsConfig = loadResolvedKeybindingsConfig(logger);
20195
20296 function logOutgoingPush(push: WsPush, recipients: number) {
20397 if (!logWebSocketEvents) return;