History

Resolve terminal keybindings on server before sending config

- Move default keybinding resolution/override merging into `server.getConfig` - Return server-resolved keybindings to the web client instead of resolving in UI - Update docs and tests to reflect server-side defaults, overrides, and truncation behavior

Julius Marminge committed 8 months ago

Commit f1cd76c1 parent 31aa32b6 files · +108 −134Browse files

Unified · Split

6 files
  1. KEYBINDINGS.md +11 −2
  2. apps/server/src/wsServer.test.ts +20 −4
  3. apps/server/src/wsServer.ts +42 −11
  4. apps/web/src/components/ChatView.tsx +5 −6
  5. apps/web/src/keybindings.test.ts +26 −64
  6. apps/web/src/keybindings.ts +4 −47
KEYBINDINGS.md+11−2
@@ -4,6 +4,15 @@ T3 Code reads keybindings from: · 3 unmodified lines
44
55 - `~/.t3/keybindings.json`
66
7+Schema source of truth:
8+
9+- `packages/contracts/src/keybindings.ts`
10+- `packages/contracts/src/server.ts`
11+
12+Server-side default resolution/merging:
13+
14+- `apps/server/src/wsServer.ts`
15+
716 The file must be a JSON array of rules:
817
918 ```json
@@ -71,13 +80,13 @@ Unknown condition keys evaluate to `false`. · 61 unmodified lines
7180
7281 ## Defaults
7382
74−Built-in defaults:
83+Built-in defaults are resolved on the server and sent to the web client via `server.getConfig`:
7584
7685 - `terminal.toggle`: `mod+j`
7786 - `terminal.split`: `mod+d` when `terminalFocus`
7887 - `terminal.new`: `mod+shift+d` when `terminalFocus`
7988
80−If you define any rules for a command, the default rule for that command is removed.
89+If you define any rules for a command, the default rule for that command is removed on the server.
8190
8291 ## Precedence
8392
apps/server/src/wsServer.test.ts+20−4
@@ -309,7 +309,11 @@ describe("WebSocket Server", () => { · 308 unmodified lines
309309 expect(response.error).toBeUndefined();
310310 expect(response.result).toEqual({
311311 cwd: "/my/workspace",
312− keybindings: [],
312+ keybindings: [
313+ { key: "mod+j", command: "terminal.toggle" },
314+ { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
315+ { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
316+ ],
313317 });
314318 });
315319
@@ -379,7 +383,11 @@ describe("WebSocket Server", () => { · 63 unmodified lines
379383 expect(response.error).toBeUndefined();
380384 expect(response.result).toEqual({
381385 cwd: "/my/workspace",
382− keybindings: [{ key: "mod+j", command: "terminal.toggle" }],
386+ keybindings: [
387+ { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
388+ { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
389+ { key: "mod+j", command: "terminal.toggle" },
390+ ],
383391 });
384392 expect(
385393 warnSpy.mock.calls.some(([message]) =>
@@ -414,7 +422,11 @@ describe("WebSocket Server", () => { · 28 unmodified lines
414422 expect(response.error).toBeUndefined();
415423 expect(response.result).toEqual({
416424 cwd: "/my/workspace",
417− keybindings: [],
425+ keybindings: [
426+ { key: "mod+j", command: "terminal.toggle" },
427+ { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
428+ { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
429+ ],
418430 });
419431 expect(
420432 warnSpy.mock.calls.some(([message]) =>
@@ -445,7 +457,11 @@ describe("WebSocket Server", () => { · 24 unmodified lines
445457 expect(response.error).toBeUndefined();
446458 expect(response.result).toEqual({
447459 cwd: "/my/workspace",
448− keybindings: [],
460+ keybindings: [
461+ { key: "mod+j", command: "terminal.toggle" },
462+ { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
463+ { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
464+ ],
449465 });
450466 expect(
451467 warnSpy.mock.calls.some(([message]) =>
apps/server/src/wsServer.ts+42−11
@@ -70,6 +70,31 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { · 69 unmodified lines
7070 return undefined;
7171 }
7272
73+const DEFAULT_KEYBINDINGS: KeybindingsConfig = [
74+ { key: "mod+j", command: "terminal.toggle" },
75+ { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
76+ { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
77+];
78+
79+function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig {
80+ if (custom.length === 0) {
81+ return [...DEFAULT_KEYBINDINGS];
82+ }
83+
84+ const overriddenCommands = new Set(custom.map((binding) => binding.command));
85+ const retainedDefaults = DEFAULT_KEYBINDINGS.filter(
86+ (binding) => !overriddenCommands.has(binding.command),
87+ );
88+ const merged = [...retainedDefaults, ...custom];
89+
90+ if (merged.length <= 256) {
91+ return merged;
92+ }
93+
94+ // Keep the latest rules when the config exceeds max size; later rules have higher precedence.
95+ return merged.slice(-256);
96+}
97+
7398 function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): KeybindingsConfig {
7499 const configPath = path.join(os.homedir(), ".t3", "keybindings.json");
75100 try {
@@ -79,7 +104,7 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind · 3 unmodified lines
79104 logger.warn("ignoring keybindings config with unsupported format; expected array", {
80105 path: configPath,
81106 });
82− return [];
107+ return [...DEFAULT_KEYBINDINGS];
83108 }
84109
85110 const sanitized: KeybindingsConfig = [];
@@ -88,13 +113,6 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind · 2 unmodified lines
88113 const result = keybindingRuleSchema.safeParse(entry);
89114 if (result.success) {
90115 sanitized.push(result.data);
91− if (sanitized.length >= 256) {
92− logger.warn("truncating keybindings config to max entries", {
93− path: configPath,
94− maxEntries: 256,
95− });
96− break;
97− }
98116 continue;
99117 }
100118 invalidEntries += 1;
@@ -106,17 +124,30 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind · 5 unmodified lines
106124 totalEntries: parsed.length,
107125 });
108126 }
109− return sanitized;
127+ const mergedBeforeCap = [
128+ ...DEFAULT_KEYBINDINGS.filter(
129+ (binding) => !new Set(sanitized.map((entry) => entry.command)).has(binding.command),
130+ ),
131+ ...sanitized,
132+ ];
133+ const merged = mergeWithDefaultKeybindings(sanitized);
134+ if (mergedBeforeCap.length > 256) {
135+ logger.warn("truncating merged keybindings config to max entries", {
136+ path: configPath,
137+ maxEntries: 256,
138+ });
139+ }
140+ return merged;
110141 } catch (error) {
111142 if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
112− return [];
143+ return [...DEFAULT_KEYBINDINGS];
113144 }
114145 logger.warn("ignoring malformed keybindings config", {
115146 path: configPath,
116147 error: error instanceof Error ? error.message : String(error),
117148 });
118149 }
119− return [];
150+ return [...DEFAULT_KEYBINDINGS];
120151 }
121152
122153 export function createServer(options: ServerOptions) {
apps/web/src/components/ChatView.tsx+5−6
@@ -52,11 +52,10 @@ import type { ChatImageAttachment } from "../types"; · 51 unmodified lines
5252 import BranchToolbar from "./BranchToolbar";
5353 import GitActionsControl from "./GitActionsControl";
5454 import {
55− DEFAULT_TERMINAL_KEYBINDINGS,
5655 isTerminalNewShortcut,
5756 isTerminalSplitShortcut,
5857 isTerminalToggleShortcut,
59− resolveTerminalKeybindings,
58+ type ResolvedTerminalKeybindings,
6059 shortcutLabelForCommand,
6160 } from "../keybindings";
6261 import ChatMarkdown from "./ChatMarkdown";
@@ -152,7 +151,7 @@ export default function ChatView() { · 89 unmodified lines
152151 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
153152 const [nowTick, setNowTick] = useState(() => Date.now());
154153 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
155− const [terminalKeybindings, setTerminalKeybindings] = useState(DEFAULT_TERMINAL_KEYBINDINGS);
154+ const [terminalKeybindings, setTerminalKeybindings] = useState<ResolvedTerminalKeybindings>([]);
156155 const messagesScrollRef = useRef<HTMLDivElement>(null);
157156 const messagesEndRef = useRef<HTMLDivElement>(null);
158157 const shouldAutoScrollRef = useRef(true);
@@ -282,7 +281,7 @@ export default function ChatView() { · 123 unmodified lines
282281
283282 useEffect(() => {
284283 if (!api || !api.server || typeof api.server.getConfig !== "function") {
285− setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS);
284+ setTerminalKeybindings([]);
286285 return;
287286 }
288287
@@ -292,11 +291,11 @@ export default function ChatView() { · 3 unmodified lines
292291 .getConfig()
293292 .then((config) => {
294293 if (disposed) return;
295− setTerminalKeybindings(resolveTerminalKeybindings(config.keybindings));
294+ setTerminalKeybindings(config.keybindings);
296295 })
297296 .catch(() => {
298297 if (disposed) return;
299− setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS);
298+ setTerminalKeybindings([]);
300299 });
301300
302301 return () => {
apps/web/src/keybindings.test.ts+26−64
@@ -1,13 +1,12 @@
11 import { describe, expect, it } from "vitest";
22
3+import type { KeybindingsConfig } from "@t3tools/contracts";
34 import {
4− DEFAULT_TERMINAL_KEYBINDINGS,
55 formatShortcutLabel,
66 isTerminalClearShortcut,
77 isTerminalNewShortcut,
88 isTerminalSplitShortcut,
99 isTerminalToggleShortcut,
10− resolveTerminalKeybindings,
1110 shortcutLabelForCommand,
1211 type ShortcutEventLike,
1312 } from "./keybindings";
@@ -23,65 +22,55 @@ function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { · 9 unmodified lines
2322 };
2423 }
2524
25+const DEFAULT_BINDINGS: KeybindingsConfig = [
26+ { key: "mod+j", command: "terminal.toggle" },
27+ { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
28+ { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
29+];
30+
2631 describe("isTerminalToggleShortcut", () => {
2732 it("matches Cmd+J on macOS", () => {
28− expect(
29− isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
30− platform: "MacIntel",
31− }),
32− ).toBe(true);
33+ expect(isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel" })).toBe(true);
3334 });
3435
3536 it("matches Ctrl+J on non-macOS", () => {
36− expect(
37− isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
38− platform: "Win32",
39− }),
40− ).toBe(true);
37+ expect(isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Win32" })).toBe(true);
4138 });
4239 });
4340
4441 describe("split/new terminal shortcuts", () => {
4542 it("requires terminalFocus for default split/new bindings", () => {
4643 expect(
47− isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
44+ isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
4845 platform: "MacIntel",
4946 context: { terminalFocus: false },
5047 }),
5148 ).toBe(false);
5249 expect(
53− isTerminalNewShortcut(
54− event({ key: "d", ctrlKey: true, shiftKey: true }),
55− DEFAULT_TERMINAL_KEYBINDINGS,
56− {
57− platform: "Linux",
58− context: { terminalFocus: false },
59− },
60− ),
50+ isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
51+ platform: "Linux",
52+ context: { terminalFocus: false },
53+ }),
6154 ).toBe(false);
6255 });
6356
6457 it("matches split/new when terminalFocus is true", () => {
6558 expect(
66− isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
59+ isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
6760 platform: "MacIntel",
6861 context: { terminalFocus: true },
6962 }),
7063 ).toBe(true);
7164 expect(
72− isTerminalNewShortcut(
73− event({ key: "d", ctrlKey: true, shiftKey: true }),
74− DEFAULT_TERMINAL_KEYBINDINGS,
75− {
76− platform: "Linux",
77− context: { terminalFocus: true },
78− },
79− ),
65+ isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
66+ platform: "Linux",
67+ context: { terminalFocus: true },
68+ }),
8069 ).toBe(true);
8170 });
8271
83− it("supports configured overrides with when expressions", () => {
84− const keybindings = resolveTerminalKeybindings([
72+ it("supports when expressions", () => {
73+ const keybindings: KeybindingsConfig = [
8574 {
8675 key: "mod+\\",
8776 command: "terminal.split",
@@ -92,7 +81,8 @@ describe("split/new terminal shortcuts", () => { · 4 unmodified lines
9281 command: "terminal.new",
9382 when: "terminalOpen && !terminalFocus",
9483 },
95− ]);
84+ { key: "mod+j", command: "terminal.toggle" },
85+ ];
9686 expect(
9787 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
9888 platform: "Win32",
@@ -114,41 +104,13 @@ describe("split/new terminal shortcuts", () => { · 15 unmodified lines
114104 });
115105 });
116106
117−describe("resolveTerminalKeybindings", () => {
118− it("uses defaults when config is empty", () => {
119− expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS);
120− });
121−
122− it("replaces defaults for commands configured by user", () => {
123− const resolved = resolveTerminalKeybindings([
124− { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
125− ]);
126−
127− expect(resolved).toEqual([
128− { key: "mod+j", command: "terminal.toggle" },
129− { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
130− { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
131− ]);
132− });
133−
134− it("ignores invalid configured values", () => {
135− expect(
136− resolveTerminalKeybindings([
137− { key: " ", command: "terminal.toggle" },
138− { key: "mod+d+f", command: "terminal.split" },
139− ]),
140− ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS);
141− });
142−});
143−
144107 describe("shortcutLabelForCommand", () => {
145108 it("returns the most recent binding label", () => {
146− const resolved = resolveTerminalKeybindings([
109+ const bindings: KeybindingsConfig = [
147110 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
148111 { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" },
149− ]);
150−
151− expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\");
112+ ];
113+ expect(shortcutLabelForCommand(bindings, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\");
152114 });
153115 });
154116
apps/web/src/keybindings.ts+4−47
@@ -1,4 +1,4 @@
1−import type { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/contracts";
1+import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts";
22
33 export interface ShortcutEventLike {
44 key: string;
@@ -14,15 +14,8 @@ export interface ShortcutMatchContext { · 9 unmodified lines
1414 [key: string]: boolean;
1515 }
1616
17−export type ResolvedTerminalKeybinding = KeybindingRule;
1817 export type ResolvedTerminalKeybindings = KeybindingsConfig;
1918
20−export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [
21− { key: "mod+j", command: "terminal.toggle" },
22− { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
23− { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
24−];
25−
2619 interface ParsedShortcut {
2720 key: string;
2821 metaKey: boolean;
@@ -306,21 +299,6 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc · 277 unmodified lines
306299 };
307300 }
308301
309−function normalizeConfiguredKeybinding(
310− keybinding: KeybindingRule,
311−): ResolvedTerminalKeybinding | null {
312− const key = keybinding.key.trim();
313− if (key.length === 0) return null;
314− if (!parseShortcutValue(key)) return null;
315−
316− const when = keybinding.when?.trim();
317− return {
318− key,
319− command: keybinding.command,
320− ...(when ? { when } : {}),
321− };
322−}
323−
324302 function matchesCommandShortcut(
325303 event: ShortcutEventLike,
326304 keybindings: ResolvedTerminalKeybindings,
@@ -340,27 +318,6 @@ function matchesCommandShortcut( · 13 unmodified lines
340318 return false;
341319 }
342320
343−export function resolveTerminalKeybindings(
344− input: KeybindingsConfig | null | undefined,
345−): ResolvedTerminalKeybindings {
346− if (!input || input.length === 0) {
347− return [...DEFAULT_TERMINAL_KEYBINDINGS];
348− }
349−
350− const normalizedInput = input
351− .map((binding) => normalizeConfiguredKeybinding(binding))
352− .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null);
353− if (normalizedInput.length === 0) {
354− return [...DEFAULT_TERMINAL_KEYBINDINGS];
355− }
356−
357− const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command));
358− const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter(
359− (binding) => !overriddenCommands.has(binding.command),
360− );
361− return [...retainedDefaults, ...normalizedInput];
362−}
363−
364321 function formatShortcutKeyLabel(key: string): string {
365322 if (key === " ") return "Space";
366323 if (key.length === 1) return key.toUpperCase();
@@ -413,7 +370,7 @@ export function shortcutLabelForCommand( · 46 unmodified lines
413370
414371 export function isTerminalToggleShortcut(
415372 event: ShortcutEventLike,
416− keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
373+ keybindings: ResolvedTerminalKeybindings,
417374 options?: ShortcutMatchOptions,
418375 ): boolean {
419376 return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);
@@ -421,7 +378,7 @@ export function isTerminalToggleShortcut( · 1 unmodified line
421378
422379 export function isTerminalSplitShortcut(
423380 event: ShortcutEventLike,
424− keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
381+ keybindings: ResolvedTerminalKeybindings,
425382 options?: ShortcutMatchOptions,
426383 ): boolean {
427384 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
@@ -429,7 +386,7 @@ export function isTerminalSplitShortcut( · 1 unmodified line
429386
430387 export function isTerminalNewShortcut(
431388 event: ShortcutEventLike,
432− keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
389+ keybindings: ResolvedTerminalKeybindings,
433390 options?: ShortcutMatchOptions,
434391 ): boolean {
435392 return matchesCommandShortcut(event, keybindings, "terminal.new", options);