From 41ba1ec7a8965b42a7a7bf48065e5a800d46dc9e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 14:05:07 -0800 Subject: [PATCH] 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 --- KEYBINDINGS.md | 3 + apps/server/src/keybindings.ts | 112 +++++++++++++++++++++++++++++++++ apps/server/src/wsServer.ts | 110 +------------------------------- 3 files changed, 117 insertions(+), 108 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 87c502b685ee8bd7319b8d0710f9f9ae8ee2388b..8128159f1175c15ef235fed11fbce74f84241b6c 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -12,6 +12,9 @@ Schema source of truth: Server-side parsing/default resolution/merging: - [`apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts) + +Server wiring (`server.getConfig`): + - [`apps/server/src/wsServer.ts`](apps/server/src/wsServer.ts) The file must be a JSON array of rules: diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 7939286c1a991aba83e8739ece45c26ae4a5562a..1c9324e7699b804fd725f0a82b1e47ef746be250 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import type { KeybindingRule, KeybindingsConfig, @@ -6,6 +10,11 @@ import type { ResolvedKeybindingRule, ResolvedKeybindingsConfig, } from "@t3tools/contracts"; +import { keybindingRuleSchema } from "@t3tools/contracts"; + +interface KeybindingsLogger { + warn(message: string, context?: Record): void; +} type WhenToken = | { type: "identifier"; value: string } @@ -15,6 +24,14 @@ type WhenToken = | { type: "lparen" } | { type: "rparen" }; +const DEFAULT_KEYBINDINGS: KeybindingsConfig = [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+shift+o", command: "chat.new" }, + { key: "mod+o", command: "editor.openFavorite" }, +]; + function normalizeKeyToken(token: string): string { if (token === "space") return " "; if (token === "esc") return "escape"; @@ -239,3 +256,98 @@ export function compileResolvedKeybindingsConfig( } return compiled; } + +function compileDefaultKeybindings(): ResolvedKeybindingsConfig { + const resolved: ResolvedKeybindingsConfig = []; + for (const rule of DEFAULT_KEYBINDINGS) { + const compiled = compileResolvedKeybindingRule(rule); + if (!compiled) { + throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`); + } + resolved.push(compiled); + } + return resolved; +} + +const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); + +function mergeWithDefaultKeybindings( + custom: ResolvedKeybindingsConfig, +): ResolvedKeybindingsConfig { + if (custom.length === 0) { + return [...DEFAULT_RESOLVED_KEYBINDINGS]; + } + + const overriddenCommands = new Set(custom.map((binding) => binding.command)); + const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( + (binding) => !overriddenCommands.has(binding.command), + ); + const merged = [...retainedDefaults, ...custom]; + + if (merged.length <= 256) { + return merged; + } + + // Keep the latest rules when the config exceeds max size; later rules have higher precedence. + return merged.slice(-256); +} + +export function loadResolvedKeybindingsConfig( + logger: KeybindingsLogger, +): ResolvedKeybindingsConfig { + const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); + try { + const raw = fs.readFileSync(configPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + logger.warn("ignoring keybindings config with unsupported format; expected array", { + path: configPath, + }); + return [...DEFAULT_RESOLVED_KEYBINDINGS]; + } + + const sanitized: ResolvedKeybindingsConfig = []; + let invalidEntries = 0; + for (const entry of parsed) { + const result = keybindingRuleSchema.safeParse(entry); + if (result.success) { + const compiled = compileResolvedKeybindingRule(result.data); + if (!compiled) { + invalidEntries += 1; + continue; + } + sanitized.push(compiled); + continue; + } + invalidEntries += 1; + } + if (invalidEntries > 0) { + logger.warn("ignoring invalid keybinding entries", { + path: configPath, + invalidEntries, + totalEntries: parsed.length, + }); + } + const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); + const mergedBeforeCapLength = + DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) + .length + sanitized.length; + const merged = mergeWithDefaultKeybindings(sanitized); + if (mergedBeforeCapLength > 256) { + logger.warn("truncating merged keybindings config to max entries", { + path: configPath, + maxEntries: 256, + }); + } + return merged; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return [...DEFAULT_RESOLVED_KEYBINDINGS]; + } + logger.warn("ignoring malformed keybindings config", { + path: configPath, + error: error instanceof Error ? error.message : String(error), + }); + } + return [...DEFAULT_RESOLVED_KEYBINDINGS]; +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index d6ea286981f3b8cd7a4f74f989e4f8369d4ee374..a8cbbba51e572a2efd94aa8a6548719136c36991 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -9,9 +9,6 @@ import { EDITORS, WS_CHANNELS, WS_METHODS, - keybindingRuleSchema, - type KeybindingsConfig, - type ResolvedKeybindingsConfig, type TerminalEvent, type WsPush, type WsRequest, @@ -34,7 +31,7 @@ import { removeGitWorktree, } from "./git"; import { TerminalManager } from "./terminalManager"; -import { compileResolvedKeybindingRule } from "./keybindings"; +import { loadResolvedKeybindingsConfig } from "./keybindings"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -72,109 +69,6 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { return undefined; } -const DEFAULT_KEYBINDINGS: KeybindingsConfig = [ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, - { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, - { key: "mod+shift+o", command: "chat.new" }, - { key: "mod+o", command: "editor.openFavorite" }, -]; - -function compileDefaultKeybindings(): ResolvedKeybindingsConfig { - const resolved: ResolvedKeybindingsConfig = []; - for (const rule of DEFAULT_KEYBINDINGS) { - const compiled = compileResolvedKeybindingRule(rule); - if (!compiled) { - throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`); - } - resolved.push(compiled); - } - return resolved; -} - -const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); - -function mergeWithDefaultKeybindings( - custom: ResolvedKeybindingsConfig, -): ResolvedKeybindingsConfig { - if (custom.length === 0) { - return [...DEFAULT_RESOLVED_KEYBINDINGS]; - } - - const overriddenCommands = new Set(custom.map((binding) => binding.command)); - const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( - (binding) => !overriddenCommands.has(binding.command), - ); - const merged = [...retainedDefaults, ...custom]; - - if (merged.length <= 256) { - return merged; - } - - // Keep the latest rules when the config exceeds max size; later rules have higher precedence. - return merged.slice(-256); -} - -function readKeybindingsConfig( - logger: ReturnType, -): ResolvedKeybindingsConfig { - const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); - try { - const raw = fs.readFileSync(configPath, "utf8"); - const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) { - logger.warn("ignoring keybindings config with unsupported format; expected array", { - path: configPath, - }); - return [...DEFAULT_RESOLVED_KEYBINDINGS]; - } - - const sanitized: ResolvedKeybindingsConfig = []; - let invalidEntries = 0; - for (const entry of parsed) { - const result = keybindingRuleSchema.safeParse(entry); - if (result.success) { - const compiled = compileResolvedKeybindingRule(result.data); - if (!compiled) { - invalidEntries += 1; - continue; - } - sanitized.push(compiled); - continue; - } - invalidEntries += 1; - } - if (invalidEntries > 0) { - logger.warn("ignoring invalid keybinding entries", { - path: configPath, - invalidEntries, - totalEntries: parsed.length, - }); - } - const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); - const mergedBeforeCapLength = - DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) - .length + sanitized.length; - const merged = mergeWithDefaultKeybindings(sanitized); - if (mergedBeforeCapLength > 256) { - logger.warn("truncating merged keybindings config to max entries", { - path: configPath, - maxEntries: 256, - }); - } - return merged; - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return [...DEFAULT_RESOLVED_KEYBINDINGS]; - } - logger.warn("ignoring malformed keybindings config", { - path: configPath, - error: error instanceof Error ? error.message : String(error), - }); - } - return [...DEFAULT_RESOLVED_KEYBINDINGS]; -} - export function createServer(options: ServerOptions) { const { port, @@ -197,7 +91,7 @@ export function createServer(options: ServerOptions) { const logger = createLogger("ws"); const logWebSocketEvents = explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); - const keybindingsConfig = readKeybindingsConfig(logger); + const keybindingsConfig = loadResolvedKeybindingsConfig(logger); function logOutgoingPush(push: WsPush, recipients: number) { if (!logWebSocketEvents) return; -- 2.54.0