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...@@ -97,4 +97,5 @@ If you define any rules for a command, the default rule for that command is remo
97## Precedence97## Precedence
9898
99- Rules are evaluated in array order.99- 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 @@...@@ -1,4 +1,4 @@
1import { describe, expect, it } from "vitest";1import { assert, describe, it } from "vitest";
22
3import type { KeybindingsConfig } from "@t3tools/contracts";3import type { KeybindingsConfig } from "@t3tools/contracts";
4import {4import {
...@@ -34,43 +34,47 @@ const DEFAULT_BINDINGS: KeybindingsConfig = [...@@ -34,43 +34,47 @@ const DEFAULT_BINDINGS: KeybindingsConfig = [
3434
35describe("isTerminalToggleShortcut", () => {35describe("isTerminalToggleShortcut", () => {
36 it("matches Cmd+J on macOS", () => {36 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 );
38 });40 });
3941
40 it("matches Ctrl+J on non-macOS", () => {42 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 );
42 });46 });
43});47});
4448
45describe("split/new terminal shortcuts", () => {49describe("split/new terminal shortcuts", () => {
46 it("requires terminalFocus for default split/new bindings", () => {50 it("requires terminalFocus for default split/new bindings", () => {
47 expect(51 assert.isFalse(
48 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {52 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
49 platform: "MacIntel",53 platform: "MacIntel",
50 context: { terminalFocus: false },54 context: { terminalFocus: false },
51 }),55 }),
52 ).toBe(false);56 );
53 expect(57 assert.isFalse(
54 isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {58 isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
55 platform: "Linux",59 platform: "Linux",
56 context: { terminalFocus: false },60 context: { terminalFocus: false },
57 }),61 }),
58 ).toBe(false);62 );
59 });63 });
6064
61 it("matches split/new when terminalFocus is true", () => {65 it("matches split/new when terminalFocus is true", () => {
62 expect(66 assert.isTrue(
63 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {67 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
64 platform: "MacIntel",68 platform: "MacIntel",
65 context: { terminalFocus: true },69 context: { terminalFocus: true },
66 }),70 }),
67 ).toBe(true);71 );
68 expect(72 assert.isTrue(
69 isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {73 isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
70 platform: "Linux",74 platform: "Linux",
71 context: { terminalFocus: true },75 context: { terminalFocus: true },
72 }),76 }),
73 ).toBe(true);77 );
74 });78 });
7579
76 it("supports when expressions", () => {80 it("supports when expressions", () => {
...@@ -87,24 +91,24 @@ describe("split/new terminal shortcuts", () => {...@@ -87,24 +91,24 @@ describe("split/new terminal shortcuts", () => {
87 },91 },
88 { key: "mod+j", command: "terminal.toggle" },92 { key: "mod+j", command: "terminal.toggle" },
89 ];93 ];
90 expect(94 assert.isTrue(
91 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {95 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
92 platform: "Win32",96 platform: "Win32",
93 context: { terminalOpen: true, terminalFocus: false },97 context: { terminalOpen: true, terminalFocus: false },
94 }),98 }),
95 ).toBe(true);99 );
96 expect(100 assert.isFalse(
97 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {101 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
98 platform: "Win32",102 platform: "Win32",
99 context: { terminalOpen: false, terminalFocus: false },103 context: { terminalOpen: false, terminalFocus: false },
100 }),104 }),
101 ).toBe(false);105 );
102 expect(106 assert.isTrue(
103 isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, {107 isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, {
104 platform: "Win32",108 platform: "Win32",
105 context: { terminalOpen: true, terminalFocus: false },109 context: { terminalOpen: true, terminalFocus: false },
106 }),110 }),
107 ).toBe(true);111 );
108 });112 });
109});113});
110114
...@@ -114,12 +118,13 @@ describe("shortcutLabelForCommand", () => {...@@ -114,12 +118,13 @@ describe("shortcutLabelForCommand", () => {
114 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },118 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
115 { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" },119 { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" },
116 ];120 ];
117 expect(shortcutLabelForCommand(bindings, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\");121 assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\");
118 });122 });
119123
120 it("returns labels for non-terminal commands", () => {124 it("returns labels for non-terminal commands", () => {
121 expect(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel")).toBe("⇧⌘O");125 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
122 expect(shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux")).toBe(126 assert.strictEqual(
127 shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"),
123 "Ctrl+O",128 "Ctrl+O",
124 );129 );
125 });130 });
...@@ -127,49 +132,103 @@ describe("shortcutLabelForCommand", () => {...@@ -127,49 +132,103 @@ describe("shortcutLabelForCommand", () => {
127132
128describe("chat/editor shortcuts", () => {133describe("chat/editor shortcuts", () => {
129 it("matches chat.new shortcut", () => {134 it("matches chat.new shortcut", () => {
130 expect(135 assert.isTrue(
131 isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {136 isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
132 platform: "MacIntel",137 platform: "MacIntel",
133 }),138 }),
134 ).toBe(true);139 );
135 expect(140 assert.isTrue(
136 isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {141 isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
137 platform: "Linux",142 platform: "Linux",
138 }),143 }),
139 ).toBe(true);144 );
140 });145 });
141146
142 it("matches editor.openFavorite shortcut", () => {147 it("matches editor.openFavorite shortcut", () => {
143 expect(148 assert.isTrue(
144 isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, {149 isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, {
145 platform: "MacIntel",150 platform: "MacIntel",
146 }),151 }),
147 ).toBe(true);152 );
148 expect(153 assert.isTrue(
149 isOpenFavoriteEditorShortcut(event({ key: "o", ctrlKey: true }), DEFAULT_BINDINGS, {154 isOpenFavoriteEditorShortcut(event({ key: "o", ctrlKey: true }), DEFAULT_BINDINGS, {
150 platform: "Linux",155 platform: "Linux",
151 }),156 }),
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 );
153 });212 });
154});213});
155214
156describe("formatShortcutLabel", () => {215describe("formatShortcutLabel", () => {
157 it("formats labels for macOS", () => {216 it("formats labels for macOS", () => {
158 expect(formatShortcutLabel("mod+shift+d", "MacIntel")).toBe("⇧⌘D");217 assert.strictEqual(formatShortcutLabel("mod+shift+d", "MacIntel"), "⇧⌘D");
159 });218 });
160219
161 it("formats labels for non-macOS", () => {220 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");
163 });222 });
164});223});
165224
166describe("isTerminalClearShortcut", () => {225describe("isTerminalClearShortcut", () => {
167 it("matches Ctrl+L on all platforms", () => {226 it("matches Ctrl+L on all platforms", () => {
168 expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")).toBe(true);227 assert.isTrue(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux"));
169 expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")).toBe(true);228 assert.isTrue(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel"));
170 });229 });
171230
172 it("matches Cmd+K on macOS", () => {231 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"));
174 });233 });
175});234});
apps/web/src/keybindings.ts+11-3
...@@ -305,17 +305,25 @@ function matchesCommandShortcut(...@@ -305,17 +305,25 @@ function matchesCommandShortcut(
305 command: KeybindingCommand,305 command: KeybindingCommand,
306 options?: ShortcutMatchOptions,306 options?: ShortcutMatchOptions,
307): boolean {307): boolean {
308 return resolveShortcutCommand(event, keybindings, options) === command;
309}
310
311function resolveShortcutCommand(
312 event: ShortcutEventLike,
313 keybindings: ResolvedKeybindings,
314 options?: ShortcutMatchOptions,
315): KeybindingCommand | null {
308 const platform = resolvePlatform(options);316 const platform = resolvePlatform(options);
309 const context = resolveContext(options);317 const context = resolveContext(options);
310318
311 for (let index = keybindings.length - 1; index >= 0; index -= 1) {319 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
312 const binding = keybindings[index];320 const binding = keybindings[index];
313 if (!binding || binding.command !== command) continue;321 if (!binding) continue;
314 if (!matchesWhenExpression(binding.when, context)) continue;322 if (!matchesWhenExpression(binding.when, context)) continue;
315 if (!matchesShortcutValue(event, binding.key, platform)) continue;323 if (!matchesShortcutValue(event, binding.key, platform)) continue;
316 return true;324 return binding.command;
317 }325 }
318 return false;326 return null;
319}327}
320328
321function formatShortcutKeyLabel(key: string): string {329function formatShortcutKeyLabel(key: string): string {