gloomberb 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/types.ts +17 -0
  3. package/src/app/global-shortcuts.test.tsx +85 -1
  4. package/src/app/global-shortcuts.ts +83 -36
  5. package/src/app/keybindings/actions.ts +223 -0
  6. package/src/app/keybindings/capture-request.ts +36 -0
  7. package/src/app/keybindings/chord.test.ts +131 -0
  8. package/src/app/keybindings/chord.ts +302 -0
  9. package/src/app/keybindings/config.ts +83 -0
  10. package/src/app/keybindings/index.ts +62 -0
  11. package/src/app/keybindings/labels.ts +87 -0
  12. package/src/app/keybindings/react.tsx +32 -0
  13. package/src/app/keybindings/resolve.test.ts +103 -0
  14. package/src/app/keybindings/resolve.ts +280 -0
  15. package/src/app.tsx +25 -17
  16. package/src/cli/commands/keybindings.test.ts +48 -0
  17. package/src/cli/commands/keybindings.ts +102 -0
  18. package/src/cli/commands/system.ts +33 -1
  19. package/src/cli/context.ts +1 -0
  20. package/src/cli/pane-functions/intraday-shot.test.ts +15 -0
  21. package/src/cli/restore-plugins.ts +2 -1
  22. package/src/components/command-bar/keyboard-shortcuts.ts +19 -2
  23. package/src/components/command-bar/routes/root/results.ts +33 -0
  24. package/src/components/command-bar/routes/root/runtime.ts +4 -0
  25. package/src/components/command-bar/routing/launch-request.ts +3 -0
  26. package/src/components/command-bar/surface/index.test.tsx +58 -0
  27. package/src/components/command-bar/surface/index.tsx +22 -0
  28. package/src/components/data-table/sections.ts +55 -0
  29. package/src/components/index.ts +11 -0
  30. package/src/components/layout/header.tsx +11 -4
  31. package/src/components/layout/pane/sidebar.test.tsx +87 -0
  32. package/src/components/layout/pane/sidebar.tsx +118 -6
  33. package/src/components/layout/shell/index.tsx +8 -3
  34. package/src/components/layout/shell/menu.ts +12 -11
  35. package/src/components/layout/shell/pane/management-shortcuts.ts +4 -2
  36. package/src/components/layout/shell/shortcuts.ts +55 -35
  37. package/src/components/ui/action-row.tsx +20 -3
  38. package/src/components/ui/button.tsx +8 -0
  39. package/src/core/app-runtime.test.ts +31 -0
  40. package/src/core/app-runtime.ts +32 -2
  41. package/src/core/app-service-ports.ts +8 -0
  42. package/src/core/app-services.ts +2 -2
  43. package/src/core/state/app/state.ts +4 -0
  44. package/src/core/state/app/types.ts +23 -3
  45. package/src/data/config/home.test.ts +70 -0
  46. package/src/data/config/home.ts +41 -0
  47. package/src/data/config/store/index.test.ts +25 -0
  48. package/src/data/config/store/node.ts +21 -19
  49. package/src/data/config/store/normalize.ts +41 -0
  50. package/src/i18n/es.ts +1 -1
  51. package/src/i18n/ja.ts +1 -1
  52. package/src/i18n/ko.ts +1 -1
  53. package/src/i18n/zh-cn.ts +1 -1
  54. package/src/i18n/zh-tw.ts +1 -1
  55. package/src/plugins/builtin/chart-composer/headless.ts +12 -3
  56. package/src/plugins/builtin/chat/channels.ts +4 -2
  57. package/src/plugins/builtin/chat/content/channel-navigation.ts +24 -16
  58. package/src/plugins/builtin/chat/content/composer-runtime.ts +5 -5
  59. package/src/plugins/builtin/chat/content/index.tsx +13 -10
  60. package/src/plugins/builtin/chat/content/layout-metrics.ts +4 -1
  61. package/src/plugins/builtin/chat/content/snapshot.ts +0 -3
  62. package/src/plugins/builtin/chat/sidebar-store.ts +100 -0
  63. package/src/plugins/builtin/chat/sidebar.test.tsx +49 -19
  64. package/src/plugins/builtin/chat/sidebar.tsx +111 -78
  65. package/src/plugins/builtin/chat/test-harness.tsx +3 -0
  66. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  67. package/src/plugins/builtin/help/functions-table.tsx +158 -0
  68. package/src/plugins/builtin/help/index.tsx +142 -301
  69. package/src/plugins/builtin/help/keybindings-editor.test.tsx +147 -0
  70. package/src/plugins/builtin/help/keybindings-editor.tsx +427 -0
  71. package/src/plugins/builtin/help/shortcut-model.ts +1 -35
  72. package/src/plugins/builtin/help/shortcut-table.tsx +90 -0
  73. package/src/plugins/builtin/help/table-cells.tsx +66 -0
  74. package/src/plugins/builtin/jobs/headless.ts +13 -0
  75. package/src/plugins/builtin/jobs/pane.tsx +11 -1
  76. package/src/plugins/builtin/research/analyst-model.test.ts +68 -2
  77. package/src/plugins/builtin/research/analyst-model.ts +180 -17
  78. package/src/plugins/builtin/research/analyst-pane.test.tsx +56 -13
  79. package/src/plugins/builtin/research/analyst-pane.tsx +123 -29
  80. package/src/plugins/builtin/research/index.test.ts +0 -35
  81. package/src/plugins/catalog-backend.ts +12 -2
  82. package/src/plugins/loader.ts +3 -3
  83. package/src/renderers/browser/app-services.ts +2 -2
  84. package/src/renderers/electrobun/bun/application-menu/index.ts +14 -7
  85. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +1 -1
  86. package/src/renderers/electrobun/bun/desktop/initialization.ts +3 -4
  87. package/src/renderers/electrobun/bun/index.ts +12 -1
  88. package/src/renderers/electrobun/view/app-services.ts +2 -2
  89. package/src/renderers/electrobun/view/desktop/controls.tsx +3 -2
  90. package/src/renderers/electrobun/view/host/media-surface.tsx +3 -0
  91. package/src/renderers/electrobun/view/test-utils.tsx +17 -8
  92. package/src/renderers/opentui/host.tsx +6 -15
  93. package/src/renderers/opentui/start.tsx +2 -5
  94. package/src/renderers/opentui/terminal-media.test.ts +78 -7
  95. package/src/renderers/opentui/terminal-media.ts +123 -31
  96. package/src/time-series/resolution.ts +28 -0
  97. package/src/time-series/resolve.test.ts +173 -0
  98. package/src/time-series/resolve.ts +84 -29
  99. package/src/time-series/session-history.ts +5 -2
  100. package/src/time-series/use-chart-resolution.ts +7 -0
  101. package/src/types/config.ts +22 -0
  102. package/src/ui/host.tsx +6 -4
  103. package/src/updater.test.ts +46 -0
  104. package/src/updater.ts +42 -5
  105. package/src/utils/shortcut-labels.ts +0 -14
@@ -0,0 +1,131 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ formatKeyChord,
4
+ isTypingChord,
5
+ keyChordFromEvent,
6
+ matchesKeyChord,
7
+ parseKeyChord,
8
+ serializeKeyChord,
9
+ } from "./chord";
10
+
11
+ describe("parseKeyChord", () => {
12
+ test("reads the accelerator grammar case-insensitively and round-trips", () => {
13
+ expect(parseKeyChord("CmdOrCtrl+Shift+F")).toEqual({ key: "f", ctrl: false, cmd: false, primary: true, alt: false, shift: true });
14
+ expect(parseKeyChord("ctrl+p")).toEqual({ key: "p", ctrl: true, cmd: false, primary: false, alt: false, shift: false });
15
+ expect(parseKeyChord("`")).toEqual({ key: "`", ctrl: false, cmd: false, primary: false, alt: false, shift: false });
16
+ expect(parseKeyChord("Alt+1")).toMatchObject({ key: "1", alt: true });
17
+ expect(parseKeyChord("F5")).toMatchObject({ key: "f5" });
18
+ expect(parseKeyChord("Ctrl++")).toMatchObject({ key: "+", ctrl: true });
19
+ expect(parseKeyChord("CmdOrCtrl+,")).toMatchObject({ key: ",", primary: true });
20
+ expect(parseKeyChord("Option+Backtick")).toMatchObject({ key: "`", alt: true });
21
+ expect(parseKeyChord("CmdOrCtrl+Digit")).toMatchObject({ key: "digit", primary: true });
22
+ for (const text of ["CmdOrCtrl+Shift+F", "Ctrl+P", "`", "Alt+1", "F5", "Shift+Tab", "CmdOrCtrl+Digit", "Escape"]) {
23
+ expect(serializeKeyChord(parseKeyChord(text)!)).toBe(text);
24
+ }
25
+ });
26
+
27
+ test("an uppercase letter is the letter, not Shift", () => {
28
+ expect(parseKeyChord("Ctrl+K")).toMatchObject({ key: "k", shift: false });
29
+ expect(parseKeyChord("Shift+R")).toMatchObject({ key: "r", shift: true });
30
+ });
31
+
32
+ test("rejects text that names no key or mixes explicit and primary modifiers", () => {
33
+ expect(parseKeyChord("")).toBeNull();
34
+ expect(parseKeyChord("Ctrl+")).toBeNull();
35
+ expect(parseKeyChord("Ctrl+Shift")).toBeNull();
36
+ expect(parseKeyChord("Hyper+K")).toBeNull();
37
+ expect(parseKeyChord("Ctrl+CmdOrCtrl+K")).toBeNull();
38
+ expect(parseKeyChord("Ctrl+Bogus")).toBeNull();
39
+ });
40
+ });
41
+
42
+ describe("formatKeyChord", () => {
43
+ test("names the primary modifier for the host", () => {
44
+ const chord = parseKeyChord("CmdOrCtrl+Shift+F")!;
45
+ expect(formatKeyChord(chord, { primaryModifier: "ctrl" })).toBe("Ctrl+Shift+F");
46
+ expect(formatKeyChord(chord, { primaryModifier: "cmd" })).toBe("Cmd+Shift+F");
47
+ expect(formatKeyChord(parseKeyChord("Escape")!, { primaryModifier: "ctrl" })).toBe("Esc");
48
+ expect(formatKeyChord(parseKeyChord("CmdOrCtrl+Digit")!, { primaryModifier: "cmd" })).toBe("Cmd+1-9");
49
+ });
50
+ });
51
+
52
+ describe("matchesKeyChord", () => {
53
+ const base = { ctrl: false, shift: false, alt: false, meta: false, super: false };
54
+
55
+ test("primary accepts Control, Command and the kitty super modifier", () => {
56
+ const chord = parseKeyChord("CmdOrCtrl+K")!;
57
+ expect(matchesKeyChord(chord, { ...base, name: "k", ctrl: true })).toBe(true);
58
+ expect(matchesKeyChord(chord, { ...base, name: "k", meta: true })).toBe(true);
59
+ expect(matchesKeyChord(chord, { ...base, name: "k", super: true })).toBe(true);
60
+ expect(matchesKeyChord(chord, { ...base, name: "k" })).toBe(false);
61
+ expect(matchesKeyChord(chord, { ...base, name: "k", ctrl: true, alt: true })).toBe(false);
62
+ });
63
+
64
+ test("explicit Control does not accept Command", () => {
65
+ const chord = parseKeyChord("Ctrl+P")!;
66
+ expect(matchesKeyChord(chord, { ...base, name: "p", ctrl: true })).toBe(true);
67
+ expect(matchesKeyChord(chord, { ...base, name: "p", meta: true })).toBe(false);
68
+ });
69
+
70
+ test("letters honour Shift, including an uppercase name from the terminal", () => {
71
+ const shifted = parseKeyChord("Shift+R")!;
72
+ const plain = parseKeyChord("R")!;
73
+ expect(matchesKeyChord(shifted, { ...base, name: "R" })).toBe(true);
74
+ expect(matchesKeyChord(shifted, { ...base, name: "r", shift: true })).toBe(true);
75
+ expect(matchesKeyChord(shifted, { ...base, name: "r" })).toBe(false);
76
+ expect(matchesKeyChord(plain, { ...base, name: "r" })).toBe(true);
77
+ expect(matchesKeyChord(plain, { ...base, name: "R" })).toBe(false);
78
+ expect(matchesKeyChord(plain, { ...base, name: "r", ctrl: true })).toBe(false);
79
+ });
80
+
81
+ test("punctuation ignores Shift and accepts the US-layout slash for question mark", () => {
82
+ const help = parseKeyChord("?")!;
83
+ expect(matchesKeyChord(help, { ...base, name: "?", shift: true })).toBe(true);
84
+ expect(matchesKeyChord(help, { ...base, name: "?" })).toBe(true);
85
+ expect(matchesKeyChord(help, { ...base, name: "/", shift: true })).toBe(true);
86
+ expect(matchesKeyChord(help, { ...base, name: "/", shift: true, alt: true })).toBe(false);
87
+ expect(matchesKeyChord(parseKeyChord("`")!, { ...base, name: "`" })).toBe(true);
88
+ });
89
+
90
+ test("named keys and the digit family", () => {
91
+ expect(matchesKeyChord(parseKeyChord("Shift+Tab")!, { ...base, name: "tab", shift: true })).toBe(true);
92
+ expect(matchesKeyChord(parseKeyChord("Tab")!, { ...base, name: "tab", shift: true })).toBe(false);
93
+ expect(matchesKeyChord(parseKeyChord("Enter")!, { ...base, name: "return" })).toBe(true);
94
+ expect(matchesKeyChord(parseKeyChord("CmdOrCtrl+Digit")!, { ...base, name: "3", super: true })).toBe(true);
95
+ expect(matchesKeyChord(parseKeyChord("CmdOrCtrl+Digit")!, { ...base, name: "0", ctrl: true })).toBe(false);
96
+ expect(matchesKeyChord(parseKeyChord("CmdOrCtrl+Digit")!, { ...base, name: "2", alt: true })).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe("keyChordFromEvent", () => {
101
+ const base = { ctrl: false, shift: false, alt: false, meta: false, super: false };
102
+
103
+ test("records the host's own modifier as CmdOrCtrl and the other one explicitly", () => {
104
+ expect(serializeKeyChord(keyChordFromEvent({ ...base, name: "t", ctrl: true }, "ctrl")!)).toBe("CmdOrCtrl+T");
105
+ expect(serializeKeyChord(keyChordFromEvent({ ...base, name: "t", super: true }, "ctrl")!)).toBe("Cmd+T");
106
+ expect(serializeKeyChord(keyChordFromEvent({ ...base, name: "t", meta: true }, "cmd")!)).toBe("CmdOrCtrl+T");
107
+ expect(serializeKeyChord(keyChordFromEvent({ ...base, name: "t", ctrl: true }, "cmd")!)).toBe("Ctrl+T");
108
+ expect(serializeKeyChord(keyChordFromEvent({ ...base, name: "f", ctrl: true, shift: true }, "ctrl")!)).toBe("CmdOrCtrl+Shift+F");
109
+ expect(serializeKeyChord(keyChordFromEvent({ ...base, name: "R" }, "ctrl")!)).toBe("Shift+R");
110
+ expect(serializeKeyChord(keyChordFromEvent({ ...base, name: "escape" }, "ctrl")!)).toBe("Escape");
111
+ expect(serializeKeyChord(keyChordFromEvent({ ...base, name: "?", shift: true }, "ctrl")!)).toBe("?");
112
+ });
113
+
114
+ test("a bare modifier is not a chord", () => {
115
+ expect(keyChordFromEvent({ ...base, name: "shift", shift: true }, "ctrl")).toBeNull();
116
+ expect(keyChordFromEvent({ ...base, name: "" }, "ctrl")).toBeNull();
117
+ });
118
+ });
119
+
120
+ describe("isTypingChord", () => {
121
+ test("flags keys a text field would consume", () => {
122
+ expect(isTypingChord(parseKeyChord("a")!)).toBe(true);
123
+ expect(isTypingChord(parseKeyChord("Shift+A")!)).toBe(true);
124
+ expect(isTypingChord(parseKeyChord("Space")!)).toBe(true);
125
+ expect(isTypingChord(parseKeyChord("Enter")!)).toBe(true);
126
+ expect(isTypingChord(parseKeyChord("F5")!)).toBe(false);
127
+ expect(isTypingChord(parseKeyChord("Alt+A")!)).toBe(false);
128
+ expect(isTypingChord(parseKeyChord("CmdOrCtrl+Shift+A")!)).toBe(false);
129
+ expect(isTypingChord(parseKeyChord("Escape")!)).toBe(false);
130
+ });
131
+ });
@@ -0,0 +1,302 @@
1
+ /**
2
+ * One key chord: the key plus the modifiers held with it.
3
+ *
4
+ * Bindings are written in the accelerator grammar the menus already use
5
+ * (`CmdOrCtrl+Shift+F`, `Alt+1`, `F5`, `` ` ``). `CmdOrCtrl` is the primary
6
+ * modifier: Control in a terminal, Command on the macOS desktop and web hosts,
7
+ * and either one is accepted when matching, which is how every built-in chord
8
+ * behaved before bindings became configurable.
9
+ */
10
+ export interface KeyChord {
11
+ /**
12
+ * Lowercase key: a single character, a named key such as `tab` or `f5`, or
13
+ * `digit`, the 1-9 family used by layout switching.
14
+ */
15
+ key: string;
16
+ /** Control only. Exclusive with `primary`. */
17
+ ctrl: boolean;
18
+ /** Command (macOS) or Super/Win only. Exclusive with `primary`. */
19
+ cmd: boolean;
20
+ /** Control or Command, whichever the host reports. */
21
+ primary: boolean;
22
+ alt: boolean;
23
+ shift: boolean;
24
+ }
25
+
26
+ export interface KeyChordEventLike {
27
+ key?: string;
28
+ name?: string;
29
+ sequence?: string;
30
+ ctrl?: boolean;
31
+ shift?: boolean;
32
+ alt?: boolean;
33
+ meta?: boolean;
34
+ super?: boolean;
35
+ }
36
+
37
+ export type PrimaryModifier = "ctrl" | "cmd";
38
+
39
+ const MODIFIER_TOKENS: Record<string, keyof Omit<KeyChord, "key">> = {
40
+ ctrl: "ctrl",
41
+ control: "ctrl",
42
+ cmd: "cmd",
43
+ command: "cmd",
44
+ super: "cmd",
45
+ meta: "cmd",
46
+ win: "cmd",
47
+ cmdorctrl: "primary",
48
+ "cmd/ctrl": "primary",
49
+ commandorcontrol: "primary",
50
+ mod: "primary",
51
+ primary: "primary",
52
+ alt: "alt",
53
+ option: "alt",
54
+ opt: "alt",
55
+ shift: "shift",
56
+ };
57
+
58
+ /**
59
+ * Named keys, the event names each host reports for them, the label shown, and
60
+ * the config spelling when it differs from the label.
61
+ */
62
+ const NAMED_KEYS: Array<{ key: string; aliases: string[]; label: string; spelling?: string }> = [
63
+ { key: "tab", aliases: ["tab"], label: "Tab" },
64
+ { key: "return", aliases: ["return", "enter"], label: "Enter" },
65
+ { key: "escape", aliases: ["escape", "esc"], label: "Esc", spelling: "Escape" },
66
+ { key: "space", aliases: ["space", " "], label: "Space" },
67
+ { key: "backspace", aliases: ["backspace"], label: "Backspace" },
68
+ { key: "delete", aliases: ["delete", "del"], label: "Delete" },
69
+ { key: "insert", aliases: ["insert", "ins"], label: "Insert" },
70
+ { key: "up", aliases: ["up", "arrowup"], label: "Up" },
71
+ { key: "down", aliases: ["down", "arrowdown"], label: "Down" },
72
+ { key: "left", aliases: ["left", "arrowleft"], label: "Left" },
73
+ { key: "right", aliases: ["right", "arrowright"], label: "Right" },
74
+ { key: "home", aliases: ["home"], label: "Home" },
75
+ { key: "end", aliases: ["end"], label: "End" },
76
+ { key: "pageup", aliases: ["pageup", "pgup"], label: "PageUp" },
77
+ { key: "pagedown", aliases: ["pagedown", "pgdn", "pgdown"], label: "PageDown" },
78
+ { key: "digit", aliases: ["digit", "1-9"], label: "1-9", spelling: "Digit" },
79
+ ...Array.from({ length: 24 }, (_, index) => ({
80
+ key: `f${index + 1}`,
81
+ aliases: [`f${index + 1}`],
82
+ label: `F${index + 1}`,
83
+ })),
84
+ ];
85
+
86
+ const NAMED_KEY_BY_ALIAS = new Map<string, typeof NAMED_KEYS[number]>();
87
+ for (const named of NAMED_KEYS) {
88
+ for (const alias of named.aliases) NAMED_KEY_BY_ALIAS.set(alias, named);
89
+ }
90
+
91
+ /** Spelled-out punctuation, for people who would rather not quote a backtick in JSON. */
92
+ const SYMBOL_WORDS: Record<string, string> = {
93
+ plus: "+",
94
+ minus: "-",
95
+ comma: ",",
96
+ period: ".",
97
+ dot: ".",
98
+ backtick: "`",
99
+ grave: "`",
100
+ slash: "/",
101
+ backslash: "\\",
102
+ semicolon: ";",
103
+ quote: "'",
104
+ equals: "=",
105
+ equal: "=",
106
+ question: "?",
107
+ };
108
+
109
+ /** Event names that are a modifier on its own, never a chord. */
110
+ const MODIFIER_ONLY_EVENT_NAMES = new Set([
111
+ "", "shift", "ctrl", "control", "alt", "option", "meta", "super", "cmd", "command", "capslock", "fn",
112
+ ]);
113
+
114
+ export function isNamedKey(key: string): boolean {
115
+ return NAMED_KEY_BY_ALIAS.get(key)?.key === key;
116
+ }
117
+
118
+ function isLetter(key: string): boolean {
119
+ return key.length === 1 && /[a-z]/i.test(key);
120
+ }
121
+
122
+ function emptyChord(): KeyChord {
123
+ return { key: "", ctrl: false, cmd: false, primary: false, alt: false, shift: false };
124
+ }
125
+
126
+ /**
127
+ * Parses `Ctrl+Shift+P` style text. Returns null for text that names no key or
128
+ * mixes `Ctrl` or `Cmd` with `CmdOrCtrl`. Case-insensitive throughout, and an
129
+ * uppercase letter means the letter, not Shift: `Ctrl+K` is Control and k.
130
+ */
131
+ export function parseKeyChord(text: string): KeyChord | null {
132
+ const trimmed = text.trim();
133
+ if (!trimmed) return null;
134
+ // "Ctrl++" and a bare "+" name the plus key; "Ctrl+" names nothing.
135
+ const tokens = trimmed === "+" || trimmed.endsWith("++")
136
+ ? [...trimmed.slice(0, -1).split("+").filter((token) => token.length > 0), "+"]
137
+ : trimmed.split("+");
138
+ if (tokens.length === 0 || tokens.some((token) => token.trim().length === 0)) return null;
139
+
140
+ const chord = emptyChord();
141
+ const keyToken = tokens[tokens.length - 1]!.trim();
142
+ for (const token of tokens.slice(0, -1)) {
143
+ const modifier = MODIFIER_TOKENS[token.trim().toLowerCase()];
144
+ if (!modifier) return null;
145
+ chord[modifier] = true;
146
+ }
147
+ if (chord.primary && (chord.ctrl || chord.cmd)) return null;
148
+
149
+ const lowered = keyToken.toLowerCase();
150
+ const named = NAMED_KEY_BY_ALIAS.get(lowered);
151
+ if (named) {
152
+ chord.key = named.key;
153
+ } else if (SYMBOL_WORDS[lowered]) {
154
+ chord.key = SYMBOL_WORDS[lowered]!;
155
+ } else if ([...keyToken].length === 1) {
156
+ chord.key = isLetter(keyToken) ? lowered : keyToken;
157
+ } else {
158
+ return null;
159
+ }
160
+ return chord;
161
+ }
162
+
163
+ /** Canonical config spelling for a chord, the inverse of {@link parseKeyChord}. */
164
+ export function serializeKeyChord(chord: KeyChord): string {
165
+ const parts: string[] = [];
166
+ if (chord.primary) parts.push("CmdOrCtrl");
167
+ if (chord.ctrl) parts.push("Ctrl");
168
+ if (chord.cmd) parts.push("Cmd");
169
+ if (chord.alt) parts.push("Alt");
170
+ if (chord.shift) parts.push("Shift");
171
+ const named = NAMED_KEY_BY_ALIAS.get(chord.key);
172
+ parts.push(named ? named.spelling ?? named.label : isLetter(chord.key) ? chord.key.toUpperCase() : chord.key);
173
+ return parts.join("+");
174
+ }
175
+
176
+ export interface FormatKeyChordOptions {
177
+ /** What `CmdOrCtrl` reads as on this host. */
178
+ primaryModifier: PrimaryModifier;
179
+ /** The label for an explicit Command chord; Windows and Linux desktops call it Super. */
180
+ cmdLabel?: string;
181
+ }
182
+
183
+ /** The label users see: `Ctrl+Shift+F` in a terminal, `Cmd+Shift+F` on a Mac. */
184
+ export function formatKeyChord(chord: KeyChord, options: FormatKeyChordOptions): string {
185
+ const cmdLabel = options.cmdLabel ?? "Cmd";
186
+ const parts: string[] = [];
187
+ if (chord.primary) parts.push(options.primaryModifier === "cmd" ? cmdLabel : "Ctrl");
188
+ if (chord.ctrl) parts.push("Ctrl");
189
+ if (chord.cmd) parts.push(cmdLabel);
190
+ if (chord.alt) parts.push("Alt");
191
+ if (chord.shift) parts.push("Shift");
192
+ const named = NAMED_KEY_BY_ALIAS.get(chord.key);
193
+ parts.push(named ? named.label : isLetter(chord.key) ? chord.key.toUpperCase() : chord.key);
194
+ return parts.join("+");
195
+ }
196
+
197
+ export function keyChordsEqual(left: KeyChord, right: KeyChord): boolean {
198
+ return left.key === right.key
199
+ && left.ctrl === right.ctrl
200
+ && left.cmd === right.cmd
201
+ && left.primary === right.primary
202
+ && left.alt === right.alt
203
+ && left.shift === right.shift;
204
+ }
205
+
206
+ /** True when the chord could be typed into a text field as-is, so it must not double as a global key. */
207
+ export function isTypingChord(chord: KeyChord): boolean {
208
+ if (chord.ctrl || chord.cmd || chord.primary || chord.alt) return false;
209
+ if (chord.key === "digit") return true;
210
+ return !isNamedKey(chord.key) || ["space", "return", "tab", "backspace", "delete"].includes(chord.key);
211
+ }
212
+
213
+ function eventKeyName(event: KeyChordEventLike): string {
214
+ return event.name ?? event.key ?? "";
215
+ }
216
+
217
+ function eventHasCmd(event: KeyChordEventLike): boolean {
218
+ return event.meta === true || event.super === true;
219
+ }
220
+
221
+ /**
222
+ * Whether the event is this chord. `primary` accepts Control or Command. Shift
223
+ * matters for letters and named keys, and a letter that arrives uppercase
224
+ * counts as shifted, which is how some terminals report Shift+R. Punctuation
225
+ * ignores Shift, because reaching `?` or `` ` `` needs Shift on many layouts
226
+ * and the host has already resolved which character came out.
227
+ */
228
+ export function matchesKeyChord(chord: KeyChord, event: KeyChordEventLike): boolean {
229
+ const ctrl = event.ctrl === true;
230
+ const cmd = eventHasCmd(event);
231
+ if (chord.primary) {
232
+ if (!ctrl && !cmd) return false;
233
+ } else if (chord.ctrl !== ctrl || chord.cmd !== cmd) {
234
+ return false;
235
+ }
236
+ if (chord.alt !== (event.alt === true)) return false;
237
+
238
+ const raw = eventKeyName(event);
239
+ const shift = event.shift === true;
240
+ if (chord.key === "digit") {
241
+ return /^[1-9]$/.test(raw) && chord.shift === shift;
242
+ }
243
+ const named = NAMED_KEY_BY_ALIAS.get(chord.key);
244
+ if (named) {
245
+ return named.aliases.includes(raw.toLowerCase()) && chord.shift === shift;
246
+ }
247
+ if (isLetter(chord.key)) {
248
+ if (raw.toLowerCase() !== chord.key) return false;
249
+ const shifted = shift || (raw.length === 1 && raw !== raw.toLowerCase());
250
+ return chord.shift === shifted;
251
+ }
252
+ if (raw === chord.key || event.sequence === chord.key) return true;
253
+ // Terminals without the kitty protocol report a US-layout "?" as Shift+/.
254
+ return chord.key === "?" && raw === "/" && shift;
255
+ }
256
+
257
+ /** The digit an event pressed, for chords on the `digit` family. */
258
+ export function keyChordDigit(event: KeyChordEventLike): number | null {
259
+ const raw = eventKeyName(event);
260
+ return /^[1-9]$/.test(raw) ? Number.parseInt(raw, 10) : null;
261
+ }
262
+
263
+ /**
264
+ * The chord an event represents, for capture. Returns null for a bare modifier.
265
+ * The host's own primary modifier is recorded as `CmdOrCtrl`, so a chord bound
266
+ * from the terminal still fires under Command in the desktop app on the same
267
+ * machine; the other one stays explicit.
268
+ */
269
+ export function keyChordFromEvent(event: KeyChordEventLike, primaryModifier: PrimaryModifier): KeyChord | null {
270
+ const raw = eventKeyName(event);
271
+ if (MODIFIER_ONLY_EVENT_NAMES.has(raw.toLowerCase())) return null;
272
+
273
+ const chord = emptyChord();
274
+ const ctrl = event.ctrl === true;
275
+ const cmd = eventHasCmd(event);
276
+ if (ctrl && cmd) {
277
+ chord.ctrl = true;
278
+ chord.cmd = true;
279
+ } else if (ctrl) {
280
+ if (primaryModifier === "ctrl") chord.primary = true;
281
+ else chord.ctrl = true;
282
+ } else if (cmd) {
283
+ if (primaryModifier === "cmd") chord.primary = true;
284
+ else chord.cmd = true;
285
+ }
286
+ chord.alt = event.alt === true;
287
+
288
+ const named = NAMED_KEY_BY_ALIAS.get(raw.toLowerCase());
289
+ if (named && named.key !== "digit") {
290
+ chord.key = named.key;
291
+ chord.shift = event.shift === true;
292
+ return chord;
293
+ }
294
+ if ([...raw].length !== 1) return null;
295
+ if (isLetter(raw)) {
296
+ chord.key = raw.toLowerCase();
297
+ chord.shift = event.shift === true || raw !== raw.toLowerCase();
298
+ return chord;
299
+ }
300
+ chord.key = raw;
301
+ return chord;
302
+ }
@@ -0,0 +1,83 @@
1
+ import type { AppConfig, KeybindingsConfig } from "../../types/config";
2
+ import { getKeybindingAction } from "./actions";
3
+ import { keyChordsEqual, parseKeyChord, serializeKeyChord, type KeyChord } from "./chord";
4
+
5
+ function withoutEmpty(config: KeybindingsConfig): KeybindingsConfig | undefined {
6
+ const actions = config.actions && Object.keys(config.actions).length > 0 ? config.actions : undefined;
7
+ const commands = config.commands && Object.keys(config.commands).length > 0 ? config.commands : undefined;
8
+ if (!actions && !commands) return undefined;
9
+ return { ...(actions ? { actions } : {}), ...(commands ? { commands } : {}) };
10
+ }
11
+
12
+ /** A config with `keybindings` replaced, and the key dropped once nothing is customised. */
13
+ export function updateKeybindingsConfig(config: AppConfig, keybindings: KeybindingsConfig | undefined): AppConfig {
14
+ const next = keybindings ? withoutEmpty(keybindings) : undefined;
15
+ const { keybindings: _previous, ...rest } = config;
16
+ return next ? { ...rest, keybindings: next } : rest;
17
+ }
18
+
19
+ function defaultChordsFor(actionId: string, defaults?: readonly KeyChord[]): readonly KeyChord[] {
20
+ if (defaults) return defaults;
21
+ const def = getKeybindingAction(actionId);
22
+ return def ? def.defaults.map((text) => parseKeyChord(text)!).filter(Boolean) : [];
23
+ }
24
+
25
+ function sameChords(left: readonly KeyChord[], right: readonly KeyChord[]): boolean {
26
+ return left.length === right.length && left.every((chord, index) => keyChordsEqual(chord, right[index]!));
27
+ }
28
+
29
+ /**
30
+ * Binds an action to `chords`. An empty list unbinds it; a list equal to the
31
+ * defaults clears the override instead of recording a redundant one. Plugin
32
+ * shortcuts pass their own defaults, since the table does not know them.
33
+ */
34
+ export function applyActionBinding(
35
+ config: KeybindingsConfig | undefined,
36
+ actionId: string,
37
+ chords: readonly KeyChord[],
38
+ defaults?: readonly KeyChord[],
39
+ ): KeybindingsConfig | undefined {
40
+ const actions = { ...(config?.actions ?? {}) };
41
+ if (sameChords(chords, defaultChordsFor(actionId, defaults))) {
42
+ delete actions[actionId];
43
+ } else if (chords.length === 0) {
44
+ actions[actionId] = null;
45
+ } else {
46
+ const texts = chords.map(serializeKeyChord);
47
+ actions[actionId] = texts.length === 1 ? texts[0]! : texts;
48
+ }
49
+ return withoutEmpty({ ...config, actions });
50
+ }
51
+
52
+ /** Binds a command bar query to a chord, replacing any command already on that chord. */
53
+ export function setCommandBinding(
54
+ config: KeybindingsConfig | undefined,
55
+ chord: KeyChord,
56
+ query: string,
57
+ ): KeybindingsConfig | undefined {
58
+ const text = serializeKeyChord(chord);
59
+ const commands: Record<string, string> = {};
60
+ for (const [existingText, existingQuery] of Object.entries(config?.commands ?? {})) {
61
+ const existing = parseKeyChord(existingText);
62
+ if (existing && keyChordsEqual(existing, chord)) continue;
63
+ commands[existingText] = existingQuery;
64
+ }
65
+ commands[text] = query.trim();
66
+ return withoutEmpty({ ...config, commands });
67
+ }
68
+
69
+ /** Removes the command on `text`, matching by chord so `alt+1` and `Alt+1` are the same key. */
70
+ export function removeCommandBinding(
71
+ config: KeybindingsConfig | undefined,
72
+ text: string,
73
+ ): KeybindingsConfig | undefined {
74
+ const target = parseKeyChord(text);
75
+ const commands: Record<string, string> = {};
76
+ for (const [existingText, existingQuery] of Object.entries(config?.commands ?? {})) {
77
+ if (existingText === text) continue;
78
+ const existing = parseKeyChord(existingText);
79
+ if (target && existing && keyChordsEqual(existing, target)) continue;
80
+ commands[existingText] = existingQuery;
81
+ }
82
+ return withoutEmpty({ ...config, commands });
83
+ }
@@ -0,0 +1,62 @@
1
+ export {
2
+ KEYBINDING_ACTIONS,
3
+ PLUGIN_ACTION_PREFIX,
4
+ getKeybindingAction,
5
+ isCoreKeybindingActionId,
6
+ pluginShortcutActionId,
7
+ type CoreKeybindingActionId,
8
+ type KeybindingActionCategory,
9
+ type KeybindingActionDef,
10
+ } from "./actions";
11
+ export {
12
+ formatKeyChord,
13
+ isTypingChord,
14
+ keyChordDigit,
15
+ keyChordFromEvent,
16
+ keyChordsEqual,
17
+ matchesKeyChord,
18
+ parseKeyChord,
19
+ serializeKeyChord,
20
+ type KeyChord,
21
+ type KeyChordEventLike,
22
+ type PrimaryModifier,
23
+ } from "./chord";
24
+ export {
25
+ describeKeybindingIssue,
26
+ isPaneKeybindingAction,
27
+ keyChordsOverlap,
28
+ keybindingActionLabel,
29
+ matchKeybinding,
30
+ matchesKeybindingAction,
31
+ pluginShortcutDefaultChord,
32
+ resolveKeybindings,
33
+ resolvePluginShortcutChords,
34
+ type KeybindingCommand,
35
+ type KeybindingIssue,
36
+ type KeybindingMatch,
37
+ type ResolvedKeybindingAction,
38
+ type ResolvedKeybindings,
39
+ } from "./resolve";
40
+ export {
41
+ advertisedChord,
42
+ chordsForHost,
43
+ formatActionChords,
44
+ formatAdvertisedChord,
45
+ formatChordForHost,
46
+ menuAcceleratorFor,
47
+ primaryModifierFor,
48
+ } from "./labels";
49
+ export { KeybindingsProvider, getDefaultKeybindings, useKeybindings, useResolvedKeybindings } from "./react";
50
+ export {
51
+ applyActionBinding,
52
+ removeCommandBinding,
53
+ setCommandBinding,
54
+ updateKeybindingsConfig,
55
+ } from "./config";
56
+ export {
57
+ hasKeybindingCaptureRequest,
58
+ requestKeybindingCapture,
59
+ subscribeKeybindingCapture,
60
+ takeKeybindingCaptureRequest,
61
+ type KeybindingCaptureRequest,
62
+ } from "./capture-request";
@@ -0,0 +1,87 @@
1
+ import {
2
+ detectShortcutPlatform,
3
+ type ShortcutDisplayMode,
4
+ type ShortcutPlatform,
5
+ } from "../../utils/shortcut-labels";
6
+ import { formatKeyChord, serializeKeyChord, type KeyChord, type PrimaryModifier } from "./chord";
7
+ import type { ResolvedKeybindings } from "./resolve";
8
+
9
+ /** What `CmdOrCtrl` is on this host: Control in a terminal, Command on a Mac desktop. */
10
+ export function primaryModifierFor(
11
+ mode: ShortcutDisplayMode,
12
+ platform: ShortcutPlatform = detectShortcutPlatform(),
13
+ ): PrimaryModifier {
14
+ if (mode === "terminal") return "ctrl";
15
+ return platform === "darwin" ? "cmd" : "ctrl";
16
+ }
17
+
18
+ function cmdLabelFor(platform: ShortcutPlatform): string {
19
+ return platform === "darwin" ? "Cmd" : "Super";
20
+ }
21
+
22
+ export function formatChordForHost(
23
+ chord: KeyChord,
24
+ mode: ShortcutDisplayMode,
25
+ platform: ShortcutPlatform = detectShortcutPlatform(),
26
+ ): string {
27
+ return formatKeyChord(chord, {
28
+ primaryModifier: primaryModifierFor(mode, platform),
29
+ cmdLabel: cmdLabelFor(platform),
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Chords worth showing on this host. A terminal cannot deliver an explicit
35
+ * Command chord, so those rows stay out of the terminal's help.
36
+ */
37
+ export function chordsForHost(chords: readonly KeyChord[], mode: ShortcutDisplayMode): KeyChord[] {
38
+ return chords.filter((chord) => mode !== "terminal" || !chord.cmd);
39
+ }
40
+
41
+ export function formatActionChords(
42
+ resolved: ResolvedKeybindings,
43
+ actionId: string,
44
+ mode: ShortcutDisplayMode,
45
+ platform: ShortcutPlatform = detectShortcutPlatform(),
46
+ ): string[] {
47
+ const action = resolved.actionsById.get(actionId);
48
+ if (!action) return [];
49
+ return chordsForHost(action.chords, mode).map((chord) => formatChordForHost(chord, mode, platform));
50
+ }
51
+
52
+ /**
53
+ * The one chord a host advertises for an action when there is room for a single
54
+ * label. The terminal takes the first chord it can deliver; desktop hosts prefer
55
+ * a chord on the platform modifier, so the header says Cmd+K on a Mac while the
56
+ * terminal keeps saying Ctrl+P. Empty when the action is unbound.
57
+ */
58
+ export function advertisedChord(
59
+ resolved: ResolvedKeybindings,
60
+ actionId: string,
61
+ mode: ShortcutDisplayMode,
62
+ ): KeyChord | null {
63
+ const chords = chordsForHost(resolved.actionsById.get(actionId)?.chords ?? [], mode);
64
+ if (chords.length === 0) return null;
65
+ if (mode === "terminal") return chords[0]!;
66
+ return chords.find((chord) => chord.primary || chord.cmd) ?? chords[0]!;
67
+ }
68
+
69
+ export function formatAdvertisedChord(
70
+ resolved: ResolvedKeybindings,
71
+ actionId: string,
72
+ mode: ShortcutDisplayMode,
73
+ platform: ShortcutPlatform = detectShortcutPlatform(),
74
+ ): string {
75
+ const chord = advertisedChord(resolved, actionId, mode);
76
+ return chord ? formatChordForHost(chord, mode, platform) : "";
77
+ }
78
+
79
+ /**
80
+ * The accelerator a native menu shows for an action. Menus only take chords on
81
+ * the platform modifier: a bare backtick as a key equivalent would swallow the
82
+ * character everywhere.
83
+ */
84
+ export function menuAcceleratorFor(resolved: ResolvedKeybindings, actionId: string): string | undefined {
85
+ const chord = resolved.actionsById.get(actionId)?.chords.find((entry) => entry.primary || entry.cmd);
86
+ return chord ? serializeKeyChord(chord) : undefined;
87
+ }