gloomberb 0.9.2 → 0.9.3

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 (111) hide show
  1. package/README.md +23 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app.tsx +10 -7
  5. package/src/capabilities/registry.ts +1 -0
  6. package/src/capabilities/types.ts +23 -0
  7. package/src/cli/commands/api.ts +119 -0
  8. package/src/cli/commands/automation.ts +173 -0
  9. package/src/cli/commands/command-utils.ts +53 -0
  10. package/src/cli/commands/market.ts +527 -0
  11. package/src/cli/commands/overview.ts +222 -0
  12. package/src/cli/commands/search.ts +39 -0
  13. package/src/cli/commands/system.ts +553 -0
  14. package/src/cli/commands/ticker.ts +104 -12
  15. package/src/cli/context.ts +35 -2
  16. package/src/cli/entry.ts +51 -0
  17. package/src/cli/errors.ts +56 -5
  18. package/src/cli/index.ts +73 -11
  19. package/src/cli/options.test.ts +38 -0
  20. package/src/cli/options.ts +95 -0
  21. package/src/cli/pane-functions/index.ts +10 -1
  22. package/src/cli/registry.test.ts +40 -0
  23. package/src/cli/registry.ts +46 -5
  24. package/src/cli/result.test.ts +82 -0
  25. package/src/cli/result.ts +149 -0
  26. package/src/cli/types.ts +11 -0
  27. package/src/cli.test.ts +11 -11
  28. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  29. package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
  30. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  31. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  32. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  33. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  34. package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
  35. package/src/components/chart/core/pane-settings.test.ts +27 -0
  36. package/src/components/chart/core/pane-settings.ts +60 -1
  37. package/src/components/chart/core/pointer.ts +2 -0
  38. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  39. package/src/components/chart/stock-chart/settings.ts +7 -3
  40. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  41. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  42. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  43. package/src/components/data-table/view.tsx +13 -2
  44. package/src/components/feed-data-table/stack-view.tsx +9 -1
  45. package/src/components/index.ts +1 -1
  46. package/src/components/input-search-bar.test.tsx +103 -0
  47. package/src/components/input-search-bar.tsx +16 -0
  48. package/src/components/layout/shell/active-drag.ts +5 -1
  49. package/src/components/layout/shell/drag/runtime.ts +21 -3
  50. package/src/components/layout/shell/fullscreen.ts +10 -0
  51. package/src/components/layout/shell/index.test.tsx +395 -2
  52. package/src/components/layout/shell/index.tsx +128 -2
  53. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  54. package/src/components/layout/shell/pane/layers.tsx +26 -14
  55. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  56. package/src/components/layout/shell/shortcuts.ts +4 -0
  57. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  58. package/src/components/layout/shell/window-mode/index.ts +11 -3
  59. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  60. package/src/components/layout/status-bar.test.tsx +105 -0
  61. package/src/components/layout/status-bar.tsx +32 -6
  62. package/src/components/layout/transient-layout.tsx +57 -0
  63. package/src/components/layout/window-edit/mode.test.ts +76 -5
  64. package/src/components/layout/window-edit/mode.ts +47 -2
  65. package/src/components/layout/window-edit/presentation.ts +2 -1
  66. package/src/components/price-performance.tsx +9 -1
  67. package/src/core/app-services.ts +7 -4
  68. package/src/data/config/store/index.ts +1 -2
  69. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  70. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  71. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  72. package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
  73. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  74. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  75. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  76. package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
  77. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  78. package/src/plugins/builtin/shared/read-state.ts +43 -0
  79. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  80. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  81. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  82. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  83. package/src/plugins/builtin/substack/pane.tsx +8 -3
  84. package/src/plugins/builtin/substack/read-state.ts +55 -0
  85. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  86. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  87. package/src/plugins/pane-manager/docking.ts +9 -2
  88. package/src/plugins/pane-manager/queries.ts +8 -2
  89. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  90. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  91. package/src/plugins/prediction-markets/pane.test.tsx +32 -0
  92. package/src/plugins/prediction-markets/pane.tsx +21 -1
  93. package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
  94. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  95. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  96. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  97. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  98. package/src/renderers/electrobun/bun/index.ts +13 -0
  99. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  100. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  101. package/src/renderers/electrobun/view/app-services.ts +6 -4
  102. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  103. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  104. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  105. package/src/renderers/electrobun/view/ui-host.tsx +1 -1
  106. package/src/renderers/opentui/start.tsx +13 -5
  107. package/src/state/app/input-capture.ts +12 -0
  108. package/src/types/plugin.ts +28 -0
  109. package/src/utils/cli-output.ts +6 -0
  110. package/src/utils/search-focus-navigation.ts +24 -0
  111. package/src/version.ts +1 -1
@@ -0,0 +1,55 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { usePluginState } from "../../runtime";
3
+ import {
4
+ DEFAULT_MAX_READ_IDS,
5
+ markReadId,
6
+ normalizeReadIds,
7
+ sameStringArray,
8
+ } from "../shared/read-state";
9
+
10
+ export interface SubstackReadState {
11
+ articleIds: string[];
12
+ }
13
+
14
+ const SUBSTACK_READ_STATE_SCHEMA_VERSION = 1;
15
+ const READ_STATE_KEY = "read-articles";
16
+ const EMPTY_READ_STATE: SubstackReadState = { articleIds: [] };
17
+
18
+ export const MAX_READ_SUBSTACK_ARTICLE_IDS = DEFAULT_MAX_READ_IDS;
19
+
20
+ export function normalizeSubstackReadState(state: SubstackReadState): SubstackReadState {
21
+ return {
22
+ articleIds: normalizeReadIds(state.articleIds, MAX_READ_SUBSTACK_ARTICLE_IDS),
23
+ };
24
+ }
25
+
26
+ export function markSubstackArticleRead(
27
+ state: SubstackReadState,
28
+ articleId: string,
29
+ ): SubstackReadState {
30
+ const current = normalizeSubstackReadState(state).articleIds;
31
+ const articleIds = markReadId(current, articleId, MAX_READ_SUBSTACK_ARTICLE_IDS);
32
+
33
+ return sameStringArray(articleIds, state.articleIds) ? state : { articleIds };
34
+ }
35
+
36
+ export function useSubstackReadState() {
37
+ const [readState, setReadState] = usePluginState<SubstackReadState>(
38
+ READ_STATE_KEY,
39
+ EMPTY_READ_STATE,
40
+ { schemaVersion: SUBSTACK_READ_STATE_SCHEMA_VERSION },
41
+ );
42
+ const normalizedReadState = useMemo(
43
+ () => normalizeSubstackReadState(readState),
44
+ [readState],
45
+ );
46
+ const readArticleIds = useMemo(
47
+ () => new Set(normalizedReadState.articleIds),
48
+ [normalizedReadState],
49
+ );
50
+ const markArticleRead = useCallback((articleId: string) => {
51
+ setReadState((current) => markSubstackArticleRead(current, articleId));
52
+ }, [setReadState]);
53
+
54
+ return { readArticleIds, markArticleRead };
55
+ }
@@ -224,6 +224,32 @@ describe("ThirteenFPane", () => {
224
224
  ).toBeUndefined();
225
225
  });
226
226
 
227
+ test("moves from the first fund row to search with Up", async () => {
228
+ setHttpFetchTransport(async (url) => {
229
+ const path = new URL(String(url)).pathname;
230
+ if (path.endsWith("/topfunds")) {
231
+ return json([
232
+ { cik: "1", name: "Alpha Capital", period_of_report: "2026-03-31", pnl: null },
233
+ ]);
234
+ }
235
+ return json([]);
236
+ });
237
+
238
+ await act(async () => {
239
+ testSetup = await testRender(<Harness />, { width: 100, height: 20 });
240
+ });
241
+ await renderFrames();
242
+
243
+ await emitKeypress({ name: "up", sequence: "\u001B[A" });
244
+ await act(async () => {
245
+ await testSetup!.mockInput.typeText("BRK");
246
+ await testSetup!.renderOnce();
247
+ });
248
+ await renderFrames(2);
249
+
250
+ expect(testSetup!.captureCharFrame()).toContain("BRK");
251
+ });
252
+
227
253
  test("rapid keyboard navigation activates the current rendered row", async () => {
228
254
  setHttpFetchTransport(async (url) => {
229
255
  const path = new URL(String(url)).pathname;
@@ -15,6 +15,7 @@ import {
15
15
  Tabs,
16
16
  usePaneFooter,
17
17
  type DataTableKeyEvent,
18
+ type DataTableRootKeyContext,
18
19
  } from "../../../components";
19
20
  import { useShortcut } from "../../../react/input";
20
21
  import { usePaneSettingValue } from "../../../state/app/context";
@@ -23,6 +24,7 @@ import { TICKER_RESEARCH_PANE_ID } from "../../../types/config";
23
24
  import type { PaneProps } from "../../../types/plugin";
24
25
  import { isDetailBackNavigationKey } from "../../../utils/back-navigation";
25
26
  import { isPlainKey } from "../../../utils/keyboard";
27
+ import { isPlainArrowUp, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation";
26
28
  import { truncateWithEllipsis } from "../../../utils/text-wrap";
27
29
  import { usePluginPaneState, usePluginTickerActions } from "../../runtime";
28
30
  import { loadBrowserRows, loadFilingPositions, loadFundDetail } from "./data";
@@ -303,6 +305,7 @@ export function ThirteenFPane({ focused, width, height }: PaneProps) {
303
305
  normalizeValue={trimSearchValue}
304
306
  onFocus={focusSearch}
305
307
  onBlur={blurSearch}
308
+ onNavigateDown={blurSearch}
306
309
  onQueryChange={updateQuery}
307
310
  />
308
311
  </Box>
@@ -312,7 +315,15 @@ export function ThirteenFPane({ focused, width, height }: PaneProps) {
312
315
  ? "Loading 13F funds..."
313
316
  : error ?? warning ?? "No 13F funds found.";
314
317
 
315
- const handleRootKeyDown = useCallback((event: DataTableKeyEvent) => {
318
+ const handleRootKeyDown = useCallback((
319
+ event: DataTableKeyEvent,
320
+ context: DataTableRootKeyContext,
321
+ ) => {
322
+ if (context.selectedIndex <= 0 && isPlainArrowUp(event)) {
323
+ stopSearchFocusNavigation(event);
324
+ focusSearch();
325
+ return true;
326
+ }
316
327
  if (event.name === "r") {
317
328
  event.preventDefault?.();
318
329
  event.stopPropagation?.();
@@ -6,6 +6,7 @@ import {
6
6
  findDockLeaf,
7
7
  getNodeAtPath,
8
8
  replaceNodeAtPath,
9
+ type DockGeometryOptions,
9
10
  type DockTarget,
10
11
  type LayoutBounds,
11
12
  } from "./dock-tree";
@@ -116,11 +117,17 @@ export function applyDrop(layout: LayoutConfig, draggedId: string, dropTarget: D
116
117
  return insertRelativeToLeaf(layout, draggedId, dropTarget.targetId, direction);
117
118
  }
118
119
 
119
- export function simulateDrop(layout: LayoutConfig, draggedId: string, dropTarget: DropTarget, bounds: LayoutBounds): LayoutSimulation {
120
+ export function simulateDrop(
121
+ layout: LayoutConfig,
122
+ draggedId: string,
123
+ dropTarget: DropTarget,
124
+ bounds: LayoutBounds,
125
+ options?: DockGeometryOptions,
126
+ ): LayoutSimulation {
120
127
  const nextLayout = applyDrop(layout, draggedId, dropTarget);
121
128
  return {
122
129
  layout: nextLayout,
123
- previewRect: getLeafRect(nextLayout, draggedId, bounds),
130
+ previewRect: getLeafRect(nextLayout, draggedId, bounds, options),
124
131
  };
125
132
  }
126
133
 
@@ -10,6 +10,7 @@ import {
10
10
  countColumnsFromGeometry,
11
11
  findDockLeaf,
12
12
  getDockLeafLayouts,
13
+ type DockGeometryOptions,
13
14
  type LayoutBounds,
14
15
  } from "./dock-tree";
15
16
 
@@ -20,8 +21,13 @@ export interface ResolvedPane {
20
21
  path?: Array<0 | 1>;
21
22
  }
22
23
 
23
- export function getLeafRect(layout: LayoutConfig, instanceId: string, bounds: LayoutBounds): LayoutBounds | null {
24
- return getDockLeafLayouts(layout, bounds).find((entry) => entry.instanceId === instanceId)?.rect ?? null;
24
+ export function getLeafRect(
25
+ layout: LayoutConfig,
26
+ instanceId: string,
27
+ bounds: LayoutBounds,
28
+ options?: DockGeometryOptions,
29
+ ): LayoutBounds | null {
30
+ return getDockLeafLayouts(layout, bounds, options).find((entry) => entry.instanceId === instanceId)?.rect ?? null;
25
31
  }
26
32
 
27
33
  export function resolveDocked(
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { type InputRenderable, type ScrollBoxRenderable } from "../../../ui";
3
3
  import { useViewport } from "../../../react/input";
4
+ import { useAppInputCapture } from "../../../state/app/input-capture";
4
5
  import { usePaneInstance } from "../../../state/app/context";
5
6
  import {
6
7
  useDebouncedPluginPaneState,
@@ -101,6 +102,7 @@ export function usePredictionMarketsController({
101
102
  const [searchFocused, setSearchFocused] = useState(false);
102
103
  const [initialParamsApplied, setInitialParamsApplied] = useState(false);
103
104
  const appViewport = useViewport();
105
+ useAppInputCapture(focused && searchFocused);
104
106
 
105
107
  const scrollRef = useRef<ScrollBoxRenderable>(null);
106
108
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);
@@ -1,6 +1,7 @@
1
1
  import { useCallback, type RefObject } from "react";
2
2
  import { type ScrollBoxRenderable } from "../../../ui";
3
3
  import { useShortcut } from "../../../react/input";
4
+ import { isPlainArrowDown, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation";
4
5
  import { getAdjacentPredictionCategoryId } from "../categories";
5
6
  import { resolvePredictionKeyboardCommand } from "../keyboard";
6
7
  import { getAdjacentPredictionVenueScope } from "../navigation";
@@ -16,7 +17,14 @@ import type {
16
17
  interface PredictionKeyboardEvent {
17
18
  name?: string;
18
19
  sequence?: string;
20
+ ctrl?: boolean;
21
+ meta?: boolean;
22
+ super?: boolean;
23
+ alt?: boolean;
24
+ option?: boolean;
19
25
  shift?: boolean;
26
+ defaultPrevented?: boolean;
27
+ propagationStopped?: boolean;
20
28
  preventDefault?: () => void;
21
29
  stopPropagation?: () => void;
22
30
  }
@@ -62,6 +70,25 @@ export function usePredictionControllerKeyboard({
62
70
  setVenue,
63
71
  toggleWatchlist,
64
72
  }: UsePredictionControllerKeyboardParams) {
73
+ const handleSearchNavigation = useCallback(
74
+ (event: PredictionKeyboardEvent) => {
75
+ if (!focused || !searchFocused) return;
76
+
77
+ if (isPlainArrowDown(event)) {
78
+ stopSearchFocusNavigation(event);
79
+ blurSearch();
80
+ return;
81
+ }
82
+
83
+ if (resolvePredictionKeyboardCommand(event) === "escape") {
84
+ event.stopPropagation?.();
85
+ event.preventDefault?.();
86
+ blurSearch();
87
+ }
88
+ },
89
+ [blurSearch, focused, searchFocused],
90
+ );
91
+
65
92
  const cycleDetailOutcome = useCallback(
66
93
  (direction: "previous" | "next") => {
67
94
  if (detailTab !== "overview" || sortedOutcomeMarkets.length === 0) {
@@ -98,15 +125,11 @@ export function usePredictionControllerKeyboard({
98
125
 
99
126
  const handleKeyboard = useCallback(
100
127
  (event: PredictionKeyboardEvent) => {
128
+ if (event.defaultPrevented || event.propagationStopped) return;
101
129
  if (!focused) return;
102
130
  const command = resolvePredictionKeyboardCommand(event);
103
131
 
104
132
  if (searchFocused) {
105
- if (command === "escape") {
106
- event.stopPropagation?.();
107
- event.preventDefault?.();
108
- blurSearch();
109
- }
110
133
  return;
111
134
  }
112
135
 
@@ -216,5 +239,11 @@ export function usePredictionControllerKeyboard({
216
239
  ],
217
240
  );
218
241
 
242
+ useShortcut(handleSearchNavigation, {
243
+ allowEditable: true,
244
+ enabled: focused && searchFocused,
245
+ phase: "before",
246
+ });
247
+
219
248
  useShortcut(handleKeyboard);
220
249
  }
@@ -265,6 +265,38 @@ describe("prediction markets pane interactions", () => {
265
265
  expect(frame).not.toContain("\u2190 Back");
266
266
  });
267
267
 
268
+ test("moves focus between search and the market table with arrows", async () => {
269
+ installPredictionMarketMocks();
270
+
271
+ testSetup = await testRender(<Harness />, { width: 120, height: 34 });
272
+ await flushFrames(testSetup);
273
+
274
+ await emitKeypress(testSetup, { name: "j", sequence: "j" });
275
+ await flushFrames(testSetup, 1);
276
+
277
+ await emitKeypress(testSetup, { name: "up", sequence: "\u001b[A" });
278
+ await flushFrames(testSetup);
279
+
280
+ let frame = testSetup.captureCharFrame();
281
+ expect(frame).toContain("/ search markets");
282
+
283
+ await emitKeypress(testSetup, { name: "up", sequence: "\u001b[A" });
284
+ await flushFrames(testSetup);
285
+
286
+ frame = testSetup.captureCharFrame();
287
+ expect(frame).toContain("? search markets");
288
+
289
+ await emitKeypress(testSetup, {
290
+ name: "down",
291
+ sequence: "\u001b[B",
292
+ targetEditable: true,
293
+ });
294
+ await flushFrames(testSetup);
295
+
296
+ frame = testSetup.captureCharFrame();
297
+ expect(frame).toContain("/ search markets");
298
+ });
299
+
268
300
  test("supports detail outcome navigation and escape return from the keyboard", async () => {
269
301
  attachPredictionMarketsPersistence(new MemoryPersistence());
270
302
 
@@ -1,6 +1,12 @@
1
1
  import { Box, Input, Text } from "../../ui";
2
2
  import { useCallback, useMemo, useRef } from "react";
3
- import { DataTableStackView, Tabs, usePaneFooter } from "../../components";
3
+ import {
4
+ DataTableStackView,
5
+ Tabs,
6
+ usePaneFooter,
7
+ type DataTableKeyEvent,
8
+ type DataTableRootKeyContext,
9
+ } from "../../components";
4
10
  import { createRowValueCache } from "../../components/ui/row-value-cache";
5
11
  import type { PaneProps } from "../../types/plugin";
6
12
  import { colors } from "../../theme/colors";
@@ -9,6 +15,7 @@ import { usePredictionMarketsController } from "./controller";
9
15
  import { PredictionMarketDetailPane } from "./detail/pane";
10
16
  import { getPredictionColumnValue } from "./metrics";
11
17
  import { BROWSE_TABS, VENUE_TABS } from "./navigation";
18
+ import { isPlainArrowUp, stopSearchFocusNavigation } from "../../utils/search-focus-navigation";
12
19
  import type {
13
20
  PredictionBrowseTab,
14
21
  PredictionCategoryId,
@@ -217,6 +224,18 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
217
224
  watchlistedRowKeys,
218
225
  ]);
219
226
 
227
+ const handleRootKeyDown = useCallback((
228
+ event: DataTableKeyEvent,
229
+ context: DataTableRootKeyContext,
230
+ ) => {
231
+ if (context.selectedIndex <= 0 && isPlainArrowUp(event)) {
232
+ stopSearchFocusNavigation(event);
233
+ controller.actions.focusSearch();
234
+ return true;
235
+ }
236
+ return false;
237
+ }, [controller.actions.focusSearch]);
238
+
220
239
  const detailContent =
221
240
  controller.selectedSummary && controller.selectedRow ? (
222
241
  <Box
@@ -271,6 +290,7 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
271
290
  }}
272
291
  onActivate={(row) =>
273
292
  controller.actions.openSelectedRow(row.key)}
293
+ onRootKeyDown={handleRootKeyDown}
274
294
  columns={controller.visibleColumns}
275
295
  items={controller.visibleRows}
276
296
  sortColumnId={controller.sortPreference.columnId}
@@ -531,6 +531,7 @@ export async function emitKeypress(
531
531
  event: {
532
532
  name?: string;
533
533
  sequence?: string;
534
+ targetEditable?: boolean;
534
535
  shift?: boolean;
535
536
  },
536
537
  ) {
@@ -30,6 +30,15 @@ describe("applicationMenuCommand", () => {
30
30
  })).toEqual({ type: "open-devtools" });
31
31
  });
32
32
 
33
+ test("parses the native quit command", () => {
34
+ expect(applicationMenuCommand({
35
+ data: {
36
+ action: ELECTROBUN_APPLICATION_MENU_ACTION,
37
+ data: { type: "quit" },
38
+ },
39
+ })).toEqual({ type: "quit" });
40
+ });
41
+
33
42
  test("ignores unknown command types", () => {
34
43
  expect(applicationMenuCommand({
35
44
  data: {
@@ -45,6 +45,7 @@ function normalizeApplicationMenuCommand(value: unknown): ElectrobunApplicationM
45
45
  case "layout-redo":
46
46
  case "layout-gridlock":
47
47
  case "open-devtools":
48
+ case "quit":
48
49
  return { type: command.type };
49
50
  default:
50
51
  return null;
@@ -9,4 +9,13 @@ describe("desktop application menu", () => {
9
9
  test("keeps the native application menu on macOS", () => {
10
10
  expect(buildDesktopApplicationMenu("darwin")).toEqual(buildApplicationMenu());
11
11
  });
12
+
13
+ test("routes macOS quit through an explicit command item", () => {
14
+ const appMenu = buildApplicationMenu()[0]?.submenu;
15
+ expect(appMenu?.at(-1)).toMatchObject({
16
+ label: "Quit Gloomberb",
17
+ accelerator: "CmdOrCtrl+Q",
18
+ data: { type: "quit" },
19
+ });
20
+ });
12
21
  });
@@ -6,7 +6,8 @@ const GITHUB_ISSUE_URL = "https://github.com/vincelwt/gloomberb/issues/new/choos
6
6
 
7
7
  export type ElectrobunApplicationMenuCommand =
8
8
  | DesktopApplicationMenuCommand
9
- | { type: "open-devtools" };
9
+ | { type: "open-devtools" }
10
+ | { type: "quit" };
10
11
 
11
12
  function commandItem(
12
13
  label: string,
@@ -38,7 +39,7 @@ export function buildApplicationMenu(): ApplicationMenuItemConfig[] {
38
39
  { role: "hideOthers" },
39
40
  { role: "showAll" },
40
41
  { type: "divider" },
41
- { role: "quit" },
42
+ commandItem("Quit Gloomberb", { type: "quit" }, { accelerator: "CmdOrCtrl+Q" }),
42
43
  ],
43
44
  },
44
45
  {
@@ -281,6 +281,15 @@ function closeAllDetachedWindows(): void {
281
281
  detachedWindowManager.closeAll();
282
282
  }
283
283
 
284
+ function quitDesktopApp(): void {
285
+ closeAllDetachedWindows();
286
+ teardownServices();
287
+ const window = mainWindow;
288
+ mainWindow = null;
289
+ window?.close();
290
+ Utils.quit();
291
+ }
292
+
284
293
  function controlWindowForRpcKey(windowKey: string | undefined, action: DesktopWindowControlAction): boolean {
285
294
  const targetWindow = windowKey === MAIN_WINDOW_RPC_KEY
286
295
  ? mainWindow
@@ -446,6 +455,10 @@ ApplicationMenu.on("application-menu-clicked", (event: unknown) => {
446
455
  openMainWindowDevTools();
447
456
  return;
448
457
  }
458
+ if (command.type === "quit") {
459
+ quitDesktopApp();
460
+ return;
461
+ }
449
462
  if (!isWindowRpcReady(MAIN_WINDOW_RPC_KEY)) return;
450
463
  getWindowRpc(MAIN_WINDOW_RPC_KEY)?.send["application-menu.select"]({ command });
451
464
  });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { detachedRpcKey, focusWindowForRpcKey, MAIN_WINDOW_RPC_KEY, type FocusableElectrobunWindow } from "./focus";
3
+
4
+ describe("Electrobun window focus routing", () => {
5
+ test("uses activate for the main window", () => {
6
+ const calls: string[] = [];
7
+ const mainWindow: FocusableElectrobunWindow = {
8
+ activate: () => calls.push("activate"),
9
+ focus: () => calls.push("focus"),
10
+ };
11
+
12
+ expect(focusWindowForRpcKey(MAIN_WINDOW_RPC_KEY, mainWindow, new Map())).toBe(true);
13
+ expect(calls).toEqual(["activate"]);
14
+ });
15
+
16
+ test("uses activate for detached windows", () => {
17
+ const calls: string[] = [];
18
+ const detachedWindows = new Map<string, FocusableElectrobunWindow>([
19
+ ["pane:1", { activate: () => calls.push("detached") }],
20
+ ]);
21
+
22
+ expect(focusWindowForRpcKey(detachedRpcKey("pane:1"), null, detachedWindows)).toBe(true);
23
+ expect(calls).toEqual(["detached"]);
24
+ });
25
+
26
+ test("falls back to focus for legacy window objects", () => {
27
+ const calls: string[] = [];
28
+ const mainWindow: FocusableElectrobunWindow = {
29
+ focus: () => calls.push("focus"),
30
+ };
31
+
32
+ expect(focusWindowForRpcKey(MAIN_WINDOW_RPC_KEY, mainWindow, new Map())).toBe(true);
33
+ expect(calls).toEqual(["focus"]);
34
+ });
35
+ });
@@ -3,6 +3,7 @@ export const MAIN_WINDOW_RPC_KEY = "main";
3
3
  const DETACHED_WINDOW_RPC_PREFIX = "detached:";
4
4
 
5
5
  export interface FocusableElectrobunWindow {
6
+ activate?: () => void;
6
7
  focus?: () => void;
7
8
  }
8
9
 
@@ -31,6 +32,10 @@ export function focusWindowForRpcKey(
31
32
  detachedWindows: ReadonlyMap<string, FocusableElectrobunWindow>,
32
33
  ): boolean {
33
34
  const window = resolveWindowForRpcKey(rpcKey, mainWindow, detachedWindows);
35
+ if (window?.activate) {
36
+ window.activate();
37
+ return true;
38
+ }
34
39
  if (!window?.focus) return false;
35
40
  window.focus();
36
41
  return true;
@@ -7,7 +7,7 @@ import type { AppServices } from "../../../core/app-services";
7
7
  import type { AppConfig } from "../../../types/config";
8
8
  import { newsProvider } from "../../../capabilities";
9
9
  import { debugLog } from "../../../utils/debug-log";
10
- import { measurePerf } from "../../../utils/perf-marks";
10
+ import { measurePerf, measurePerfAsync } from "../../../utils/perf-marks";
11
11
  import { getRendererBuiltinPlugins } from "../../../plugins/catalog-ui";
12
12
  import { createRemoteAssetDataClient } from "./remote/asset-data-client";
13
13
  import { RemotePersistence } from "./remote/persistence";
@@ -47,10 +47,11 @@ export function createAppServices({ config }: { config: AppConfig }): AppService
47
47
  }));
48
48
 
49
49
  const plugins = getRendererBuiltinPlugins();
50
+ const pluginReadyPromises: Promise<void>[] = [];
50
51
  for (const plugin of plugins) {
51
- measurePerf("startup.services.register-plugin", () => {
52
- void pluginRegistry.register(plugin);
53
- }, { pluginId: plugin.id });
52
+ pluginReadyPromises.push(measurePerfAsync("startup.services.register-plugin", () => (
53
+ pluginRegistry.register(plugin)
54
+ ), { pluginId: plugin.id }));
54
55
  }
55
56
  measurePerf("startup.services.news-start", () => {
56
57
  newsService.start();
@@ -65,6 +66,7 @@ export function createAppServices({ config }: { config: AppConfig }): AppService
65
66
  marketData,
66
67
  pluginRegistry,
67
68
  newsService,
69
+ ready: Promise.all(pluginReadyPromises).then(() => {}),
68
70
  destroy() {
69
71
  setSharedMarketDataCoordinator(null);
70
72
  setSharedNewsService(null);
@@ -0,0 +1,50 @@
1
+ import { renderAsciiText, type AsciiFontName } from "../../../../ui/ascii-font";
2
+
3
+ const WEB_GLOOMBERB_WORDMARK = [
4
+ " ____ _ _ _ ",
5
+ " / ___| | ___ ___ _ __ ___ | |__ ___ _ __| |__ ",
6
+ "| | _| |/ _ \\ / _ \\| '_ ` _ \\| '_ \\ / _ \\ '__| '_ \\ ",
7
+ "| |_| | | (_) | (_) | | | | | | |_) | __/ | | |_) |",
8
+ " \\____|_|\\___/ \\___/|_| |_| |_|_.__/ \\___|_| |_.__/ ",
9
+ ];
10
+
11
+ type WebWordmarkVariant = "legacy" | "compat" | null;
12
+
13
+ function currentDesktopPlatform(): string {
14
+ const navigatorLike = (globalThis as {
15
+ navigator?: {
16
+ platform?: string;
17
+ userAgent?: string;
18
+ userAgentData?: { platform?: string };
19
+ };
20
+ }).navigator;
21
+ return [
22
+ navigatorLike?.userAgentData?.platform,
23
+ navigatorLike?.platform,
24
+ navigatorLike?.userAgent,
25
+ ].filter(Boolean).join(" ");
26
+ }
27
+
28
+ function isMacDesktopPlatform(desktopPlatform?: string): boolean {
29
+ const raw = desktopPlatform?.trim() || currentDesktopPlatform();
30
+ return /(darwin|mac|iphone|ipad|ipod)/i.test(raw);
31
+ }
32
+
33
+ export function webAsciiTextWordmarkVariant(
34
+ text: string,
35
+ font: AsciiFontName = "tiny",
36
+ desktopPlatform?: string,
37
+ ): WebWordmarkVariant {
38
+ if (font !== "wordmark" || text.trim().toLowerCase() !== "gloomberb") return null;
39
+ return isMacDesktopPlatform(desktopPlatform) ? "legacy" : "compat";
40
+ }
41
+
42
+ export function webAsciiTextLines(
43
+ text: string,
44
+ font: AsciiFontName = "tiny",
45
+ desktopPlatform?: string,
46
+ ): string[] {
47
+ return webAsciiTextWordmarkVariant(text, font, desktopPlatform) === "compat"
48
+ ? WEB_GLOOMBERB_WORDMARK
49
+ : renderAsciiText(text, font);
50
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { renderAsciiText } from "../../../../ui/ascii-font";
3
+ import { webAsciiTextLines, webAsciiTextWordmarkVariant } from "./ascii-text";
4
+
5
+ describe("desktop web ASCII text", () => {
6
+ test("keeps the legacy Gloomberb wordmark on macOS", () => {
7
+ expect(webAsciiTextWordmarkVariant("Gloomberb", "wordmark", "darwin")).toBe("legacy");
8
+ expect(webAsciiTextWordmarkVariant("Gloomberb", "wordmark", "MacIntel")).toBe("legacy");
9
+ expect(webAsciiTextLines("Gloomberb", "wordmark", "darwin")).toEqual(
10
+ renderAsciiText("Gloomberb", "wordmark"),
11
+ );
12
+ });
13
+
14
+ test("uses the web-safe Gloomberb wordmark off macOS", () => {
15
+ const lines = webAsciiTextLines("Gloomberb", "wordmark", "win32");
16
+
17
+ expect(webAsciiTextWordmarkVariant("Gloomberb", "wordmark", "win32")).toBe("compat");
18
+ expect(lines).toHaveLength(5);
19
+ expect(lines[0]).toContain("____");
20
+ expect(lines).not.toEqual(renderAsciiText("Gloomberb", "wordmark"));
21
+ });
22
+ });