authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 12:00:53-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 15:51:05-08:00
log38c93a2abdabb59b5a24c402d17397d1fd1493b8
tree1aa1177cb1ecae3a5a29161e09f97cdf64796540
parentef4ce2e1ec3a6e08ccc392b802462519cbb706bc

Resolve keybinding precedence across commands

- Evaluate shortcut matches by resolving the last matching key+when rule, regardless of command - Add cross-command precedence tests and switch assertions to `vitest` `assert` - Clarify precedence behavior in `KEYBINDINGS.md`

3 files changed, 105 insertions(+), 37 deletions(-)

KEYBINDINGS.md+2-1
......@@ -97,4 +97,5 @@ If you define any rules for a command, the default rule for that command is remo
9797## Precedence
9898
9999- Rules are evaluated in array order.
100- Later matching rules win over earlier matching rules for the same command.
100- For a key event, the server/client resolves the last rule where both `key` matches and `when` evaluates to `true`.
101- That means precedence is across commands, not only within the same command.
apps/web/src/keybindings.test.ts+92-33
......@@ -1,4 +1,4 @@
1import { describe, expect, it } from "vitest";
1import { assert, describe, it } from "vitest";
22
33import type { KeybindingsConfig } from "@t3tools/contracts";
44import {
......@@ -34,43 +34,47 @@ const DEFAULT_BINDINGS: KeybindingsConfig = [
3434
3535describe("isTerminalToggleShortcut", () => {
3636 it("matches Cmd+J on macOS", () => {
37 expect(isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel" })).toBe(true);
37 assert.isTrue(
38 isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel" }),
39 );
3840 });
3941
4042 it("matches Ctrl+J on non-macOS", () => {
41 expect(isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Win32" })).toBe(true);
43 assert.isTrue(
44 isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Win32" }),
45 );
4246 });
4347});
4448
4549describe("split/new terminal shortcuts", () => {
4650 it("requires terminalFocus for default split/new bindings", () => {
47 expect(
51 assert.isFalse(
4852 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
4953 platform: "MacIntel",
5054 context: { terminalFocus: false },
5155 }),
52 ).toBe(false);
53 expect(
56 );
57 assert.isFalse(
5458 isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
5559 platform: "Linux",
5660 context: { terminalFocus: false },
5761 }),
58 ).toBe(false);
62 );
5963 });
6064
6165 it("matches split/new when terminalFocus is true", () => {
62 expect(
66 assert.isTrue(
6367 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
6468 platform: "MacIntel",
6569 context: { terminalFocus: true },
6670 }),
67 ).toBe(true);
68 expect(
71 );
72 assert.isTrue(
6973 isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
7074 platform: "Linux",
7175 context: { terminalFocus: true },
7276 }),
73 ).toBe(true);
77 );
7478 });
7579
7680 it("supports when expressions", () => {
......@@ -87,24 +91,24 @@ describe("split/new terminal shortcuts", () => {
8791 },
8892 { key: "mod+j", command: "terminal.toggle" },
8993 ];
90 expect(
94 assert.isTrue(
9195 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
9296 platform: "Win32",
9397 context: { terminalOpen: true, terminalFocus: false },
9498 }),
95 ).toBe(true);
96 expect(
99 );
100 assert.isFalse(
97101 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
98102 platform: "Win32",
99103 context: { terminalOpen: false, terminalFocus: false },
100104 }),
101 ).toBe(false);
102 expect(
105 );
106 assert.isTrue(
103107 isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, {
104108 platform: "Win32",
105109 context: { terminalOpen: true, terminalFocus: false },
106110 }),
107 ).toBe(true);
111 );
108112 });
109113});
110114
......@@ -114,12 +118,13 @@ describe("shortcutLabelForCommand", () => {
114118 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
115119 { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" },
116120 ];
117 expect(shortcutLabelForCommand(bindings, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\");
121 assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\");
118122 });
119123
120124 it("returns labels for non-terminal commands", () => {
121 expect(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel")).toBe("⇧⌘O");
122 expect(shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux")).toBe(
125 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
126 assert.strictEqual(
127 shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"),
123128 "Ctrl+O",
124129 );
125130 });
......@@ -127,49 +132,103 @@ describe("shortcutLabelForCommand", () => {
127132
128133describe("chat/editor shortcuts", () => {
129134 it("matches chat.new shortcut", () => {
130 expect(
135 assert.isTrue(
131136 isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
132137 platform: "MacIntel",
133138 }),
134 ).toBe(true);
135 expect(
139 );
140 assert.isTrue(
136141 isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
137142 platform: "Linux",
138143 }),
139 ).toBe(true);
144 );
140145 });
141146
142147 it("matches editor.openFavorite shortcut", () => {
143 expect(
148 assert.isTrue(
144149 isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, {
145150 platform: "MacIntel",
146151 }),
147 ).toBe(true);
148 expect(
152 );
153 assert.isTrue(
149154 isOpenFavoriteEditorShortcut(event({ key: "o", ctrlKey: true }), DEFAULT_BINDINGS, {
150155 platform: "Linux",
151156 }),
152 ).toBe(true);
157 );
158 });
159});
160
161describe("cross-command precedence", () => {
162 it("uses when + order so a later focused rule overrides a global rule", () => {
163 const keybindings: KeybindingsConfig = [
164 { key: "mod+n", command: "chat.new" },
165 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
166 ];
167
168 assert.isTrue(
169 isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, {
170 platform: "MacIntel",
171 context: { terminalFocus: true },
172 }),
173 );
174 assert.isFalse(
175 isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, {
176 platform: "MacIntel",
177 context: { terminalFocus: true },
178 }),
179 );
180 assert.isFalse(
181 isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, {
182 platform: "MacIntel",
183 context: { terminalFocus: false },
184 }),
185 );
186 assert.isTrue(
187 isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, {
188 platform: "MacIntel",
189 context: { terminalFocus: false },
190 }),
191 );
192 });
193
194 it("still lets a later global rule win when both rules match", () => {
195 const keybindings: KeybindingsConfig = [
196 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
197 { key: "mod+n", command: "chat.new" },
198 ];
199
200 assert.isFalse(
201 isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, {
202 platform: "Linux",
203 context: { terminalFocus: true },
204 }),
205 );
206 assert.isTrue(
207 isChatNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, {
208 platform: "Linux",
209 context: { terminalFocus: true },
210 }),
211 );
153212 });
154213});
155214
156215describe("formatShortcutLabel", () => {
157216 it("formats labels for macOS", () => {
158 expect(formatShortcutLabel("mod+shift+d", "MacIntel")).toBe("⇧⌘D");
217 assert.strictEqual(formatShortcutLabel("mod+shift+d", "MacIntel"), "⇧⌘D");
159218 });
160219
161220 it("formats labels for non-macOS", () => {
162 expect(formatShortcutLabel("mod+shift+d", "Linux")).toBe("Ctrl+Shift+D");
221 assert.strictEqual(formatShortcutLabel("mod+shift+d", "Linux"), "Ctrl+Shift+D");
163222 });
164223});
165224
166225describe("isTerminalClearShortcut", () => {
167226 it("matches Ctrl+L on all platforms", () => {
168 expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")).toBe(true);
169 expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")).toBe(true);
227 assert.isTrue(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux"));
228 assert.isTrue(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel"));
170229 });
171230
172231 it("matches Cmd+K on macOS", () => {
173 expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true);
232 assert.isTrue(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel"));
174233 });
175234});
apps/web/src/keybindings.ts+11-3
......@@ -305,17 +305,25 @@ function matchesCommandShortcut(
305305 command: KeybindingCommand,
306306 options?: ShortcutMatchOptions,
307307): boolean {
308 return resolveShortcutCommand(event, keybindings, options) === command;
309}
310
311function resolveShortcutCommand(
312 event: ShortcutEventLike,
313 keybindings: ResolvedKeybindings,
314 options?: ShortcutMatchOptions,
315): KeybindingCommand | null {
308316 const platform = resolvePlatform(options);
309317 const context = resolveContext(options);
310318
311319 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
312320 const binding = keybindings[index];
313 if (!binding || binding.command !== command) continue;
321 if (!binding) continue;
314322 if (!matchesWhenExpression(binding.when, context)) continue;
315323 if (!matchesShortcutValue(event, binding.key, platform)) continue;
316 return true;
324 return binding.command;
317325 }
318 return false;
326 return null;
319327}
320328
321329function formatShortcutKeyLabel(key: string): string {