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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/index.tsx",
@@ -535,6 +535,10 @@ export interface CloudJobsSeriesPoint {
535
535
  open: number;
536
536
  new: number;
537
537
  closed: number;
538
+ /** Open standing roles the company hires for continuously. */
539
+ evergreen?: number;
540
+ /** Open contract and temporary roles. */
541
+ contract?: number;
538
542
  }
539
543
 
540
544
  export interface CloudJobsBucket {
@@ -554,6 +558,9 @@ export interface CloudJobsPosting {
554
558
  location: string | null;
555
559
  country: string | null;
556
560
  remote: boolean | null;
561
+ employmentKind?: string | null;
562
+ /** A standing posting for a continuous-hiring role; null when unjudged. */
563
+ evergreen?: boolean | null;
557
564
  postedAt: string | null;
558
565
  postedPrecision: "exact" | "approximate" | "floor" | null;
559
566
  firstSeenAt: string;
@@ -595,6 +602,16 @@ export interface CloudJobsSummaryPayload {
595
602
  /** Open postings the system dates within the last 30 days. */
596
603
  posted30d?: number | null;
597
604
  remoteShare: number | null;
605
+ /**
606
+ * Open roles that are standing postings for continuous-hiring positions
607
+ * (baristas, servers, store associates, nurses). One more of those says
608
+ * nothing about the company's plans; open minus evergreen is the count of
609
+ * specific roles.
610
+ */
611
+ evergreenCount?: number;
612
+ evergreenShare?: number | null;
613
+ /** Share of open roles that are contract or temporary, among those known. */
614
+ contractShare?: number | null;
598
615
  medianAgeDays: number | null;
599
616
  series: CloudJobsSeriesPoint[];
600
617
  /** The current backlog by how long each role has been posted. */
@@ -4,8 +4,9 @@ import { act } from "react";
4
4
  import type { PluginRegistry } from "../plugins/registry";
5
5
  import { TestDialogProvider, createOpenTuiTestRoot as createRoot, emitKeypress as emitTuiKeypress, type TestKeyEvent } from "../renderers/opentui/test-utils";
6
6
  import { createInitialState, type AppAction, type AppState } from "../state/app/context";
7
- import { cloneLayout, createDefaultConfig } from "../types/config";
7
+ import { cloneLayout, createDefaultConfig, type KeybindingsConfig } from "../types/config";
8
8
  import { useAppGlobalShortcuts } from "./global-shortcuts";
9
+ import { resolveKeybindings } from "./keybindings";
9
10
 
10
11
  let testSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined;
11
12
  let root: ReturnType<typeof createRoot> | undefined;
@@ -63,6 +64,7 @@ function ShortcutHarness({
63
64
  dispatch,
64
65
  focusedTickerSymbol,
65
66
  isDetachedWindow: false,
67
+ keybindings: resolveKeybindings(state.config.keybindings),
66
68
  pluginRegistry,
67
69
  refreshTicker,
68
70
  startUpdate: () => {},
@@ -71,6 +73,12 @@ function ShortcutHarness({
71
73
  return <text>ready</text>;
72
74
  }
73
75
 
76
+ function stateWithKeybindings(suffix: string, keybindings: KeybindingsConfig, options: Partial<AppState> = {}): AppState {
77
+ const config = createDefaultConfig(`/tmp/gloomberb-global-shortcuts-${suffix}`);
78
+ config.keybindings = keybindings;
79
+ return { ...createInitialState(config), ...options };
80
+ }
81
+
74
82
  async function renderHarness(
75
83
  state: AppState,
76
84
  registry: PluginRegistry,
@@ -152,6 +160,82 @@ describe("useAppGlobalShortcuts", () => {
152
160
  expect(event.propagationStopped).toBe(true);
153
161
  });
154
162
 
163
+ test("a rebound action answers to its new key and not the old one", async () => {
164
+ const actions: AppAction[] = [];
165
+ const state = stateWithKeybindings("rebound", {
166
+ actions: { "ticker-search": "Ctrl+Shift+S", "command-bar": "Ctrl+Shift+P", help: null },
167
+ });
168
+ const openedPanes: string[] = [];
169
+ await renderHarness(state, createRegistry(undefined, (paneId) => openedPanes.push(paneId)), (action) => actions.push(action));
170
+
171
+ const oldKey = await emitKeypress({ name: "`" });
172
+ expect(actions).toEqual([]);
173
+ expect(oldKey.defaultPrevented).toBe(false);
174
+
175
+ await emitKeypress({ name: "s", ctrl: true, shift: true });
176
+ expect(actions).toEqual([{
177
+ type: "SET_COMMAND_BAR",
178
+ open: true,
179
+ query: "",
180
+ launch: { kind: "ticker-search", query: "" },
181
+ }]);
182
+
183
+ await emitKeypress({ name: "p", ctrl: true });
184
+ expect(actions).toHaveLength(1);
185
+ await emitKeypress({ name: "p", ctrl: true, shift: true });
186
+ expect(actions[1]).toEqual({ type: "TOGGLE_COMMAND_BAR" });
187
+
188
+ const help = await emitKeypress({ name: "?", shift: true });
189
+ expect(openedPanes).toEqual([]);
190
+ expect(help.defaultPrevented).toBe(false);
191
+ });
192
+
193
+ test("a command binding opens the bar with the text to run, unless the bar or a dialog owns the keyboard", async () => {
194
+ const actions: AppAction[] = [];
195
+ const state = stateWithKeybindings("command", { commands: { "Alt+1": "DES AAPL" } });
196
+ await renderHarness(state, createRegistry(), (action) => actions.push(action));
197
+
198
+ const event = await emitKeypress({ name: "1", alt: true });
199
+
200
+ expect(actions).toEqual([{
201
+ type: "SET_COMMAND_BAR",
202
+ open: true,
203
+ query: "DES AAPL",
204
+ launch: { kind: "run-query", query: "DES AAPL" },
205
+ }]);
206
+ expect(event.defaultPrevented).toBe(true);
207
+ expect(event.propagationStopped).toBe(true);
208
+ });
209
+
210
+ test("a Control command binding yields to the editor while text is being typed", async () => {
211
+ const actions: AppAction[] = [];
212
+ const state = stateWithKeybindings("command-typing", { commands: { "Ctrl+N": "NEWS", F5: "NEWS" } }, { inputCaptured: true });
213
+ await renderHarness(state, createRegistry(), (action) => actions.push(action));
214
+
215
+ const ctrl = await emitKeypress({ name: "n", ctrl: true });
216
+ expect(actions).toEqual([]);
217
+ expect(ctrl.defaultPrevented).toBe(false);
218
+
219
+ await emitKeypress({ name: "f5" });
220
+ expect(actions).toEqual([{
221
+ type: "SET_COMMAND_BAR",
222
+ open: true,
223
+ query: "NEWS",
224
+ launch: { kind: "run-query", query: "NEWS" },
225
+ }]);
226
+ });
227
+
228
+ test("a plugin shortcut honours its override", async () => {
229
+ let executed = 0;
230
+ const state = stateWithKeybindings("plugin-override", { actions: { "plugin:test-shortcut": "Alt+X" } });
231
+ await renderHarness(state, createRegistry(() => { executed += 1; }), () => {});
232
+
233
+ await emitKeypress({ name: "x" });
234
+ expect(executed).toBe(0);
235
+ await emitKeypress({ name: "x", alt: true });
236
+ expect(executed).toBe(1);
237
+ });
238
+
155
239
  function layoutState(suffix: string, options: { commandBarOpen?: boolean } = {}) {
156
240
  const config = createDefaultConfig(`/tmp/gloomberb-global-shortcuts-${suffix}`);
157
241
  config.layouts = [
@@ -1,4 +1,4 @@
1
- import type { Dispatch } from "react";
1
+ import { useEffect, useRef, type Dispatch } from "react";
2
2
  import { useShortcut } from "../react/input";
3
3
  import { useNativeRenderer, useRendererHost } from "../ui";
4
4
  import { useDialogState } from "../ui/dialog";
@@ -14,11 +14,49 @@ import {
14
14
  isPasteShortcut,
15
15
  pasteSystemClipboard,
16
16
  } from "../utils/selection-clipboard";
17
+ import {
18
+ describeKeybindingIssue,
19
+ getDefaultKeybindings,
20
+ matchKeybinding,
21
+ matchesKeyChord,
22
+ resolvePluginShortcutChords,
23
+ type ResolvedKeybindings,
24
+ } from "./keybindings";
25
+
26
+ /**
27
+ * Tells the user once per launch when `config.json` holds a binding that does
28
+ * not parse, names no action, or lands on a key another action already has.
29
+ * Silence would read as "I set it and nothing happened".
30
+ */
31
+ function useKeybindingIssueNotice(
32
+ keybindings: ResolvedKeybindings,
33
+ pluginRegistry: PluginRegistry,
34
+ initialized: boolean,
35
+ ) {
36
+ const noticedRef = useRef<string | null>(null);
37
+ const issueKey = keybindings.issues.map(describeKeybindingIssue).join("\n");
38
+ useEffect(() => {
39
+ if (!initialized || !issueKey || noticedRef.current === issueKey) return;
40
+ noticedRef.current = issueKey;
41
+ const lines = keybindings.issues.map(describeKeybindingIssue);
42
+ const shown = lines.slice(0, 3);
43
+ const more = lines.length - shown.length;
44
+ pluginRegistry.notify({
45
+ body: [
46
+ "Keybindings need attention:",
47
+ ...shown,
48
+ ...(more > 0 ? [`and ${more} more; see Help > Shortcuts.`] : []),
49
+ ].join("\n"),
50
+ type: "error",
51
+ });
52
+ }, [initialized, issueKey, keybindings.issues, pluginRegistry]);
53
+ }
17
54
 
18
55
  export function useAppGlobalShortcuts({
19
56
  dispatch,
20
57
  focusedTickerSymbol,
21
58
  isDetachedWindow,
59
+ keybindings = getDefaultKeybindings(),
22
60
  pluginRegistry,
23
61
  refreshTicker,
24
62
  startUpdate,
@@ -27,6 +65,8 @@ export function useAppGlobalShortcuts({
27
65
  dispatch: Dispatch<AppAction>;
28
66
  focusedTickerSymbol: string | null;
29
67
  isDetachedWindow: boolean;
68
+ /** The resolved table; the app resolves it once and shares it with the shell. */
69
+ keybindings?: ResolvedKeybindings;
30
70
  pluginRegistry: PluginRegistry;
31
71
  refreshTicker: (symbol: string, exchange?: string, tickerOverride?: TickerRecord | null, priority?: number) => void;
32
72
  startUpdate: (release: ReleaseInfo) => void;
@@ -35,6 +75,7 @@ export function useAppGlobalShortcuts({
35
75
  const dialogOpen = useDialogState((s) => s.isOpen);
36
76
  const nativeRenderer = useNativeRenderer();
37
77
  const rendererHost = useRendererHost();
78
+ useKeybindingIssueNotice(keybindings, pluginRegistry, state.initialized);
38
79
 
39
80
  useShortcut((event) => {
40
81
  if (isCopyShortcut(event) && copyActiveSelection(nativeRenderer)) {
@@ -48,17 +89,18 @@ export function useAppGlobalShortcuts({
48
89
  return;
49
90
  }
50
91
 
92
+ const match = matchKeybinding(keybindings, event);
93
+ const action = match?.kind === "action" ? match.id : null;
94
+
51
95
  // Terminals send Ctrl; the browser and the desktop webview send Cmd on
52
96
  // macOS, which the OpenTUI host also reports as `super` under the kitty
53
- // protocol. Alt stays out so Alt-digit keeps its terminal meaning. While a
54
- // dialog, the command bar or an editable field owns the keyboard the digit
55
- // must not move layouts, but it still has to be swallowed there or the
56
- // webview hands Cmd-digit to the browser's own tab switcher.
57
- if (!isDetachedWindow
58
- && /^[1-9]$/.test(event.name ?? "")
59
- && (event.ctrl || event.meta || event.super)) {
97
+ // protocol. While a dialog, the command bar or an editable field owns the
98
+ // keyboard the digit must not move layouts, but it still has to be
99
+ // swallowed there or the webview hands Cmd-digit to the browser's own tab
100
+ // switcher.
101
+ if (!isDetachedWindow && action === "switch-layout" && match?.kind === "action" && match.digit !== null) {
60
102
  const layouts = state.config.layouts ?? [];
61
- const idx = parseInt(event.name!, 10) - 1;
103
+ const idx = match.digit - 1;
62
104
  const uiOwnsKeyboard = dialogOpen || state.commandBarOpen || event.targetEditable === true;
63
105
  if (!uiOwnsKeyboard && idx < layouts.length && idx !== state.config.activeLayoutIndex) {
64
106
  dispatch({ type: "SWITCH_LAYOUT", index: idx });
@@ -70,16 +112,13 @@ export function useAppGlobalShortcuts({
70
112
 
71
113
  if (dialogOpen) return;
72
114
 
73
- if (!isDetachedWindow && (
74
- (event.name === "p" && event.ctrl)
75
- || (event.name === "k" && (event.ctrl || event.meta || event.super))
76
- )) {
115
+ if (!isDetachedWindow && action === "command-bar") {
77
116
  event.preventDefault();
78
117
  event.stopPropagation();
79
118
  dispatch({ type: "TOGGLE_COMMAND_BAR" });
80
119
  return;
81
120
  }
82
- if (!isDetachedWindow && event.name === "`" && !state.commandBarOpen) {
121
+ if (!isDetachedWindow && action === "ticker-search" && !state.commandBarOpen) {
83
122
  event.preventDefault();
84
123
  event.stopPropagation();
85
124
  dispatch({
@@ -91,11 +130,9 @@ export function useAppGlobalShortcuts({
91
130
  return;
92
131
  }
93
132
 
94
- const hasShortcutModifier = event.ctrl || event.meta || event.super || event.alt;
95
-
96
133
  if (state.commandBarOpen) return;
97
134
 
98
- if (event.name === "tab") {
135
+ if (action === "focus-next-pane" || action === "focus-prev-pane") {
99
136
  const paneOrder = getVisiblePaneCycleOrder(
100
137
  state.config.layout,
101
138
  pluginRegistry,
@@ -103,50 +140,60 @@ export function useAppGlobalShortcuts({
103
140
  );
104
141
  if (paneOrder.length === 0) return;
105
142
 
106
- if (event.shift) {
107
- dispatch({ type: "FOCUS_PREV", paneOrder });
108
- } else {
109
- dispatch({ type: "FOCUS_NEXT", paneOrder });
110
- }
143
+ dispatch({ type: action === "focus-prev-pane" ? "FOCUS_PREV" : "FOCUS_NEXT", paneOrder });
144
+ event.preventDefault();
145
+ event.stopPropagation();
146
+ return;
147
+ }
148
+
149
+ if (match?.kind === "command" && !isDetachedWindow) {
150
+ // Control chords belong to the editor while text is being typed; Alt,
151
+ // Command and function keys are never text, so they still fire.
152
+ const editing = state.inputCaptured || event.targetEditable === true;
153
+ if (editing && event.ctrl && !(event.meta || event.super)) return;
111
154
  event.preventDefault();
112
155
  event.stopPropagation();
156
+ dispatch({
157
+ type: "SET_COMMAND_BAR",
158
+ open: true,
159
+ query: match.command.query,
160
+ launch: { kind: "run-query", query: match.command.query },
161
+ });
113
162
  return;
114
163
  }
115
164
 
116
165
  if (state.inputCaptured) return;
117
166
 
118
- const isQuestionMark = event.name === "?"
119
- || event.key === "?"
120
- || event.sequence === "?"
121
- || (event.name === "/" && event.shift);
122
- if (!isDetachedWindow && !hasShortcutModifier && isQuestionMark) {
167
+ if (!isDetachedWindow && action === "help") {
123
168
  event.preventDefault();
124
169
  event.stopPropagation();
125
170
  pluginRegistry.showPane("help");
126
171
  return;
127
172
  }
128
173
 
129
- if (!hasShortcutModifier && !isDetachedWindow && event.name === "q") {
174
+ if (!isDetachedWindow && action === "quit") {
130
175
  rendererHost.requestExit();
131
- } else if (!hasShortcutModifier && event.name === "r") {
176
+ } else if (action === "refresh-ticker") {
132
177
  if (focusedTickerSymbol) {
133
178
  const ticker = state.tickers.get(focusedTickerSymbol);
134
179
  if (ticker) refreshTicker(ticker.metadata.ticker, ticker.metadata.exchange, ticker, 0);
135
180
  }
136
- } else if (!hasShortcutModifier && (event.name === "R" || (event.name === "r" && event.shift))) {
181
+ } else if (action === "refresh-all") {
137
182
  for (const ticker of state.tickers.values()) {
138
183
  refreshTicker(ticker.metadata.ticker, ticker.metadata.exchange, ticker, 1);
139
184
  }
140
- } else if (!hasShortcutModifier && event.name === "u" && state.updateAvailable && !state.updateProgress && !state.updateCheckInProgress && canSelfUpdate(state.updateAvailable)) {
141
- startUpdate(state.updateAvailable);
142
- } else {
185
+ } else if (action === "install-update") {
186
+ if (state.updateAvailable && !state.updateProgress && !state.updateCheckInProgress && canSelfUpdate(state.updateAvailable)) {
187
+ startUpdate(state.updateAvailable);
188
+ }
189
+ } else if (!action) {
143
190
  const disabledPlugins = new Set(state.config.disabledPlugins || []);
191
+ const keybindingsConfig = state.config.keybindings;
144
192
  for (const shortcut of pluginRegistry.shortcuts.values()) {
145
193
  const ownerId = pluginRegistry.getShortcutPluginId(shortcut.id);
146
194
  if (ownerId && disabledPlugins.has(ownerId)) continue;
147
- if (shortcut.key === event.name
148
- && (shortcut.ctrl ?? false) === (event.ctrl ?? false)
149
- && (shortcut.shift ?? false) === (event.shift ?? false)) {
195
+ const chords = resolvePluginShortcutChords(keybindingsConfig, shortcut);
196
+ if (chords.some((chord) => matchesKeyChord(chord, event))) {
150
197
  shortcut.execute();
151
198
  break;
152
199
  }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Every built-in action a key can be bound to, with the chords it ships with.
3
+ * The ids are what `config.json` and the CLI use, so they read as actions, not
4
+ * as the keys they happen to sit on today.
5
+ */
6
+ export type CoreKeybindingActionId =
7
+ | "command-bar"
8
+ | "ticker-search"
9
+ | "help"
10
+ | "switch-layout"
11
+ | "focus-next-pane"
12
+ | "focus-prev-pane"
13
+ | "refresh-ticker"
14
+ | "refresh-all"
15
+ | "install-update"
16
+ | "quit"
17
+ | "pane-close"
18
+ | "close-floating-panes"
19
+ | "pane-settings"
20
+ | "pane-fullscreen"
21
+ | "pane-float"
22
+ | "pane-pop-out"
23
+ | "pane-screenshot"
24
+ | "pane-export-csv"
25
+ | "pane-share"
26
+ | "layout-gallery"
27
+ | "tidy-windows"
28
+ | "window-move-mode"
29
+ | "window-resize-mode";
30
+
31
+ export type KeybindingActionCategory = "Global Keys" | "Pane Management";
32
+
33
+ export interface KeybindingActionDef {
34
+ id: CoreKeybindingActionId;
35
+ category: KeybindingActionCategory;
36
+ /** Help text. Kept in English here; the help pane translates it. */
37
+ description: string;
38
+ defaults: readonly string[];
39
+ /** Only the desktop app has this action; the terminal hides the row. */
40
+ desktopOnly?: boolean;
41
+ }
42
+
43
+ export const PANE_ACTION_IDS = new Set<CoreKeybindingActionId>([
44
+ "pane-close",
45
+ "close-floating-panes",
46
+ "pane-settings",
47
+ "pane-fullscreen",
48
+ "pane-float",
49
+ "pane-pop-out",
50
+ "pane-screenshot",
51
+ "pane-export-csv",
52
+ "pane-share",
53
+ "layout-gallery",
54
+ "tidy-windows",
55
+ "window-move-mode",
56
+ "window-resize-mode",
57
+ ]);
58
+
59
+ /**
60
+ * Order matters twice: it is the order the help pane lists rows in, and the
61
+ * order chords are matched in when two actions share one.
62
+ */
63
+ export const KEYBINDING_ACTIONS: readonly KeybindingActionDef[] = [
64
+ {
65
+ id: "command-bar",
66
+ category: "Global Keys",
67
+ description: "Open the command bar.",
68
+ defaults: ["Ctrl+P", "CmdOrCtrl+K"],
69
+ },
70
+ {
71
+ id: "ticker-search",
72
+ category: "Global Keys",
73
+ description: "Open ticker search directly.",
74
+ // One chord on purpose: the command bar already searches symbols for
75
+ // free text, so a layout without a backtick key still has Ctrl+P and
76
+ // typing. A dedicated key is a rebind away for anyone who wants one.
77
+ defaults: ["`"],
78
+ },
79
+ {
80
+ id: "help",
81
+ category: "Global Keys",
82
+ description: "Open help.",
83
+ defaults: ["?"],
84
+ },
85
+ {
86
+ id: "switch-layout",
87
+ category: "Global Keys",
88
+ description: "Switch saved layouts by number.",
89
+ defaults: ["CmdOrCtrl+Digit"],
90
+ },
91
+ {
92
+ id: "focus-next-pane",
93
+ category: "Global Keys",
94
+ description: "Move focus to the next pane or floating window.",
95
+ defaults: ["Tab"],
96
+ },
97
+ {
98
+ id: "focus-prev-pane",
99
+ category: "Global Keys",
100
+ description: "Move focus to the previous pane or floating window.",
101
+ defaults: ["Shift+Tab"],
102
+ },
103
+ {
104
+ id: "refresh-ticker",
105
+ category: "Global Keys",
106
+ description: "Refresh the focused ticker.",
107
+ defaults: ["R"],
108
+ },
109
+ {
110
+ id: "refresh-all",
111
+ category: "Global Keys",
112
+ description: "Refresh everything.",
113
+ defaults: ["Shift+R"],
114
+ },
115
+ {
116
+ id: "install-update",
117
+ category: "Global Keys",
118
+ description: "Install an available app update when one is shown.",
119
+ defaults: ["U"],
120
+ },
121
+ {
122
+ id: "quit",
123
+ category: "Global Keys",
124
+ description: "Quit the terminal app.",
125
+ defaults: ["Q"],
126
+ },
127
+ {
128
+ id: "pane-close",
129
+ category: "Pane Management",
130
+ description: "Close the focused pane, docked or floating. Locked panes stay open.",
131
+ defaults: ["CmdOrCtrl+W"],
132
+ },
133
+ {
134
+ id: "close-floating-panes",
135
+ category: "Pane Management",
136
+ description: "Close all floating panes except locked ones.",
137
+ defaults: ["CmdOrCtrl+Alt+W"],
138
+ },
139
+ {
140
+ id: "pane-settings",
141
+ category: "Pane Management",
142
+ description: "Edit settings for the focused pane.",
143
+ defaults: ["CmdOrCtrl+,"],
144
+ },
145
+ {
146
+ id: "pane-fullscreen",
147
+ category: "Pane Management",
148
+ description: "Fill the window with the focused pane, or restore it.",
149
+ defaults: ["CmdOrCtrl+Shift+F"],
150
+ },
151
+ {
152
+ id: "pane-float",
153
+ category: "Pane Management",
154
+ description: "Dock or float the focused pane.",
155
+ defaults: ["CmdOrCtrl+Shift+D"],
156
+ },
157
+ {
158
+ id: "pane-pop-out",
159
+ category: "Pane Management",
160
+ description: "Pop the focused pane out to a desktop window.",
161
+ defaults: ["CmdOrCtrl+Shift+O"],
162
+ desktopOnly: true,
163
+ },
164
+ {
165
+ id: "pane-screenshot",
166
+ category: "Pane Management",
167
+ description: "Copy a screenshot of the focused pane.",
168
+ defaults: ["CmdOrCtrl+Shift+C"],
169
+ desktopOnly: true,
170
+ },
171
+ {
172
+ id: "pane-export-csv",
173
+ category: "Pane Management",
174
+ description: "Export the focused pane's table as CSV.",
175
+ defaults: ["CmdOrCtrl+Shift+E"],
176
+ },
177
+ {
178
+ id: "pane-share",
179
+ category: "Pane Management",
180
+ description: "Share the focused pane.",
181
+ defaults: ["CmdOrCtrl+Shift+S"],
182
+ },
183
+ {
184
+ id: "layout-gallery",
185
+ category: "Pane Management",
186
+ description: "Open layout actions.",
187
+ defaults: ["CmdOrCtrl+Shift+L"],
188
+ },
189
+ {
190
+ id: "tidy-windows",
191
+ category: "Pane Management",
192
+ description: "Tidy Windows",
193
+ defaults: ["CmdOrCtrl+Shift+G"],
194
+ },
195
+ {
196
+ id: "window-move-mode",
197
+ category: "Pane Management",
198
+ description: "Enter window move mode.",
199
+ defaults: ["CmdOrCtrl+Shift+M"],
200
+ },
201
+ {
202
+ id: "window-resize-mode",
203
+ category: "Pane Management",
204
+ description: "Enter window resize mode.",
205
+ defaults: ["CmdOrCtrl+Shift+R"],
206
+ },
207
+ ];
208
+
209
+ const ACTION_BY_ID = new Map(KEYBINDING_ACTIONS.map((action) => [action.id, action]));
210
+
211
+ export function getKeybindingAction(id: string): KeybindingActionDef | undefined {
212
+ return ACTION_BY_ID.get(id as CoreKeybindingActionId);
213
+ }
214
+
215
+ export function isCoreKeybindingActionId(id: string): id is CoreKeybindingActionId {
216
+ return ACTION_BY_ID.has(id as CoreKeybindingActionId);
217
+ }
218
+
219
+ export const PLUGIN_ACTION_PREFIX = "plugin:";
220
+
221
+ export function pluginShortcutActionId(shortcutId: string): string {
222
+ return `${PLUGIN_ACTION_PREFIX}${shortcutId}`;
223
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A request to bind a key to command bar text, handed from the command bar to
3
+ * the help pane. The bar cannot capture a key itself: the moment it opens, it
4
+ * owns the keyboard, and the capture has to outlive the bar closing.
5
+ */
6
+ export interface KeybindingCaptureRequest {
7
+ kind: "command";
8
+ query: string;
9
+ }
10
+
11
+ let pending: KeybindingCaptureRequest | null = null;
12
+ const listeners = new Set<() => void>();
13
+
14
+ export function requestKeybindingCapture(request: KeybindingCaptureRequest): void {
15
+ pending = request;
16
+ for (const listener of listeners) listener();
17
+ }
18
+
19
+ /** Whether a request is waiting, for a pane deciding which tab to open on. */
20
+ export function hasKeybindingCaptureRequest(): boolean {
21
+ return pending !== null;
22
+ }
23
+
24
+ /** Returns and clears the pending request, so it is honoured exactly once. */
25
+ export function takeKeybindingCaptureRequest(): KeybindingCaptureRequest | null {
26
+ const request = pending;
27
+ pending = null;
28
+ return request;
29
+ }
30
+
31
+ export function subscribeKeybindingCapture(listener: () => void): () => void {
32
+ listeners.add(listener);
33
+ return () => {
34
+ listeners.delete(listener);
35
+ };
36
+ }