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:...@@ -12,6 +12,9 @@ Schema source of truth:
12Server-side parsing/default resolution/merging:12Server-side parsing/default resolution/merging:
1313
14- [`apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts)14- [`apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts)
15
16Server wiring (`server.getConfig`):
17
15- [`apps/server/src/wsServer.ts`](apps/server/src/wsServer.ts)18- [`apps/server/src/wsServer.ts`](apps/server/src/wsServer.ts)
1619
17The file must be a JSON array of rules:20The file must be a JSON array of rules:
apps/server/src/keybindings.ts+112
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
1import type {5import type {
2 KeybindingRule,6 KeybindingRule,
3 KeybindingsConfig,7 KeybindingsConfig,
...@@ -6,6 +10,11 @@ import type {...@@ -6,6 +10,11 @@ import type {
6 ResolvedKeybindingRule,10 ResolvedKeybindingRule,
7 ResolvedKeybindingsConfig,11 ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { keybindingRuleSchema } from "@t3tools/contracts";
14
15interface KeybindingsLogger {
16 warn(message: string, context?: Record<string, unknown>): void;
17}
918
10type WhenToken =19type WhenToken =
11 | { type: "identifier"; value: string }20 | { type: "identifier"; value: string }
...@@ -15,6 +24,14 @@ type WhenToken =...@@ -15,6 +24,14 @@ type WhenToken =
15 | { type: "lparen" }24 | { type: "lparen" }
16 | { type: "rparen" };25 | { 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
18function normalizeKeyToken(token: string): string {35function normalizeKeyToken(token: string): string {
19 if (token === "space") return " ";36 if (token === "space") return " ";
20 if (token === "esc") return "escape";37 if (token === "esc") return "escape";
...@@ -239,3 +256,98 @@ export function compileResolvedKeybindingsConfig(...@@ -239,3 +256,98 @@ export function compileResolvedKeybindingsConfig(
239 }256 }
240 return compiled;257 return compiled;
241}258}
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 {...@@ -9,9 +9,6 @@ import {
9 EDITORS,9 EDITORS,
10 WS_CHANNELS,10 WS_CHANNELS,
11 WS_METHODS,11 WS_METHODS,
12 keybindingRuleSchema,
13 type KeybindingsConfig,
14 type ResolvedKeybindingsConfig,
15 type TerminalEvent,12 type TerminalEvent,
16 type WsPush,13 type WsPush,
17 type WsRequest,14 type WsRequest,
...@@ -34,7 +31,7 @@ import {...@@ -34,7 +31,7 @@ import {
34 removeGitWorktree,31 removeGitWorktree,
35} from "./git";32} from "./git";
36import { TerminalManager } from "./terminalManager";33import { TerminalManager } from "./terminalManager";
37import { compileResolvedKeybindingRule } from "./keybindings";34import { loadResolvedKeybindingsConfig } from "./keybindings";
3835
39const MIME_TYPES: Record<string, string> = {36const MIME_TYPES: Record<string, string> = {
40 ".html": "text/html; charset=utf-8",37 ".html": "text/html; charset=utf-8",
...@@ -72,109 +69,6 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {...@@ -72,109 +69,6 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
72 return undefined;69 return undefined;
73}70}
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
178export function createServer(options: ServerOptions) {72export function createServer(options: ServerOptions) {
179 const {73 const {
180 port,74 port,
...@@ -197,7 +91,7 @@ export function createServer(options: ServerOptions) {...@@ -197,7 +91,7 @@ export function createServer(options: ServerOptions) {
197 const logger = createLogger("ws");91 const logger = createLogger("ws");
198 const logWebSocketEvents =92 const logWebSocketEvents =
199 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);93 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);
200 const keybindingsConfig = readKeybindingsConfig(logger);94 const keybindingsConfig = loadResolvedKeybindingsConfig(logger);
20195
202 function logOutgoingPush(push: WsPush, recipients: number) {96 function logOutgoingPush(push: WsPush, recipients: number) {
203 if (!logWebSocketEvents) return;97 if (!logWebSocketEvents) return;