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,32 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
+ import type { KeybindingsConfig } from "../../types/config";
3
+ import { resolveKeybindings, type ResolvedKeybindings } from "./resolve";
4
+
5
+ const KeybindingsContext = createContext<ResolvedKeybindings | null>(null);
6
+
7
+ let defaultKeybindings: ResolvedKeybindings | null = null;
8
+
9
+ /** The shipped table, for surfaces rendered outside the app tree such as tests. */
10
+ export function getDefaultKeybindings(): ResolvedKeybindings {
11
+ if (!defaultKeybindings) defaultKeybindings = resolveKeybindings(undefined);
12
+ return defaultKeybindings;
13
+ }
14
+
15
+ /** Resolves the config once per change; the app passes the result down and into its own hooks. */
16
+ export function useResolvedKeybindings(config: KeybindingsConfig | undefined): ResolvedKeybindings {
17
+ return useMemo(() => resolveKeybindings(config), [config]);
18
+ }
19
+
20
+ export function KeybindingsProvider({
21
+ value,
22
+ children,
23
+ }: {
24
+ value: ResolvedKeybindings;
25
+ children: ReactNode;
26
+ }) {
27
+ return <KeybindingsContext value={value}>{children}</KeybindingsContext>;
28
+ }
29
+
30
+ export function useKeybindings(): ResolvedKeybindings {
31
+ return useContext(KeybindingsContext) ?? getDefaultKeybindings();
32
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { applyActionBinding, removeCommandBinding, setCommandBinding, updateKeybindingsConfig } from "./config";
3
+ import { parseKeyChord, serializeKeyChord } from "./chord";
4
+ import { matchKeybinding, resolveKeybindings, resolvePluginShortcutChords } from "./resolve";
5
+ import { createDefaultConfig } from "../../types/config";
6
+
7
+ const base = { ctrl: false, shift: false, alt: false, meta: false, super: false };
8
+
9
+ describe("resolveKeybindings", () => {
10
+ test("ships every default and reports nothing", () => {
11
+ const resolved = resolveKeybindings(undefined);
12
+ expect(resolved.issues).toEqual([]);
13
+ expect(resolved.actionsById.get("ticker-search")?.chords.map(serializeKeyChord)).toEqual(["`"]);
14
+ expect(matchKeybinding(resolved, { ...base, name: "`" })).toMatchObject({ kind: "action", id: "ticker-search" });
15
+ expect(matchKeybinding(resolved, { ...base, name: "t", ctrl: true })).toBeNull();
16
+ expect(matchKeybinding(resolved, { ...base, name: "2", super: true })).toMatchObject({ kind: "action", id: "switch-layout", digit: 2 });
17
+ expect(matchKeybinding(resolved, { ...base, name: "w", ctrl: true, alt: true })).toMatchObject({ id: "close-floating-panes" });
18
+ expect(matchKeybinding(resolved, { ...base, name: "w", ctrl: true })).toMatchObject({ id: "pane-close" });
19
+ expect(matchKeybinding(resolved, { ...base, name: "x" })).toBeNull();
20
+ });
21
+
22
+ test("overrides replace defaults, null unbinds, and the rest stay put", () => {
23
+ const resolved = resolveKeybindings({
24
+ actions: { "ticker-search": "Ctrl+T", "command-bar": "Ctrl+Shift+P", help: null },
25
+ });
26
+ expect(resolved.issues).toEqual([]);
27
+ expect(matchKeybinding(resolved, { ...base, name: "`" })).toBeNull();
28
+ expect(matchKeybinding(resolved, { ...base, name: "t", ctrl: true })).toMatchObject({ id: "ticker-search" });
29
+ expect(matchKeybinding(resolved, { ...base, name: "k", ctrl: true })).toBeNull();
30
+ expect(matchKeybinding(resolved, { ...base, name: "p", ctrl: true, shift: true })).toMatchObject({ id: "command-bar" });
31
+ expect(matchKeybinding(resolved, { ...base, name: "?", shift: true })).toBeNull();
32
+ expect(resolved.actionsById.get("help")).toMatchObject({ custom: true, chords: [] });
33
+ expect(resolved.actionsById.get("quit")).toMatchObject({ custom: false });
34
+ });
35
+
36
+ test("commands run after actions and need a modifier or function key", () => {
37
+ const resolved = resolveKeybindings({
38
+ commands: { "Alt+1": "DES AAPL", F5: " NEWS ", "CmdOrCtrl+W": "HN", a: "HN", "": "HN", "Alt+2": "" },
39
+ });
40
+ expect(matchKeybinding(resolved, { ...base, name: "1", alt: true })).toMatchObject({ kind: "command", command: { query: "DES AAPL" } });
41
+ expect(matchKeybinding(resolved, { ...base, name: "f5" })).toMatchObject({ kind: "command", command: { query: "NEWS" } });
42
+ expect(matchKeybinding(resolved, { ...base, name: "w", ctrl: true })).toMatchObject({ kind: "action", id: "pane-close" });
43
+ expect(resolved.commands.map((command) => command.text)).toEqual(["Alt+1", "F5", "CmdOrCtrl+W"]);
44
+ expect(resolved.issues).toEqual([
45
+ { kind: "typing-chord", target: "commands", text: "a" },
46
+ { kind: "invalid-chord", target: "commands", text: "" },
47
+ { kind: "conflict", chord: parseKeyChord("CmdOrCtrl+W"), targets: ["pane-close", "command:CmdOrCtrl+W"] },
48
+ ]);
49
+ });
50
+
51
+ test("reports unparseable overrides, unknown actions and overlapping chords", () => {
52
+ const resolved = resolveKeybindings({
53
+ actions: { "ticker-search": ["Ctrl+K", "Bogus+Q"], "not-a-thing": "F1" },
54
+ });
55
+ expect(resolved.issues).toEqual([
56
+ { kind: "invalid-chord", target: "ticker-search", text: "Bogus+Q" },
57
+ { kind: "unknown-action", actionId: "not-a-thing" },
58
+ { kind: "conflict", chord: parseKeyChord("CmdOrCtrl+K"), targets: ["command-bar", "ticker-search"] },
59
+ ]);
60
+ // Table order decides, so the earlier action keeps the chord.
61
+ expect(matchKeybinding(resolved, { ...base, name: "k", ctrl: true })).toMatchObject({ id: "command-bar" });
62
+ });
63
+
64
+ test("folds plugin shortcuts in under their own namespace", () => {
65
+ const shortcut = { id: "hn-refresh", key: "x", ctrl: true, description: "Refresh", execute: () => {} };
66
+ const plain = resolveKeybindings(undefined, { pluginShortcuts: [shortcut] });
67
+ expect(matchKeybinding(plain, { ...base, name: "x", ctrl: true })).toMatchObject({ id: "plugin:hn-refresh" });
68
+ expect(resolvePluginShortcutChords(undefined, shortcut).map(serializeKeyChord)).toEqual(["Ctrl+X"]);
69
+
70
+ const config = { actions: { "plugin:hn-refresh": "Alt+X" } };
71
+ const custom = resolveKeybindings(config, { pluginShortcuts: [shortcut] });
72
+ expect(custom.issues).toEqual([]);
73
+ expect(matchKeybinding(custom, { ...base, name: "x", ctrl: true })).toBeNull();
74
+ expect(matchKeybinding(custom, { ...base, name: "x", alt: true })).toMatchObject({ id: "plugin:hn-refresh", action: { custom: true } });
75
+ expect(resolvePluginShortcutChords(config, shortcut).map(serializeKeyChord)).toEqual(["Alt+X"]);
76
+ });
77
+ });
78
+
79
+ describe("keybinding config edits", () => {
80
+ test("applyActionBinding records overrides and drops ones equal to the default", () => {
81
+ const bound = applyActionBinding(undefined, "ticker-search", [parseKeyChord("Ctrl+T")!]);
82
+ expect(bound).toEqual({ actions: { "ticker-search": "Ctrl+T" } });
83
+ const unbound = applyActionBinding(bound, "help", []);
84
+ expect(unbound).toEqual({ actions: { "ticker-search": "Ctrl+T", help: null } });
85
+ const restored = applyActionBinding(unbound, "ticker-search", [parseKeyChord("`")!]);
86
+ expect(restored).toEqual({ actions: { help: null } });
87
+ expect(applyActionBinding(restored, "help", [parseKeyChord("?")!])).toBeUndefined();
88
+ });
89
+
90
+ test("setCommandBinding replaces a command already on the chord, and removal clears the key", () => {
91
+ const first = setCommandBinding(undefined, parseKeyChord("Alt+1")!, "DES AAPL");
92
+ const second = setCommandBinding(first, parseKeyChord("alt+1")!, "HN");
93
+ expect(second).toEqual({ commands: { "Alt+1": "HN" } });
94
+ expect(removeCommandBinding(second, "Alt+1")).toBeUndefined();
95
+ });
96
+
97
+ test("updateKeybindingsConfig drops the key once nothing is customised", () => {
98
+ const config = { ...createDefaultConfig("/tmp/keybindings"), keybindings: { actions: { help: null } } };
99
+ expect(updateKeybindingsConfig(config, undefined).keybindings).toBeUndefined();
100
+ expect("keybindings" in updateKeybindingsConfig(config, { actions: {} })).toBe(false);
101
+ expect(updateKeybindingsConfig(config, { commands: { F5: "NEWS" } }).keybindings).toEqual({ commands: { F5: "NEWS" } });
102
+ });
103
+ });
@@ -0,0 +1,280 @@
1
+ import type { KeybindingsConfig } from "../../types/config";
2
+ import type { KeyboardShortcut } from "../../types/plugin";
3
+ import {
4
+ getKeybindingAction,
5
+ isCoreKeybindingActionId,
6
+ KEYBINDING_ACTIONS,
7
+ PANE_ACTION_IDS,
8
+ PLUGIN_ACTION_PREFIX,
9
+ pluginShortcutActionId,
10
+ type KeybindingActionDef,
11
+ } from "./actions";
12
+ import {
13
+ isTypingChord,
14
+ keyChordDigit,
15
+ matchesKeyChord,
16
+ parseKeyChord,
17
+ serializeKeyChord,
18
+ type KeyChord,
19
+ type KeyChordEventLike,
20
+ } from "./chord";
21
+
22
+ export interface ResolvedKeybindingAction {
23
+ id: string;
24
+ /** Set for built-in actions. */
25
+ def?: KeybindingActionDef;
26
+ /** Set for actions a plugin registered. */
27
+ pluginShortcut?: KeyboardShortcut;
28
+ chords: KeyChord[];
29
+ defaults: KeyChord[];
30
+ /** The user replaced or removed the default chords. */
31
+ custom: boolean;
32
+ }
33
+
34
+ export interface KeybindingCommand {
35
+ chord: KeyChord;
36
+ /** The chord text as written in the config, which is the map key. */
37
+ text: string;
38
+ /** Command bar text submitted when the chord is pressed. */
39
+ query: string;
40
+ }
41
+
42
+ export type KeybindingIssue =
43
+ | { kind: "invalid-chord"; target: string; text: string }
44
+ | { kind: "unknown-action"; actionId: string }
45
+ | { kind: "typing-chord"; target: string; text: string }
46
+ | { kind: "conflict"; chord: KeyChord; targets: string[] };
47
+
48
+ export interface ResolvedKeybindings {
49
+ actions: ResolvedKeybindingAction[];
50
+ actionsById: Map<string, ResolvedKeybindingAction>;
51
+ commands: KeybindingCommand[];
52
+ issues: KeybindingIssue[];
53
+ }
54
+
55
+ export type KeybindingMatch =
56
+ | { kind: "action"; id: string; action: ResolvedKeybindingAction; digit: number | null }
57
+ | { kind: "command"; command: KeybindingCommand };
58
+
59
+ export interface ResolveKeybindingsOptions {
60
+ /** Plugin shortcuts to fold into the table, for listing and conflict checks. */
61
+ pluginShortcuts?: Iterable<KeyboardShortcut>;
62
+ }
63
+
64
+ export function pluginShortcutDefaultChord(shortcut: KeyboardShortcut): KeyChord {
65
+ return {
66
+ key: shortcut.key.toLowerCase(),
67
+ ctrl: shortcut.ctrl === true,
68
+ cmd: false,
69
+ primary: false,
70
+ alt: false,
71
+ shift: shortcut.shift === true || (shortcut.key.length === 1 && shortcut.key !== shortcut.key.toLowerCase()),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * An override's chords. `null` in the config is a deliberate unbind and gives
77
+ * an empty list; text that does not parse is reported and dropped, and when
78
+ * nothing parses the override is ignored so a typo never silently unbinds.
79
+ */
80
+ function parseChordList(
81
+ value: string | string[] | null | undefined,
82
+ target: string,
83
+ issues: KeybindingIssue[],
84
+ ): KeyChord[] | null {
85
+ if (value === undefined) return null;
86
+ if (value === null) return [];
87
+ const texts = Array.isArray(value) ? value : [value];
88
+ const chords: KeyChord[] = [];
89
+ let invalid = false;
90
+ for (const text of texts) {
91
+ if (typeof text !== "string") continue;
92
+ const chord = parseKeyChord(text);
93
+ if (!chord) {
94
+ issues.push({ kind: "invalid-chord", target, text });
95
+ invalid = true;
96
+ continue;
97
+ }
98
+ chords.push(chord);
99
+ }
100
+ return chords.length === 0 && invalid ? null : chords;
101
+ }
102
+
103
+ function parseDefaults(texts: readonly string[]): KeyChord[] {
104
+ return texts.map((text) => {
105
+ const chord = parseKeyChord(text);
106
+ if (!chord) throw new Error(`Default keybinding "${text}" does not parse.`);
107
+ return chord;
108
+ });
109
+ }
110
+
111
+ const DEFAULT_CHORDS = new Map(KEYBINDING_ACTIONS.map((action) => [action.id, parseDefaults(action.defaults)]));
112
+
113
+ /**
114
+ * Two chords fire on the same keypress somewhere: identical, or one says
115
+ * `CmdOrCtrl` where the other names Control or Command explicitly.
116
+ */
117
+ export function keyChordsOverlap(left: KeyChord, right: KeyChord): boolean {
118
+ if (left.key !== right.key || left.alt !== right.alt || left.shift !== right.shift) return false;
119
+ if (left.primary && right.primary) return true;
120
+ if (left.primary) return (right.ctrl || right.cmd) && !(right.ctrl && right.cmd);
121
+ if (right.primary) return (left.ctrl || left.cmd) && !(left.ctrl && left.cmd);
122
+ return left.ctrl === right.ctrl && left.cmd === right.cmd;
123
+ }
124
+
125
+ function resolveAction(
126
+ def: KeybindingActionDef | undefined,
127
+ pluginShortcut: KeyboardShortcut | undefined,
128
+ id: string,
129
+ defaults: KeyChord[],
130
+ overrides: NonNullable<KeybindingsConfig["actions"]>,
131
+ issues: KeybindingIssue[],
132
+ ): ResolvedKeybindingAction {
133
+ const override = parseChordList(overrides[id], id, issues);
134
+ return {
135
+ id,
136
+ def,
137
+ pluginShortcut,
138
+ defaults,
139
+ chords: override ?? defaults,
140
+ custom: override !== null,
141
+ };
142
+ }
143
+
144
+ function collectConflicts(actions: ResolvedKeybindingAction[], commands: KeybindingCommand[]): KeybindingIssue[] {
145
+ const entries: Array<{ target: string; chord: KeyChord }> = [
146
+ ...actions.flatMap((action) => action.chords.map((chord) => ({ target: action.id, chord }))),
147
+ ...commands.map((command) => ({ target: `command:${command.text}`, chord: command.chord })),
148
+ ];
149
+ const reported = new Set<string>();
150
+ const issues: KeybindingIssue[] = [];
151
+ for (let index = 0; index < entries.length; index += 1) {
152
+ const entry = entries[index]!;
153
+ const targets = [entry.target];
154
+ for (let other = index + 1; other < entries.length; other += 1) {
155
+ const candidate = entries[other]!;
156
+ if (candidate.target !== entry.target && keyChordsOverlap(entry.chord, candidate.chord)) {
157
+ targets.push(candidate.target);
158
+ }
159
+ }
160
+ if (targets.length < 2) continue;
161
+ const key = `${serializeKeyChord(entry.chord)}|${[...new Set(targets)].sort().join(",")}`;
162
+ if (reported.has(key)) continue;
163
+ reported.add(key);
164
+ issues.push({ kind: "conflict", chord: entry.chord, targets: [...new Set(targets)] });
165
+ }
166
+ return issues;
167
+ }
168
+
169
+ export function resolveKeybindings(
170
+ config: KeybindingsConfig | undefined,
171
+ options: ResolveKeybindingsOptions = {},
172
+ ): ResolvedKeybindings {
173
+ const issues: KeybindingIssue[] = [];
174
+ const overrides = config?.actions ?? {};
175
+ const actions: ResolvedKeybindingAction[] = KEYBINDING_ACTIONS.map((def) => resolveAction(
176
+ def,
177
+ undefined,
178
+ def.id,
179
+ DEFAULT_CHORDS.get(def.id)!,
180
+ overrides,
181
+ issues,
182
+ ));
183
+ for (const shortcut of options.pluginShortcuts ?? []) {
184
+ actions.push(resolveAction(
185
+ undefined,
186
+ shortcut,
187
+ pluginShortcutActionId(shortcut.id),
188
+ [pluginShortcutDefaultChord(shortcut)],
189
+ overrides,
190
+ issues,
191
+ ));
192
+ }
193
+ for (const actionId of Object.keys(overrides)) {
194
+ if (isCoreKeybindingActionId(actionId) || actionId.startsWith(PLUGIN_ACTION_PREFIX)) continue;
195
+ issues.push({ kind: "unknown-action", actionId });
196
+ }
197
+
198
+ const commands: KeybindingCommand[] = [];
199
+ for (const [text, query] of Object.entries(config?.commands ?? {})) {
200
+ if (typeof query !== "string" || !query.trim()) continue;
201
+ const chord = parseKeyChord(text);
202
+ if (!chord) {
203
+ issues.push({ kind: "invalid-chord", target: "commands", text });
204
+ continue;
205
+ }
206
+ if (isTypingChord(chord)) {
207
+ issues.push({ kind: "typing-chord", target: "commands", text });
208
+ continue;
209
+ }
210
+ commands.push({ chord, text, query: query.trim() });
211
+ }
212
+
213
+ issues.push(...collectConflicts(actions, commands));
214
+
215
+ return {
216
+ actions,
217
+ actionsById: new Map(actions.map((action) => [action.id, action])),
218
+ commands,
219
+ issues,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * The first action or command whose chord the event is, in table order. Actions
225
+ * win over commands, so a stray command binding never shadows a core key.
226
+ */
227
+ export function matchKeybinding(resolved: ResolvedKeybindings, event: KeyChordEventLike): KeybindingMatch | null {
228
+ for (const action of resolved.actions) {
229
+ for (const chord of action.chords) {
230
+ if (!matchesKeyChord(chord, event)) continue;
231
+ return {
232
+ kind: "action",
233
+ id: action.id,
234
+ action,
235
+ digit: chord.key === "digit" ? keyChordDigit(event) : null,
236
+ };
237
+ }
238
+ }
239
+ for (const command of resolved.commands) {
240
+ if (matchesKeyChord(command.chord, event)) return { kind: "command", command };
241
+ }
242
+ return null;
243
+ }
244
+
245
+ export function matchesKeybindingAction(resolved: ResolvedKeybindings, id: string, event: KeyChordEventLike): boolean {
246
+ const action = resolved.actionsById.get(id);
247
+ return !!action && action.chords.some((chord) => matchesKeyChord(chord, event));
248
+ }
249
+
250
+ export function isPaneKeybindingAction(id: string): boolean {
251
+ return isCoreKeybindingActionId(id) && PANE_ACTION_IDS.has(id);
252
+ }
253
+
254
+ /**
255
+ * Chords for one plugin shortcut, honouring an override, for a handler that
256
+ * reads the live registry instead of a resolved table.
257
+ */
258
+ export function resolvePluginShortcutChords(config: KeybindingsConfig | undefined, shortcut: KeyboardShortcut): KeyChord[] {
259
+ const override = parseChordList(config?.actions?.[pluginShortcutActionId(shortcut.id)], shortcut.id, []);
260
+ return override ?? [pluginShortcutDefaultChord(shortcut)];
261
+ }
262
+
263
+ export function describeKeybindingIssue(issue: KeybindingIssue): string {
264
+ switch (issue.kind) {
265
+ case "invalid-chord":
266
+ return `"${issue.text}" is not a key chord (${issue.target}).`;
267
+ case "unknown-action":
268
+ return `"${issue.actionId}" is not a keybinding action.`;
269
+ case "typing-chord":
270
+ return `"${issue.text}" would fire while typing; command bindings need Ctrl, Cmd, Alt or a function key.`;
271
+ case "conflict":
272
+ return `${serializeKeyChord(issue.chord)} is bound to ${issue.targets.join(" and ")}.`;
273
+ }
274
+ }
275
+
276
+ export function keybindingActionLabel(action: ResolvedKeybindingAction): string {
277
+ return action.def?.description ?? action.pluginShortcut?.description ?? action.id;
278
+ }
279
+
280
+ export { getKeybindingAction };
package/src/app.tsx CHANGED
@@ -39,6 +39,7 @@ import { useBrokerImportRuntime } from "./app/runtime/broker-import";
39
39
  import { useDesktopDeepLinkRuntime } from "./app/runtime/desktop-deeplink";
40
40
  import { useDesktopApplicationMenuRuntime } from "./app/runtime/desktop-menu";
41
41
  import { useAppGlobalShortcuts } from "./app/global-shortcuts";
42
+ import { KeybindingsProvider, useResolvedKeybindings } from "./app/keybindings";
42
43
  import { useAppPaneRuntime } from "./app/pane-runtime";
43
44
  import { bindPluginRegistryRuntimeAccess } from "./app/runtime/plugin-bindings";
44
45
  import { useAppStartupRuntime } from "./app/runtime/startup";
@@ -380,10 +381,12 @@ function AppInner({
380
381
  }
381
382
  }, [focusedTickerSymbol]);
382
383
 
384
+ const keybindings = useResolvedKeybindings(state.config.keybindings);
383
385
  useAppGlobalShortcuts({
384
386
  dispatch,
385
387
  focusedTickerSymbol,
386
388
  isDetachedWindow,
389
+ keybindings,
387
390
  pluginRegistry,
388
391
  refreshTicker,
389
392
  startUpdate,
@@ -392,27 +395,30 @@ function AppInner({
392
395
 
393
396
  if (desktopWindowBridge?.kind === "detached" && desktopWindowBridge.paneId) {
394
397
  return (
395
- <ContextMenuProvider pluginRegistry={pluginRegistry}>
396
- <RemoteControlHost
397
- adapter={remoteControlAdapter}
398
- dispatch={dispatch}
399
- getState={getRemoteState}
400
- pluginRegistry={pluginRegistry}
401
- desktopWindowBridge={desktopWindowBridge}
402
- >
403
- <ThemedAppRoot>
404
- <DetachedPaneShell
405
- pluginRegistry={pluginRegistry}
406
- desktopWindowBridge={{ ...desktopWindowBridge, kind: "detached", paneId: desktopWindowBridge.paneId }}
407
- />
408
- <ToastViewport position="bottom-right" />
409
- </ThemedAppRoot>
410
- </RemoteControlHost>
411
- </ContextMenuProvider>
398
+ <KeybindingsProvider value={keybindings}>
399
+ <ContextMenuProvider pluginRegistry={pluginRegistry}>
400
+ <RemoteControlHost
401
+ adapter={remoteControlAdapter}
402
+ dispatch={dispatch}
403
+ getState={getRemoteState}
404
+ pluginRegistry={pluginRegistry}
405
+ desktopWindowBridge={desktopWindowBridge}
406
+ >
407
+ <ThemedAppRoot>
408
+ <DetachedPaneShell
409
+ pluginRegistry={pluginRegistry}
410
+ desktopWindowBridge={{ ...desktopWindowBridge, kind: "detached", paneId: desktopWindowBridge.paneId }}
411
+ />
412
+ <ToastViewport position="bottom-right" />
413
+ </ThemedAppRoot>
414
+ </RemoteControlHost>
415
+ </ContextMenuProvider>
416
+ </KeybindingsProvider>
412
417
  );
413
418
  }
414
419
 
415
420
  return (
421
+ <KeybindingsProvider value={keybindings}>
416
422
  <ContextMenuProvider pluginRegistry={pluginRegistry}>
417
423
  <RemoteControlHost
418
424
  adapter={remoteControlAdapter}
@@ -460,6 +466,7 @@ function AppInner({
460
466
  </ThemedAppRoot>
461
467
  </RemoteControlHost>
462
468
  </ContextMenuProvider>
469
+ </KeybindingsProvider>
463
470
  );
464
471
  }
465
472
 
@@ -548,6 +555,7 @@ export function App({
548
555
  servicesFactory({
549
556
  config,
550
557
  plugins,
558
+ externalPlugins,
551
559
  })
552
560
  ), {
553
561
  externalPluginCount: externalPlugins.length,
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createDefaultConfig } from "../../types/config";
3
+ import { applyKeybindingCliSet, describeKeybindingsForCli } from "./keybindings";
4
+
5
+ const config = createDefaultConfig("/tmp/gloomberb-cli-keybindings");
6
+
7
+ describe("config set keybindings", () => {
8
+ test("binds, unbinds and restores actions", () => {
9
+ const bound = applyKeybindingCliSet(config, "keybindings.actions.ticker-search", "Ctrl+T `");
10
+ if (!bound.ok) throw new Error(bound.message);
11
+ expect(bound.value).toEqual(["Ctrl+T", "`"]);
12
+ expect(bound.config.keybindings).toEqual({ actions: { "ticker-search": ["Ctrl+T", "`"] } });
13
+
14
+ const unbound = applyKeybindingCliSet(bound.config, "keybindings.actions.help", "null");
15
+ if (!unbound.ok) throw new Error(unbound.message);
16
+ expect(unbound.config.keybindings?.actions).toEqual({ "ticker-search": ["Ctrl+T", "`"], help: null });
17
+
18
+ const restored = applyKeybindingCliSet(unbound.config, "keybindings.actions.ticker-search", "default");
19
+ if (!restored.ok) throw new Error(restored.message);
20
+ expect(restored.config.keybindings).toEqual({ actions: { help: null } });
21
+ expect(describeKeybindingsForCli(restored.config)).toMatchObject({
22
+ actions: {
23
+ help: { keys: [], custom: true, defaults: ["?"] },
24
+ "ticker-search": { keys: ["`"] },
25
+ },
26
+ issues: [],
27
+ });
28
+ });
29
+
30
+ test("binds and removes commands, refusing chords a text field would eat", () => {
31
+ const bound = applyKeybindingCliSet(config, "keybindings.commands.alt+1", "DES AAPL");
32
+ if (!bound.ok) throw new Error(bound.message);
33
+ expect(bound.config.keybindings).toEqual({ commands: { "Alt+1": "DES AAPL" } });
34
+
35
+ const removed = applyKeybindingCliSet(bound.config, "keybindings.commands.Alt+1", "null");
36
+ if (!removed.ok) throw new Error(removed.message);
37
+ expect(removed.config.keybindings).toBeUndefined();
38
+
39
+ expect(applyKeybindingCliSet(config, "keybindings.commands.a", "HN")).toMatchObject({ ok: false });
40
+ });
41
+
42
+ test("rejects unknown actions, bad chords and bad sections with a usable message", () => {
43
+ expect(applyKeybindingCliSet(config, "keybindings.actions.nope", "F1")).toMatchObject({ ok: false, message: expect.stringContaining("not a keybinding action") });
44
+ expect(applyKeybindingCliSet(config, "keybindings.actions.quit", "Hyper+Q")).toMatchObject({ ok: false, message: expect.stringContaining("not a key chord") });
45
+ expect(applyKeybindingCliSet(config, "keybindings.other.x", "F1")).toMatchObject({ ok: false });
46
+ expect(applyKeybindingCliSet(config, "keybindings.actions", "F1")).toMatchObject({ ok: false });
47
+ });
48
+ });
@@ -0,0 +1,102 @@
1
+ import {
2
+ applyActionBinding,
3
+ describeKeybindingIssue,
4
+ isCoreKeybindingActionId,
5
+ isTypingChord,
6
+ parseKeyChord,
7
+ PLUGIN_ACTION_PREFIX,
8
+ removeCommandBinding,
9
+ resolveKeybindings,
10
+ serializeKeyChord,
11
+ setCommandBinding,
12
+ updateKeybindingsConfig,
13
+ } from "../../app/keybindings";
14
+ import type { AppConfig } from "../../types/config";
15
+
16
+ export const KEYBINDINGS_CONFIG_KEY = "keybindings";
17
+
18
+ const UNBIND_WORDS = new Set(["null", "none", "off", "unbind"]);
19
+ const DEFAULT_WORDS = new Set(["default", "defaults", "reset"]);
20
+
21
+ /** The effective table with defaults filled in, plus anything that needs fixing. */
22
+ export function describeKeybindingsForCli(config: AppConfig): Record<string, unknown> {
23
+ const resolved = resolveKeybindings(config.keybindings);
24
+ return {
25
+ actions: Object.fromEntries(resolved.actions.map((action) => [
26
+ action.id,
27
+ {
28
+ keys: action.chords.map(serializeKeyChord),
29
+ ...(action.custom ? { custom: true, defaults: action.defaults.map(serializeKeyChord) } : {}),
30
+ },
31
+ ])),
32
+ commands: Object.fromEntries(resolved.commands.map((command) => [command.text, command.query])),
33
+ issues: resolved.issues.map(describeKeybindingIssue),
34
+ };
35
+ }
36
+
37
+ export type KeybindingCliSetResult =
38
+ | { ok: true; config: AppConfig; value: unknown }
39
+ | { ok: false; message: string };
40
+
41
+ /**
42
+ * `config set keybindings.actions.<action> <chords>` and
43
+ * `config set keybindings.commands.<chord> <query>`. Chords are separated by
44
+ * whitespace, since no chord contains a space; `null` unbinds and `default`
45
+ * clears an override.
46
+ */
47
+ export function applyKeybindingCliSet(config: AppConfig, key: string, rawValue: string): KeybindingCliSetResult {
48
+ const [, section, ...rest] = key.split(".");
49
+ const target = rest.join(".");
50
+ const value = rawValue.trim();
51
+ if (!target) {
52
+ return { ok: false, message: `Usage: gloomberb config set ${KEYBINDINGS_CONFIG_KEY}.actions.<action> <chord> or ${KEYBINDINGS_CONFIG_KEY}.commands.<chord> <query>` };
53
+ }
54
+
55
+ if (section === "actions") {
56
+ if (!isCoreKeybindingActionId(target) && !target.startsWith(PLUGIN_ACTION_PREFIX)) {
57
+ return { ok: false, message: `"${target}" is not a keybinding action. Run "gloomberb config get keybindings" for the list.` };
58
+ }
59
+ const lowered = value.toLowerCase();
60
+ if (DEFAULT_WORDS.has(lowered)) {
61
+ const actions = { ...(config.keybindings?.actions ?? {}) };
62
+ delete actions[target];
63
+ return { ok: true, config: updateKeybindingsConfig(config, { ...config.keybindings, actions }), value: "default" };
64
+ }
65
+ if (UNBIND_WORDS.has(lowered) || value === "") {
66
+ return { ok: true, config: updateKeybindingsConfig(config, applyActionBinding(config.keybindings, target, [])), value: null };
67
+ }
68
+ const chords = [];
69
+ for (const text of value.split(/\s+/)) {
70
+ const chord = parseKeyChord(text);
71
+ if (!chord) return { ok: false, message: `"${text}" is not a key chord. Examples: Ctrl+T, CmdOrCtrl+Shift+F, Alt+1, F5, \`` };
72
+ chords.push(chord);
73
+ }
74
+ return {
75
+ ok: true,
76
+ config: updateKeybindingsConfig(config, applyActionBinding(config.keybindings, target, chords)),
77
+ value: chords.map(serializeKeyChord),
78
+ };
79
+ }
80
+
81
+ if (section === "commands") {
82
+ const chord = parseKeyChord(target);
83
+ if (!chord) return { ok: false, message: `"${target}" is not a key chord. Examples: Alt+1, CmdOrCtrl+Shift+N, F5` };
84
+ if (UNBIND_WORDS.has(value.toLowerCase()) || value === "") {
85
+ return {
86
+ ok: true,
87
+ config: updateKeybindingsConfig(config, removeCommandBinding(config.keybindings, serializeKeyChord(chord))),
88
+ value: null,
89
+ };
90
+ }
91
+ if (isTypingChord(chord)) {
92
+ return { ok: false, message: `"${target}" would fire while typing; command bindings need Ctrl, Cmd, Alt or a function key.` };
93
+ }
94
+ return {
95
+ ok: true,
96
+ config: updateKeybindingsConfig(config, setCommandBinding(config.keybindings, chord, value)),
97
+ value,
98
+ };
99
+ }
100
+
101
+ return { ok: false, message: `Unknown keybindings section "${section ?? ""}". Use keybindings.actions or keybindings.commands.` };
102
+ }