gloomberb 0.4.0 → 0.5.0

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 (218) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +184 -69
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +296 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -1
  48. package/src/components/layout/header.tsx +5 -4
  49. package/src/components/layout/pane-sizing.ts +6 -0
  50. package/src/components/layout/pane.tsx +5 -1
  51. package/src/components/layout/shell.test.tsx +263 -32
  52. package/src/components/layout/shell.tsx +10 -6
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +62 -2
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +82 -5
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +92 -18
  103. package/src/plugins/builtin/chat-controller.ts +40 -11
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +420 -119
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +33 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/gateway-service.test.ts +22 -0
  166. package/src/plugins/ibkr/gateway-service.ts +6 -0
  167. package/src/plugins/ibkr/index.test.tsx +1 -1
  168. package/src/plugins/ibkr/index.tsx +8 -2
  169. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  170. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  171. package/src/plugins/ibkr/trade-utils.ts +7 -6
  172. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  173. package/src/plugins/plugin-runtime.test.tsx +2 -0
  174. package/src/plugins/plugin-runtime.tsx +10 -0
  175. package/src/plugins/prediction-markets/controller-data.ts +129 -14
  176. package/src/plugins/prediction-markets/controller.ts +1 -1
  177. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  178. package/src/plugins/prediction-markets/index.tsx +5 -1
  179. package/src/plugins/prediction-markets/pane.test.tsx +78 -0
  180. package/src/plugins/prediction-markets/pane.tsx +7 -3
  181. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  182. package/src/plugins/prediction-markets/services/fetch.ts +17 -7
  183. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  184. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  185. package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
  186. package/src/plugins/registry.ts +45 -13
  187. package/src/sources/provider-router.test.ts +3 -2
  188. package/src/sources/sec-edgar.ts +13 -1
  189. package/src/sources/yahoo-finance.ts +75 -1
  190. package/src/state/app-context-selector.test.tsx +17 -1
  191. package/src/state/app-context.test.tsx +24 -0
  192. package/src/state/app-context.tsx +29 -6
  193. package/src/theme/colors.ts +5 -0
  194. package/src/theme/themes.ts +25 -0
  195. package/src/types/config.test.ts +107 -0
  196. package/src/types/config.ts +30 -1
  197. package/src/types/data-provider.ts +13 -0
  198. package/src/types/plugin.ts +35 -1
  199. package/src/updater.test.ts +2 -0
  200. package/src/utils/api-client.ts +12 -0
  201. package/src/utils/format.test.ts +11 -1
  202. package/src/utils/format.ts +86 -8
  203. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  204. package/src/utils/inline-content-tokenizer.ts +76 -0
  205. package/src/utils/link-tokenizer.test.ts +27 -0
  206. package/src/utils/link-tokenizer.ts +93 -0
  207. package/src/utils/market-format.test.ts +109 -0
  208. package/src/utils/market-format.ts +299 -0
  209. package/src/utils/market-status.ts +3 -2
  210. package/src/utils/selection-clipboard.test.ts +43 -0
  211. package/src/utils/selection-clipboard.ts +88 -0
  212. package/src/utils/terminal-input-reset.test.ts +50 -0
  213. package/src/utils/terminal-input-reset.ts +24 -0
  214. package/src/utils/throttled-fetch.test.ts +145 -0
  215. package/src/utils/throttled-fetch.ts +212 -0
  216. package/src/utils/ticker-tokenizer.test.ts +31 -0
  217. package/src/utils/ticker-tokenizer.ts +7 -42
  218. package/src/version.ts +1 -1
@@ -8,6 +8,7 @@ import type { BrokerInstanceConfig, LayoutConfig, PaneInstanceConfig } from "../
8
8
  import type { DataProvider } from "../types/data-provider";
9
9
  import type { TickerFinancials } from "../types/financials";
10
10
  import type {
11
+ AppNotificationRequest,
11
12
  CommandDef,
12
13
  CustomColumnDef,
13
14
  DetailTabDef,
@@ -27,7 +28,7 @@ import type {
27
28
  import type { TickerRecord } from "../types/ticker";
28
29
  import { addPaneFloating, removePane } from "./pane-manager";
29
30
  import { EventBus, type PluginEvents } from "./event-bus";
30
- import { createPaneInstance } from "../types/config";
31
+ import { createPaneInstance, resolvePaneInstance } from "../types/config";
31
32
  import { createPluginPersistence } from "./plugin-persistence";
32
33
  import { debugLog } from "../utils/debug-log";
33
34
  import { PluginRenderProvider, type PluginRuntimeAccess } from "./plugin-runtime";
@@ -104,6 +105,7 @@ export class PluginRegistry implements PluginRuntimeAccess {
104
105
  switchPanelFn: ((panel: "left" | "right") => void) = () => {};
105
106
  switchTabFn: ((tabId: string, paneId?: string) => void) = () => {};
106
107
  openCommandBarFn: ((query?: string) => void) = () => {};
108
+ openPluginCommandWorkflowFn: ((commandId: string) => void) = () => {};
107
109
  openPaneSettingsFn: ((paneId?: string) => void) = () => {};
108
110
  showPaneFn: ((paneId: string) => void) = () => {};
109
111
  createPaneFromTemplateFn: ((templateId: string, options?: PaneTemplateCreateOptions) => void) = () => {};
@@ -111,12 +113,22 @@ export class PluginRegistry implements PluginRuntimeAccess {
111
113
  hidePaneFn: ((paneId: string) => void) = () => {};
112
114
  focusPaneFn: ((paneId: string) => void) = () => {};
113
115
  pinTickerFn: ((symbol: string, options?: { floating?: boolean; paneType?: string }) => void) = () => {};
116
+ navigateTickerFn: ((symbol: string) => void) = () => {};
117
+ pinTicker = (symbol: string, options?: { floating?: boolean; paneType?: string }) => {
118
+ this.pinTickerFn(symbol, options);
119
+ };
120
+ navigateTicker = (symbol: string) => {
121
+ this.navigateTickerFn(symbol);
122
+ };
123
+ openPluginCommandWorkflow = (commandId: string) => {
124
+ this.openPluginCommandWorkflowFn(commandId);
125
+ };
114
126
 
115
127
  getLayoutFn: (() => LayoutConfig) = () => ({ dockRoot: null, instances: [], floating: [] });
116
128
  updateLayoutFn: ((layout: LayoutConfig) => void) = () => {};
117
129
  getTermSizeFn: (() => { width: number; height: number }) = () => ({ width: 120, height: 40 });
118
130
 
119
- showToastFn: ((message: string, options?: { duration?: number; type?: "info" | "success" | "error" }) => void) = () => {};
131
+ notifyFn: ((notification: AppNotificationRequest) => void) = () => {};
120
132
  getPaneRuntimeStateFn: ((paneId: string) => PaneRuntimeState | null) = () => null;
121
133
  updatePaneRuntimeStateFn: ((paneId: string, patch: Partial<PaneRuntimeState>) => void) = () => {};
122
134
  applyPaneSettingValueFn: ((paneId: string, field: import("../types/plugin").PaneSettingField, value: unknown) => Promise<void>) = async () => {};
@@ -177,6 +189,10 @@ export class PluginRegistry implements PluginRuntimeAccess {
177
189
  return this.pluginItems.get(pluginId)?.paneTemplates ?? [];
178
190
  }
179
191
 
192
+ notify(notification: AppNotificationRequest): void {
193
+ this.notifyFn(notification);
194
+ }
195
+
180
196
  private getOrCreatePluginItems(pluginId: string): PluginItems {
181
197
  const existing = this.pluginItems.get(pluginId);
182
198
  if (existing) return existing;
@@ -272,16 +288,8 @@ export class PluginRegistry implements PluginRuntimeAccess {
272
288
  return Object.keys(this.getConfigFn().pluginConfig[pluginId] ?? {}).sort();
273
289
  }
274
290
 
275
- private resolvePrimaryPaneInstanceId(paneId: string): string | undefined {
276
- const layout = this.getLayoutFn();
277
- return layout.instances.find((instance) => instance.paneId === paneId)?.instanceId;
278
- }
279
-
280
291
  private resolvePaneTarget(paneId: string): string | undefined {
281
- const layout = this.getLayoutFn();
282
- const isInstanceId = layout.instances.some((instance) => instance.instanceId === paneId);
283
- if (isInstanceId) return paneId;
284
- return this.resolvePrimaryPaneInstanceId(paneId);
292
+ return resolvePaneInstance(this.getLayoutFn(), paneId)?.instanceId;
285
293
  }
286
294
 
287
295
  resolvePaneSettings(paneId: string): {
@@ -514,7 +522,8 @@ export class PluginRegistry implements PluginRuntimeAccess {
514
522
  createPaneFromTemplate: (templateId, options) => this.createPaneFromTemplateFn(templateId, options),
515
523
  hidePane: (paneId) => this.hidePaneFn(paneId),
516
524
  focusPane: (paneId) => this.focusPaneFn(paneId),
517
- pinTicker: (symbol, options) => this.pinTickerFn(symbol, options),
525
+ pinTicker: this.pinTicker,
526
+ navigateTicker: this.navigateTicker,
518
527
  openPaneSettings: (paneId) => this.openPaneSettingsFn(paneId),
519
528
 
520
529
  on: <K extends keyof PluginEvents>(event: K, handler: (payload: PluginEvents[K]) => void) => {
@@ -526,7 +535,7 @@ export class PluginRegistry implements PluginRuntimeAccess {
526
535
 
527
536
  showWidget: (widgetId) => this.showWidget(widgetId),
528
537
  hideWidget: (widgetId) => this.hideWidget(widgetId),
529
- showToast: (message, options) => this.showToastFn(message, options),
538
+ notify: (notification) => this.notifyFn(notification),
530
539
  };
531
540
  }
532
541
 
@@ -630,4 +639,27 @@ export class PluginRegistry implements PluginRuntimeAccess {
630
639
  this.plugins.delete(pluginId);
631
640
  this.events.emit("plugin:unregistered", { pluginId });
632
641
  }
642
+
643
+ destroy(): void {
644
+ for (const pluginId of [...this.plugins.keys()].reverse()) {
645
+ try {
646
+ this.unregister(pluginId);
647
+ } catch (error) {
648
+ this.registryLog.error("Failed to unregister plugin during registry destroy", {
649
+ pluginId,
650
+ error: error instanceof Error ? error.message : String(error),
651
+ });
652
+ }
653
+ }
654
+ this.pluginResumeListeners.clear();
655
+ if (sharedRegistry === this) {
656
+ sharedRegistry = undefined;
657
+ }
658
+ if (sharedDataProvider === this.dataProvider) {
659
+ sharedDataProvider = undefined;
660
+ }
661
+ if ((globalThis as any).__gloomRegistry === this) {
662
+ delete (globalThis as any).__gloomRegistry;
663
+ }
664
+ }
633
665
  }
@@ -1293,7 +1293,8 @@ describe("ProviderRouter", () => {
1293
1293
  test("drops stale cached cloud quotes while preserving cached cloud fundamentals", () => {
1294
1294
  const dbPath = createTempDbPath("stale-cloud-cached-financials");
1295
1295
  const persistence = new AppPersistence(dbPath);
1296
- const now = Date.parse("2026-04-08T10:55:00Z");
1296
+ const now = Date.now();
1297
+ const previousSession = now - 24 * 60 * 60_000;
1297
1298
 
1298
1299
  persistence.resources.set(
1299
1300
  {
@@ -1313,7 +1314,7 @@ describe("ProviderRouter", () => {
1313
1314
  currency: "EUR",
1314
1315
  change: 28,
1315
1316
  changePercent: 5.6,
1316
- lastUpdated: Date.parse("2026-04-07T19:55:00Z"),
1317
+ lastUpdated: previousSession,
1317
1318
  marketState: "REGULAR",
1318
1319
  exchangeName: "FWB2",
1319
1320
  listingExchangeName: "FWB2",
@@ -507,9 +507,21 @@ export class SecEdgarClient {
507
507
  }
508
508
 
509
509
  async getFilingContent(filing: Pick<SecFilingItem, "primaryDocumentUrl" | "filingUrl" | "form">): Promise<string | null> {
510
- const targetUrl = filing.primaryDocumentUrl || filing.filingUrl;
510
+ let targetUrl = filing.primaryDocumentUrl || filing.filingUrl;
511
511
  if (!targetUrl) return null;
512
512
 
513
+ // SEC ownership forms (3/4/5) have XSL-prefixed primaryDocument paths
514
+ // (e.g., "xslF345X06/wk-form4_xxx.xml") which return rendered HTML.
515
+ // Strip the prefix and return the raw XML for programmatic parsing.
516
+ const ownershipForms = new Set(["3", "4", "5"]);
517
+ if (filing.primaryDocumentUrl && filing.form && ownershipForms.has(filing.form.trim())) {
518
+ const rawUrl = filing.primaryDocumentUrl.replace(/\/xsl[^/]+\//, "/");
519
+ if (rawUrl !== filing.primaryDocumentUrl) {
520
+ const { body } = await this.fetchText(rawUrl);
521
+ return body;
522
+ }
523
+ }
524
+
513
525
  if (filing.primaryDocumentUrl && isPdfDocument("", "", filing.primaryDocumentUrl)) {
514
526
  const alternativeUrl = filing.filingUrl
515
527
  ? await this.findAlternativeHtmlDocumentUrl(filing.filingUrl, filing.primaryDocumentUrl)
@@ -1,5 +1,5 @@
1
1
  import type { Quote, Fundamentals, FinancialStatement, PricePoint, TickerFinancials, MarketState, OptionContract, OptionsChain, CompanyProfile } from "../types/financials";
2
- import type { DataProvider, MarketDataRequestContext, NewsItem, SecFilingItem } from "../types/data-provider";
2
+ import type { DataProvider, EarningsEvent, MarketDataRequestContext, NewsItem, SecFilingItem } from "../types/data-provider";
3
3
  import type { TimeRange } from "../components/chart/chart-types";
4
4
  import {
5
5
  normalizeChartResolutionSupport,
@@ -1065,4 +1065,78 @@ export class YahooFinanceClient implements DataProvider {
1065
1065
  }
1066
1066
  throw lastError || new Error(`No options chain for ${ticker}`);
1067
1067
  }
1068
+
1069
+ async getEarningsCalendar(symbols: string[], _context?: MarketDataRequestContext): Promise<EarningsEvent[]> {
1070
+ const results: EarningsEvent[] = [];
1071
+ const BATCH_SIZE = 5;
1072
+
1073
+ for (let i = 0; i < symbols.length; i += BATCH_SIZE) {
1074
+ if (i > 0) await new Promise((r) => setTimeout(r, 200));
1075
+ const batch = symbols.slice(i, i + BATCH_SIZE);
1076
+
1077
+ const settled = await Promise.allSettled(
1078
+ batch.map(async (symbol) => {
1079
+ const params = new URLSearchParams({ modules: "calendarEvents,earningsTrend,quoteType" });
1080
+ const url = `https://query1.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?${params}`;
1081
+ const data = await this.fetchJsonWithCrumb<{
1082
+ quoteSummary?: {
1083
+ result?: Array<{
1084
+ calendarEvents?: {
1085
+ earnings?: {
1086
+ earningsDate?: Array<{ raw: number }>;
1087
+ earningsAverage?: { raw: number };
1088
+ revenueAverage?: { raw: number };
1089
+ };
1090
+ };
1091
+ earningsTrend?: {
1092
+ trend?: Array<{
1093
+ period: string;
1094
+ earningsEstimate?: { avg?: { raw: number } };
1095
+ revenueEstimate?: { avg?: { raw: number } };
1096
+ }>;
1097
+ };
1098
+ quoteType?: {
1099
+ shortName?: string;
1100
+ longName?: string;
1101
+ };
1102
+ }>;
1103
+ };
1104
+ }>(`earnings ${symbol}`, url);
1105
+
1106
+ const mod = data.quoteSummary?.result?.[0];
1107
+ if (!mod) return null;
1108
+
1109
+ const cal = mod.calendarEvents?.earnings;
1110
+ if (!cal?.earningsDate?.length) return null;
1111
+
1112
+ const earningsDate = new Date(cal.earningsDate[0]!.raw * 1000);
1113
+ if (isNaN(earningsDate.getTime())) return null;
1114
+
1115
+ const currentQtr = mod.earningsTrend?.trend?.find((t) => t.period === "0q");
1116
+ const name = mod.quoteType?.shortName || mod.quoteType?.longName || symbol;
1117
+
1118
+ return {
1119
+ symbol,
1120
+ name,
1121
+ earningsDate,
1122
+ epsEstimate: currentQtr?.earningsEstimate?.avg?.raw ?? cal.earningsAverage?.raw ?? null,
1123
+ epsActual: null,
1124
+ revenueEstimate: currentQtr?.revenueEstimate?.avg?.raw ?? cal.revenueAverage?.raw ?? null,
1125
+ revenueActual: null,
1126
+ surprise: null,
1127
+ timing: "" as const,
1128
+ };
1129
+ }),
1130
+ );
1131
+
1132
+ for (const result of settled) {
1133
+ if (result.status === "fulfilled" && result.value) {
1134
+ results.push(result.value);
1135
+ }
1136
+ }
1137
+ }
1138
+
1139
+ results.sort((a, b) => a.earningsDate.getTime() - b.earningsDate.getTime());
1140
+ return results;
1141
+ }
1068
1142
  }
@@ -3,7 +3,7 @@ import { act, useRef, type Dispatch } from "react";
3
3
  import { testRender } from "@opentui/react/test-utils";
4
4
  import { AppProvider, PaneInstanceProvider, useAppDispatch, useAppSelector, usePaneTicker, type AppAction } from "./app-context";
5
5
  import { cloneLayout, createDefaultConfig, type AppConfig } from "../types/config";
6
- import { getCurrentThemeId } from "../theme/colors";
6
+ import { applyTheme, getCurrentThemeId } from "../theme/colors";
7
7
 
8
8
  const TEST_PANE_ID = "ticker-detail:test";
9
9
 
@@ -51,6 +51,7 @@ describe("pane selectors", () => {
51
51
  testSetup?.renderer.destroy();
52
52
  testSetup = undefined;
53
53
  capturedDispatch = null;
54
+ applyTheme("amber");
54
55
  });
55
56
 
56
57
  test("does not rerender usePaneTicker consumers for unrelated app state updates", async () => {
@@ -96,4 +97,19 @@ describe("pane selectors", () => {
96
97
  await testSetup.renderOnce();
97
98
  expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
98
99
  });
100
+
101
+ test("uses the configured theme on the first provider render", async () => {
102
+ const config = createTickerDetailConfig("AAPL");
103
+ config.theme = "green";
104
+
105
+ testSetup = await testRender(
106
+ <AppProvider config={config}>
107
+ <ThemeSelectorHarness />
108
+ </AppProvider>,
109
+ { width: 32, height: 4 },
110
+ );
111
+
112
+ await testSetup.renderOnce();
113
+ expect(testSetup.captureCharFrame()).toContain(`${TEST_PANE_ID}:green`);
114
+ });
99
115
  });
@@ -22,6 +22,30 @@ describe("appReducer command bar state", () => {
22
22
  expect(next.commandBarQuery).toBe("");
23
23
  });
24
24
 
25
+ test("stores and clears command bar launch requests", () => {
26
+ const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
27
+ const opened = appReducer(initial, {
28
+ type: "SET_COMMAND_BAR",
29
+ open: true,
30
+ launch: { kind: "plugin-command", commandId: "set-alert" },
31
+ });
32
+ const relaunched = appReducer(opened, {
33
+ type: "SET_COMMAND_BAR",
34
+ open: true,
35
+ launch: { kind: "plugin-command", commandId: "set-alert" },
36
+ });
37
+ const closed = appReducer(relaunched, { type: "SET_COMMAND_BAR", open: false });
38
+
39
+ expect(opened.commandBarOpen).toBe(true);
40
+ expect(opened.commandBarLaunchRequest).toEqual({
41
+ kind: "plugin-command",
42
+ commandId: "set-alert",
43
+ sequence: 1,
44
+ });
45
+ expect(relaunched.commandBarLaunchRequest?.sequence).toBe(2);
46
+ expect(closed.commandBarLaunchRequest).toBeNull();
47
+ });
48
+
25
49
  test("toggle opens blank and clears on close", () => {
26
50
  const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
27
51
  const opened = appReducer(initial, { type: "TOGGLE_COMMAND_BAR" });
@@ -13,7 +13,7 @@ import {
13
13
  } from "react";
14
14
  import type { SessionStore } from "../data/session-store";
15
15
  import { saveConfig } from "../data/config-store";
16
- import { applyTheme } from "../theme/colors";
16
+ import { applyTheme, syncTheme } from "../theme/colors";
17
17
  import { isBrokerPortfolioId } from "../utils/broker-instances";
18
18
  import { resolveTickerFinancialsQuoteState } from "../utils/quote-resolution";
19
19
  import {
@@ -61,6 +61,12 @@ export interface CollectionSortPreference {
61
61
  direction: SortDirection;
62
62
  }
63
63
 
64
+ export interface CommandBarLaunchRequest {
65
+ kind: "plugin-command";
66
+ commandId: string;
67
+ sequence: number;
68
+ }
69
+
64
70
  export interface AppState {
65
71
  config: AppConfig;
66
72
  tickers: Map<string, TickerRecord>;
@@ -73,6 +79,7 @@ export interface AppState {
73
79
  recentTickers: string[];
74
80
  commandBarOpen: boolean;
75
81
  commandBarQuery: string;
82
+ commandBarLaunchRequest: CommandBarLaunchRequest | null;
76
83
  refreshing: Set<string>;
77
84
  initialized: boolean;
78
85
  statusBarVisible: boolean;
@@ -97,7 +104,12 @@ export type AppAction =
97
104
  | { type: "TRACK_TICKER"; symbol: string | null }
98
105
  | { type: "SET_ACTIVE_PANEL"; panel: "left" | "right" }
99
106
  | { type: "TOGGLE_COMMAND_BAR" }
100
- | { type: "SET_COMMAND_BAR"; open: boolean; query?: string }
107
+ | {
108
+ type: "SET_COMMAND_BAR";
109
+ open: boolean;
110
+ query?: string;
111
+ launch?: { kind: "plugin-command"; commandId: string } | null;
112
+ }
101
113
  | { type: "SET_COMMAND_BAR_QUERY"; query: string }
102
114
  | { type: "SET_REFRESHING"; symbol: string; refreshing: boolean }
103
115
  | { type: "SET_BROKER_ACCOUNTS"; instanceId: string; accounts: BrokerAccount[] }
@@ -492,15 +504,23 @@ export function appReducer(state: AppState, action: AppAction): AppState {
492
504
 
493
505
  case "TOGGLE_COMMAND_BAR":
494
506
  return state.commandBarOpen
495
- ? { ...state, commandBarOpen: false, commandBarQuery: "" }
496
- : { ...state, commandBarOpen: true, commandBarQuery: "" };
497
-
498
- case "SET_COMMAND_BAR":
507
+ ? { ...state, commandBarOpen: false, commandBarQuery: "", commandBarLaunchRequest: null }
508
+ : { ...state, commandBarOpen: true, commandBarQuery: "", commandBarLaunchRequest: null };
509
+
510
+ case "SET_COMMAND_BAR": {
511
+ const launchRequest = action.open && action.launch
512
+ ? {
513
+ ...action.launch,
514
+ sequence: (state.commandBarLaunchRequest?.sequence ?? 0) + 1,
515
+ }
516
+ : null;
499
517
  return {
500
518
  ...state,
501
519
  commandBarOpen: action.open,
502
520
  commandBarQuery: action.open ? (action.query ?? "") : "",
521
+ commandBarLaunchRequest: launchRequest,
503
522
  };
523
+ }
504
524
 
505
525
  case "SET_COMMAND_BAR_QUERY":
506
526
  return { ...state, commandBarQuery: action.query };
@@ -1018,6 +1038,7 @@ export function createInitialState(config: AppConfig, sessionSnapshot: AppSessio
1018
1038
  recentTickers: config.recentTickers,
1019
1039
  commandBarOpen: false,
1020
1040
  commandBarQuery: "",
1041
+ commandBarLaunchRequest: null,
1021
1042
  refreshing: new Set(),
1022
1043
  initialized: false,
1023
1044
  statusBarVisible: sessionSnapshot?.statusBarVisible !== false,
@@ -1043,6 +1064,8 @@ export function AppProvider({
1043
1064
  sessionStore?: SessionStore;
1044
1065
  sessionSnapshot?: AppSessionSnapshot | null;
1045
1066
  }) {
1067
+ // Sync the configured theme before descendants read the shared palette on first render.
1068
+ syncTheme(config.theme);
1046
1069
  const [state, dispatch] = useReducer(appReducer, { config, sessionSnapshot }, ({ config, sessionSnapshot: initialSessionSnapshot }) => (
1047
1070
  createInitialState(config, initialSessionSnapshot)
1048
1071
  ));
@@ -18,6 +18,11 @@ export function applyTheme(id: string): void {
18
18
  Object.assign(colors, theme);
19
19
  }
20
20
 
21
+ export function syncTheme(id: string): void {
22
+ if (currentThemeId === id) return;
23
+ applyTheme(id);
24
+ }
25
+
21
26
  export type ColorKey = keyof typeof colors;
22
27
 
23
28
  export { blendHex } from "./color-utils";
@@ -17,6 +17,7 @@ export interface Theme {
17
17
  positive: string;
18
18
  negative: string;
19
19
  neutral: string;
20
+ warning: string;
20
21
 
21
22
  header: string;
22
23
  headerText: string;
@@ -79,6 +80,12 @@ function normalizeTheme(theme: Theme): Theme {
79
80
  highestMinimumContrast(bodySurfaces, [textDim, text, "#c0c7d1"]),
80
81
  SUBTLE_TEXT_MIN,
81
82
  );
83
+ const warning = blendForContrastOnSurfaces(
84
+ theme.warning,
85
+ bodySurfaces,
86
+ blendHex(theme.warning, theme.textBright, 0.5),
87
+ SUBTLE_TEXT_MIN,
88
+ );
82
89
  const selected = blendForContrastOnSurfaces(
83
90
  theme.selected,
84
91
  bodySurfaces,
@@ -106,6 +113,7 @@ function normalizeTheme(theme: Theme): Theme {
106
113
  positive,
107
114
  negative,
108
115
  neutral,
116
+ warning,
109
117
  headerText,
110
118
  selected,
111
119
  selectedText,
@@ -127,6 +135,7 @@ const rawThemes: Record<string, Theme> = {
127
135
  positive: "#00cc66",
128
136
  negative: "#ff3333",
129
137
  neutral: "#888888",
138
+ warning: "#ffaa00",
130
139
  header: "#0044aa",
131
140
  headerText: "#ffffff",
132
141
  selected: "#1a3a5c",
@@ -149,6 +158,7 @@ const rawThemes: Record<string, Theme> = {
149
158
  positive: "#00ff66",
150
159
  negative: "#ff4444",
151
160
  neutral: "#666666",
161
+ warning: "#cccc00",
152
162
  header: "#003300",
153
163
  headerText: "#00ff00",
154
164
  selected: "#003300",
@@ -171,6 +181,7 @@ const rawThemes: Record<string, Theme> = {
171
181
  positive: "#00ff88",
172
182
  negative: "#ff4444",
173
183
  neutral: "#666666",
184
+ warning: "#ddcc00",
174
185
  header: "#002222",
175
186
  headerText: "#00ffff",
176
187
  selected: "#002a2a",
@@ -193,6 +204,7 @@ const rawThemes: Record<string, Theme> = {
193
204
  positive: "#44cc44",
194
205
  negative: "#ff6666",
195
206
  neutral: "#666666",
207
+ warning: "#ddaa00",
196
208
  header: "#220000",
197
209
  headerText: "#ff3333",
198
210
  selected: "#2a0000",
@@ -215,6 +227,7 @@ const rawThemes: Record<string, Theme> = {
215
227
  positive: "#00cc66",
216
228
  negative: "#ff4444",
217
229
  neutral: "#666666",
230
+ warning: "#ddaa00",
218
231
  header: "#000033",
219
232
  headerText: "#4488ff",
220
233
  selected: "#001144",
@@ -237,6 +250,7 @@ const rawThemes: Record<string, Theme> = {
237
250
  positive: "#44dd66",
238
251
  negative: "#ff4444",
239
252
  neutral: "#666666",
253
+ warning: "#ddaa00",
240
254
  header: "#1a0028",
241
255
  headerText: "#bb55ff",
242
256
  selected: "#220033",
@@ -259,6 +273,7 @@ const rawThemes: Record<string, Theme> = {
259
273
  positive: "#44dd66",
260
274
  negative: "#ff5555",
261
275
  neutral: "#666666",
276
+ warning: "#ddaa00",
262
277
  header: "#22000f",
263
278
  headerText: "#ff44aa",
264
279
  selected: "#2a0018",
@@ -281,6 +296,7 @@ const rawThemes: Record<string, Theme> = {
281
296
  positive: "#00cc66",
282
297
  negative: "#ff3333",
283
298
  neutral: "#888888",
299
+ warning: "#ddaa00",
284
300
  header: "#1a1a1a",
285
301
  headerText: "#ffffff",
286
302
  selected: "#222222",
@@ -303,6 +319,7 @@ const rawThemes: Record<string, Theme> = {
303
319
  positive: "#9ece6a",
304
320
  negative: "#f7768e",
305
321
  neutral: "#565f89",
322
+ warning: "#e0af68",
306
323
  header: "#24283b",
307
324
  headerText: "#7aa2f7",
308
325
  selected: "#283457",
@@ -325,6 +342,7 @@ const rawThemes: Record<string, Theme> = {
325
342
  positive: "#859900",
326
343
  negative: "#dc322f",
327
344
  neutral: "#657b83",
345
+ warning: "#b58900",
328
346
  header: "#073642",
329
347
  headerText: "#268bd2",
330
348
  selected: "#0a4a5c",
@@ -347,6 +365,7 @@ const rawThemes: Record<string, Theme> = {
347
365
  positive: "#50fa7b",
348
366
  negative: "#ff5555",
349
367
  neutral: "#6272a4",
368
+ warning: "#f1fa8c",
350
369
  header: "#44475a",
351
370
  headerText: "#bd93f9",
352
371
  selected: "#44475a",
@@ -369,6 +388,7 @@ const rawThemes: Record<string, Theme> = {
369
388
  positive: "#a3be8c",
370
389
  negative: "#bf616a",
371
390
  neutral: "#4c566a",
391
+ warning: "#ebcb8b",
372
392
  header: "#3b4252",
373
393
  headerText: "#88c0d0",
374
394
  selected: "#4c566a",
@@ -391,6 +411,7 @@ const rawThemes: Record<string, Theme> = {
391
411
  positive: "#a6e22e",
392
412
  negative: "#f92672",
393
413
  neutral: "#75715e",
414
+ warning: "#e6db74",
394
415
  header: "#49483e",
395
416
  headerText: "#e6db74",
396
417
  selected: "#49483e",
@@ -413,6 +434,7 @@ const rawThemes: Record<string, Theme> = {
413
434
  positive: "#a6e3a1",
414
435
  negative: "#f38ba8",
415
436
  neutral: "#6c7086",
437
+ warning: "#f9e2af",
416
438
  header: "#313244",
417
439
  headerText: "#cba6f7",
418
440
  selected: "#3e3f56",
@@ -435,6 +457,7 @@ const rawThemes: Record<string, Theme> = {
435
457
  positive: "#b8bb26",
436
458
  negative: "#fb4934",
437
459
  neutral: "#928374",
460
+ warning: "#fabd2f",
438
461
  header: "#3c3836",
439
462
  headerText: "#fabd2f",
440
463
  selected: "#3c3836",
@@ -457,6 +480,7 @@ const rawThemes: Record<string, Theme> = {
457
480
  positive: "#31748f",
458
481
  negative: "#eb6f92",
459
482
  neutral: "#6e6a86",
483
+ warning: "#f6c177",
460
484
  header: "#26233a",
461
485
  headerText: "#c4a7e7",
462
486
  selected: "#332f4a",
@@ -479,6 +503,7 @@ const rawThemes: Record<string, Theme> = {
479
503
  positive: "#00cc66",
480
504
  negative: "#ff3333",
481
505
  neutral: "#6688bb",
506
+ warning: "#ff9900",
482
507
  header: "#001144",
483
508
  headerText: "#ff6600",
484
509
  selected: "#002255",
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createPaneInstance,
4
+ findPrimaryPaneInstance,
5
+ resolveFollowBindingInstance,
6
+ resolvePaneInstance,
7
+ type LayoutConfig,
8
+ type PaneInstanceConfig,
9
+ } from "./config";
10
+
11
+ function createLayout(instances: PaneInstanceConfig[]): LayoutConfig {
12
+ return {
13
+ dockRoot: null,
14
+ instances,
15
+ floating: [],
16
+ };
17
+ }
18
+
19
+ describe("findPrimaryPaneInstance", () => {
20
+ test("prefers the main non-fixed ticker pane", () => {
21
+ const layout = createLayout([
22
+ createPaneInstance("portfolio-list", {
23
+ instanceId: "portfolio-list:main",
24
+ binding: { kind: "none" },
25
+ }),
26
+ createPaneInstance("ticker-detail", {
27
+ instanceId: "ticker-detail:pinned",
28
+ binding: { kind: "fixed", symbol: "AAPL" },
29
+ }),
30
+ createPaneInstance("ticker-detail", {
31
+ instanceId: "ticker-detail:main",
32
+ binding: { kind: "follow", sourceInstanceId: "portfolio-list:main" },
33
+ }),
34
+ ]);
35
+
36
+ expect(findPrimaryPaneInstance(layout, "ticker-detail")?.instanceId).toBe("ticker-detail:main");
37
+ });
38
+
39
+ test("does not treat fixed ticker panes as the primary pane", () => {
40
+ const layout = createLayout([
41
+ createPaneInstance("ticker-detail", {
42
+ instanceId: "ticker-detail:aapl",
43
+ binding: { kind: "fixed", symbol: "AAPL" },
44
+ }),
45
+ createPaneInstance("ticker-detail", {
46
+ instanceId: "ticker-detail:msft",
47
+ binding: { kind: "fixed", symbol: "MSFT" },
48
+ }),
49
+ ]);
50
+
51
+ expect(findPrimaryPaneInstance(layout, "ticker-detail")).toBeUndefined();
52
+ });
53
+ });
54
+
55
+ describe("resolvePaneInstance", () => {
56
+ test("accepts either an instance id or a pane id", () => {
57
+ const layout = createLayout([
58
+ createPaneInstance("portfolio-list", {
59
+ instanceId: "portfolio-list:main",
60
+ binding: { kind: "none" },
61
+ }),
62
+ ]);
63
+
64
+ expect(resolvePaneInstance(layout, "portfolio-list:main")?.instanceId).toBe("portfolio-list:main");
65
+ expect(resolvePaneInstance(layout, "portfolio-list")?.instanceId).toBe("portfolio-list:main");
66
+ });
67
+ });
68
+
69
+ describe("resolveFollowBindingInstance", () => {
70
+ test("walks follow bindings until it finds a matching pane", () => {
71
+ const layout = createLayout([
72
+ createPaneInstance("portfolio-list", {
73
+ instanceId: "portfolio-list:main",
74
+ binding: { kind: "none" },
75
+ }),
76
+ createPaneInstance("ticker-detail", {
77
+ instanceId: "ticker-detail:main",
78
+ binding: { kind: "follow", sourceInstanceId: "portfolio-list:main" },
79
+ }),
80
+ createPaneInstance("quote-monitor", {
81
+ instanceId: "quote-monitor:main",
82
+ binding: { kind: "follow", sourceInstanceId: "ticker-detail:main" },
83
+ }),
84
+ ]);
85
+
86
+ expect(
87
+ resolveFollowBindingInstance(layout, "quote-monitor:main", (instance) => instance.paneId === "portfolio-list")?.instanceId,
88
+ ).toBe("portfolio-list:main");
89
+ });
90
+
91
+ test("stops on follow cycles", () => {
92
+ const layout = createLayout([
93
+ createPaneInstance("ticker-detail", {
94
+ instanceId: "ticker-detail:first",
95
+ binding: { kind: "follow", sourceInstanceId: "quote-monitor:first" },
96
+ }),
97
+ createPaneInstance("quote-monitor", {
98
+ instanceId: "quote-monitor:first",
99
+ binding: { kind: "follow", sourceInstanceId: "ticker-detail:first" },
100
+ }),
101
+ ]);
102
+
103
+ expect(
104
+ resolveFollowBindingInstance(layout, "ticker-detail:first", (instance) => instance.paneId === "portfolio-list"),
105
+ ).toBeUndefined();
106
+ });
107
+ });