gloomberb 0.9.7 → 0.9.9

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 (276) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.test.ts +3 -3
  4. package/src/api-client/index.ts +48 -6
  5. package/src/api-client/request.test.ts +63 -0
  6. package/src/api-client/request.ts +53 -5
  7. package/src/api-client/types.ts +45 -2
  8. package/src/app/pane-runtime/layout-placement.ts +0 -1
  9. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  10. package/src/app/runtime/desktop-deeplink.ts +496 -0
  11. package/src/app.tsx +34 -0
  12. package/src/brokers/account-cache.ts +2 -10
  13. package/src/brokers/broker-portfolio-sync.ts +0 -1
  14. package/src/brokers/broker-ticker-sync.ts +20 -16
  15. package/src/brokers/remote-broker-adapter.ts +3 -0
  16. package/src/capabilities/registry.test.ts +25 -0
  17. package/src/capabilities/registry.ts +22 -5
  18. package/src/cli/commands/automation.ts +1 -3
  19. package/src/cli/commands/command-utils.ts +0 -7
  20. package/src/cli/commands/overview.ts +2 -2
  21. package/src/cli/commands/plugins.ts +34 -17
  22. package/src/cli/index.ts +2 -2
  23. package/src/cli.test.ts +0 -157
  24. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  25. package/src/components/chart/bar-chart-renderer.ts +111 -55
  26. package/src/components/chart/comparison/renderer.ts +0 -1
  27. package/src/components/chart/comparison-stock-chart/render-output.ts +3 -8
  28. package/src/components/chart/comparison-stock-chart/view.tsx +0 -2
  29. package/src/components/chart/core/index.test.ts +0 -24
  30. package/src/components/chart/core/renderer.ts +0 -4
  31. package/src/components/chart/core/resolution.test.ts +0 -17
  32. package/src/components/chart/core/resolution.ts +0 -26
  33. package/src/components/chart/core/scroll.test.ts +0 -6
  34. package/src/components/chart/core/scroll.ts +0 -22
  35. package/src/components/chart/core/viewport.test.ts +2 -23
  36. package/src/components/chart/core/viewport.ts +0 -27
  37. package/src/components/chart/market-session.ts +1 -1
  38. package/src/components/chart/native/surface/hooks.ts +10 -3
  39. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  40. package/src/components/chart/stock-chart/data-runtime.ts +0 -1
  41. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  42. package/src/components/chart/stock-chart/footer.ts +68 -13
  43. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  44. package/src/components/chart/stock-chart/viewport/effects.ts +0 -3
  45. package/src/components/command-bar/helpers.test.ts +1 -80
  46. package/src/components/command-bar/helpers.ts +1 -36
  47. package/src/components/command-bar/pane-settings.ts +1 -1
  48. package/src/components/command-bar/pane-templates/items.ts +11 -3
  49. package/src/components/command-bar/panel/runtime.ts +0 -2
  50. package/src/components/command-bar/routes/root/results.ts +1 -1
  51. package/src/components/command-bar/routes/ticker-search/results.ts +0 -11
  52. package/src/components/command-bar/surface/index.test.tsx +52 -16
  53. package/src/components/command-bar/surface/index.tsx +0 -1
  54. package/src/components/command-bar/workflow/ops.ts +81 -20
  55. package/src/components/feed-data-table/stack-view.test.tsx +36 -130
  56. package/src/components/feed-data-table/stack-view.tsx +0 -1
  57. package/src/components/index.ts +1 -0
  58. package/src/components/layout/shell/index.test.tsx +2 -113
  59. package/src/components/layout/shell/index.tsx +0 -1
  60. package/src/components/layout/shell/menu.ts +0 -1
  61. package/src/components/layout/shell/terminal-pointer-runtime.ts +0 -1
  62. package/src/components/onboarding/onboarding-wizard.tsx +3 -3
  63. package/src/components/onboarding/wizard-keyboard.ts +2 -23
  64. package/src/components/price-selector-dialog.tsx +3 -3
  65. package/src/components/ticker/badge/text.tsx +0 -1
  66. package/src/components/toggle-list.tsx +29 -73
  67. package/src/components/ui/button.tsx +4 -1
  68. package/src/components/ui/checkbox.tsx +76 -0
  69. package/src/components/ui/choice-dialog.tsx +15 -3
  70. package/src/components/ui/frame.tsx +1 -1
  71. package/src/components/ui/index.ts +2 -0
  72. package/src/components/ui/message-composer.tsx +4 -0
  73. package/src/components/ui/native-select.tsx +4 -2
  74. package/src/components/ui/page-stack-view.tsx +1 -1
  75. package/src/components/ui/ui.test.tsx +0 -78
  76. package/src/core/state/app/layout-reducer.ts +0 -1
  77. package/src/data/config/layout.ts +3 -3
  78. package/src/data/resource-store.ts +6 -0
  79. package/src/data/sqlite/cache.test.ts +5 -0
  80. package/src/data/ticker-store.ts +0 -7
  81. package/src/market-data/coordinator/auxiliary.ts +1 -44
  82. package/src/market-data/coordinator/entries.ts +0 -1
  83. package/src/market-data/coordinator/events.test.ts +49 -0
  84. package/src/market-data/coordinator/events.ts +25 -4
  85. package/src/market-data/coordinator/index.test.ts +223 -10
  86. package/src/market-data/coordinator/index.ts +41 -21
  87. package/src/market-data/coordinator/quotes.ts +13 -11
  88. package/src/market-data/coordinator/subscriptions.test.ts +1 -0
  89. package/src/market-data/hooks.tsx +17 -13
  90. package/src/market-data/market/freshness.ts +58 -1
  91. package/src/market-data/market/status.ts +19 -1
  92. package/src/market-data/query-store.ts +0 -4
  93. package/src/market-data/quotes/freshness.test.ts +49 -1
  94. package/src/market-data/quotes/freshness.ts +8 -0
  95. package/src/market-data/quotes/resolution.test.ts +185 -0
  96. package/src/market-data/quotes/resolution.ts +96 -10
  97. package/src/market-data/request-types.test.ts +61 -1
  98. package/src/market-data/request-types.ts +42 -8
  99. package/src/market-data/result-types.ts +0 -11
  100. package/src/market-data/selectors.ts +1 -5
  101. package/src/news/aggregator.ts +5 -1
  102. package/src/pane-settings.test.ts +0 -18
  103. package/src/plugins/builtin/account-management/footer.ts +2 -5
  104. package/src/plugins/builtin/account-management/form-components.tsx +175 -59
  105. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  106. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  107. package/src/plugins/builtin/account-management/model.ts +259 -23
  108. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  109. package/src/plugins/builtin/broker-manager/index.test.tsx +0 -32
  110. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  111. package/src/plugins/builtin/buildout/detail/satellite-images.tsx +1 -1
  112. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -1
  113. package/src/plugins/builtin/buildout/format.ts +2 -11
  114. package/src/plugins/builtin/chat/chat.test.tsx +127 -1
  115. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  116. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  117. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  118. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  119. package/src/plugins/builtin/chat/content/index.tsx +153 -16
  120. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  121. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  122. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  123. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  124. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  125. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  126. package/src/plugins/builtin/chat/content/transcript.tsx +0 -5
  127. package/src/plugins/builtin/chat/controller/index.ts +0 -10
  128. package/src/plugins/builtin/chat/controller.test.ts +5 -5
  129. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  130. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  131. package/src/plugins/builtin/chat/sidebar.test.tsx +83 -6
  132. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  133. package/src/plugins/builtin/chat/test-harness.tsx +22 -12
  134. package/src/plugins/builtin/cloud/plugin.tsx +8 -1
  135. package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -1
  136. package/src/plugins/builtin/congress-trades/model.ts +2 -7
  137. package/src/plugins/builtin/congress-trades/pane.tsx +0 -1
  138. package/src/plugins/builtin/correlation/index.tsx +4 -1
  139. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  140. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  141. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  142. package/src/plugins/builtin/earnings/index.tsx +0 -9
  143. package/src/plugins/builtin/econ/index.tsx +1 -5
  144. package/src/plugins/builtin/help/index.tsx +2 -2
  145. package/src/plugins/builtin/holders/pane.tsx +0 -5
  146. package/src/plugins/builtin/insider/index.tsx +1 -8
  147. package/src/plugins/builtin/kelly-sizer/index.test.tsx +0 -26
  148. package/src/plugins/builtin/kelly-sizer/index.tsx +0 -2
  149. package/src/plugins/builtin/market-movers/index.tsx +0 -2
  150. package/src/plugins/builtin/news/index.tsx +0 -1
  151. package/src/plugins/builtin/news/wire/news/table.tsx +0 -11
  152. package/src/plugins/builtin/options/view.test.tsx +1 -38
  153. package/src/plugins/builtin/plugin-groups.ts +17 -3
  154. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -8
  155. package/src/plugins/builtin/portfolio-list/index.test.ts +52 -25
  156. package/src/plugins/builtin/portfolio-list/metrics.test.ts +44 -7
  157. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  158. package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
  159. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
  160. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
  161. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
  162. package/src/plugins/builtin/sec/filing-documents.ts +0 -11
  163. package/src/plugins/builtin/sec/index.tsx +0 -2
  164. package/src/plugins/builtin/substack/article-detail.tsx +1 -1
  165. package/src/plugins/builtin/thirteenf/api.ts +0 -15
  166. package/src/plugins/builtin/thirteenf/pane.tsx +1 -3
  167. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +42 -6
  168. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  169. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  170. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  171. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  172. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  173. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +0 -7
  174. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  175. package/src/plugins/builtin/ticker-detail/quote-monitor/index.test.tsx +5 -34
  176. package/src/plugins/builtin/world-indices/index.tsx +0 -4
  177. package/src/plugins/builtin/world-indices/model.ts +0 -7
  178. package/src/plugins/ibkr/account-cache.ts +1 -1
  179. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  180. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  181. package/src/plugins/ibkr/portfolio-performance.ts +1 -1
  182. package/src/plugins/ibkr/trade/utils.ts +1 -7
  183. package/src/plugins/loader.ts +2 -1
  184. package/src/plugins/prediction-markets/detail.test.tsx +0 -34
  185. package/src/plugins/registry/context.ts +15 -0
  186. package/src/plugins/registry/contributions.ts +22 -11
  187. package/src/plugins/registry/index.test.ts +44 -0
  188. package/src/plugins/registry/index.ts +75 -26
  189. package/src/remote/controller.ts +1 -7
  190. package/src/remote/json-patch.ts +15 -1
  191. package/src/remote/server.ts +16 -6
  192. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
  193. package/src/renderers/electrobun/bun/core-capabilities.ts +2 -0
  194. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +14 -0
  195. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +8 -0
  196. package/src/renderers/electrobun/bun/desktop/initialization.ts +2 -1
  197. package/src/renderers/electrobun/bun/index.ts +42 -0
  198. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  199. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  200. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  201. package/src/renderers/electrobun/view/data-table/dom.ts +0 -1
  202. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  203. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  204. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  205. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  206. package/src/renderers/electrobun/view/host/mouse.ts +0 -11
  207. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  208. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  209. package/src/renderers/electrobun/view/http-fetch.ts +15 -2
  210. package/src/renderers/electrobun/view/main.tsx +3 -0
  211. package/src/renderers/electrobun/view/remote/asset-data-client.ts +9 -18
  212. package/src/renderers/electrobun/view/remote/capability-invoker.test.ts +50 -0
  213. package/src/renderers/electrobun/view/remote/capability-invoker.ts +36 -0
  214. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  215. package/src/renderers/opentui/host.tsx +11 -3
  216. package/src/renderers/opentui/input-host.tsx +5 -1
  217. package/src/sources/gloomberb-cloud/index.test.ts +0 -72
  218. package/src/sources/gloomberb-cloud/index.ts +1 -30
  219. package/src/sources/gloomberb-cloud/news.ts +0 -12
  220. package/src/sources/provider-router/batches.ts +11 -3
  221. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  222. package/src/sources/provider-router/financial-routes.ts +77 -5
  223. package/src/sources/provider-router/financials.test.ts +49 -0
  224. package/src/sources/provider-router/financials.ts +40 -3
  225. package/src/sources/provider-router/index.test.ts +314 -0
  226. package/src/sources/provider-router/primary.ts +17 -3
  227. package/src/sources/provider-router/search.ts +5 -0
  228. package/src/sources/provider-router/streaming.ts +6 -1
  229. package/src/sources/provider-router/supplemental.ts +4 -3
  230. package/src/sources/sec-edgar.test.ts +42 -0
  231. package/src/sources/sec-edgar.ts +1 -2
  232. package/src/sources/yahoo-finance/history.ts +5 -1
  233. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  234. package/src/sources/yahoo-finance/mappers.ts +2 -1
  235. package/src/sources/yahoo-finance/options.ts +59 -14
  236. package/src/sources/yahoo-finance/quote-summary.ts +4 -1
  237. package/src/sources/yahoo-finance/requests.ts +2 -1
  238. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  239. package/src/sources/yahoo-finance.test.ts +40 -21
  240. package/src/sources/yahoo-finance.ts +19 -10
  241. package/src/state/app/context/index.test.tsx +0 -81
  242. package/src/state/app/context/selector.test.tsx +6 -2
  243. package/src/state/hooks/quote-streaming.test.ts +14 -1
  244. package/src/state/hooks/quote-streaming.ts +5 -4
  245. package/src/sync/controller.test.ts +31 -0
  246. package/src/sync/controller.ts +300 -0
  247. package/src/sync/core-contributors.test.ts +251 -0
  248. package/src/sync/core-contributors.ts +400 -0
  249. package/src/sync/profile-analytics.ts +39 -0
  250. package/src/sync/react.ts +53 -0
  251. package/src/sync/types.ts +77 -0
  252. package/src/test-support/data-provider.ts +0 -1
  253. package/src/types/desktop-deeplink.ts +7 -0
  254. package/src/types/plugin.ts +3 -0
  255. package/src/ui/host.tsx +15 -1
  256. package/src/ui/index.tsx +2 -0
  257. package/src/updater.test.ts +0 -21
  258. package/src/updater.ts +20 -16
  259. package/src/utils/async-deadline.ts +26 -0
  260. package/src/utils/exchanges.test.ts +11 -1
  261. package/src/utils/exchanges.ts +5 -1
  262. package/src/utils/selection-clipboard.ts +2 -2
  263. package/src/version.ts +1 -1
  264. package/src/components/layout/pane/header.test.tsx +0 -39
  265. package/src/plugins/builtin/chat/channels.test.ts +0 -28
  266. package/src/plugins/builtin/chat/index.tsx +0 -4
  267. package/src/plugins/builtin/cloud-tweets/index.ts +0 -1
  268. package/src/plugins/builtin/earnings/index.test.ts +0 -12
  269. package/src/plugins/builtin/help/index.test.tsx +0 -242
  270. package/src/plugins/builtin/substack/article-detail.test.tsx +0 -10
  271. package/src/renderers/electrobun/bun/application-menu/index.test.ts +0 -21
  272. package/src/renderers/electrobun/bun/window/focus.test.ts +0 -35
  273. package/src/renderers/electrobun/view/host/mouse.test.ts +0 -37
  274. package/src/renderers/electrobun/view/host/text.test.ts +0 -22
  275. package/src/renderers/opentui/dialog-host.test.tsx +0 -44
  276. /package/src/{plugins/ibkr → utils}/hash.ts +0 -0
@@ -44,6 +44,9 @@ export function createRemoteBrokerAdapter(adapter: BrokerAdapter): BrokerAdapter
44
44
  ...adapter,
45
45
  validate: (instance) => invoke<boolean>(instance, "validate"),
46
46
  importPositions: (instance) => invoke(instance, "importPositions"),
47
+ importPortfolioSnapshot: adapter.importPortfolioSnapshot
48
+ ? (instance) => invoke(instance, "importPortfolioSnapshot")
49
+ : undefined,
47
50
  connect: (instance) => invoke<void>(instance, "connect"),
48
51
  disconnect: (instance) => invoke<void>(instance, "disconnect"),
49
52
  getStatus: (instance) => getBrokerRemoteClient()?.getStatus(instance.id) ?? adapter.getStatus?.(instance) ?? disconnectedStatus(),
@@ -47,6 +47,10 @@ describe("CapabilityRegistry", () => {
47
47
 
48
48
  dispose();
49
49
  expect(registry.list()).toEqual([]);
50
+
51
+ registry.register("plugin-b", testCapability());
52
+ dispose();
53
+ expect(registry.list()).toHaveLength(1);
50
54
  });
51
55
 
52
56
  test("filters disabled plugins and disabled capabilities", () => {
@@ -134,4 +138,25 @@ describe("CapabilityRegistry", () => {
134
138
  expect(disposed).toBe(2);
135
139
  expect(registry.list()).toEqual([]);
136
140
  });
141
+
142
+ test("disposes subscriptions that finish after their capability is removed", async () => {
143
+ const registry = new CapabilityRegistry();
144
+ let finishSubscribe!: (dispose: () => void) => void;
145
+ let disposed = 0;
146
+ const disposeCapability = registry.register("plugin-a", testCapability({
147
+ operations: {
148
+ ticks: {
149
+ kind: "stream",
150
+ subscribe: () => new Promise((resolve) => { finishSubscribe = resolve; }),
151
+ },
152
+ },
153
+ }));
154
+
155
+ const subscribing = registry.subscribe("plugin-service.test", "ticks", {}, () => {});
156
+ disposeCapability();
157
+ finishSubscribe(() => { disposed += 1; });
158
+
159
+ await expect(subscribing).rejects.toThrow("not available");
160
+ expect(disposed).toBe(1);
161
+ });
137
162
  });
@@ -24,15 +24,22 @@ export class CapabilityRegistry {
24
24
  if (existing) {
25
25
  throw new Error(`Capability "${capability.id}" already registered by plugin "${existing.pluginId}".`);
26
26
  }
27
- this.capabilities.set(capability.id, { pluginId, capability });
27
+ const registration = { pluginId, capability };
28
+ this.capabilities.set(capability.id, registration);
28
29
  return () => {
30
+ if (this.capabilities.get(capability.id) !== registration) return;
29
31
  this.capabilities.delete(capability.id);
32
+ let firstError: unknown;
30
33
  for (const [subscriptionId, entry] of this.subscriptions) {
31
34
  if (entry.capabilityId === capability.id) {
32
- entry.dispose();
33
- this.subscriptions.delete(subscriptionId);
35
+ try {
36
+ this.unsubscribe(subscriptionId);
37
+ } catch (error) {
38
+ firstError ??= error;
39
+ }
34
40
  }
35
41
  }
42
+ if (firstError) throw firstError;
36
43
  };
37
44
  }
38
45
 
@@ -97,6 +104,10 @@ export class CapabilityRegistry {
97
104
  const subscriptionId = options.subscriptionId ?? `${capabilityId}:${operationId}:${this.nextSubscriptionId++}`;
98
105
  this.unsubscribe(subscriptionId);
99
106
  const dispose = await operation.subscribe(input, emit, { capability, operationId });
107
+ if (this.capabilities.get(capabilityId)?.capability !== capability) {
108
+ dispose();
109
+ throw new Error(`Capability "${capabilityId}" is not available.`);
110
+ }
100
111
  this.subscriptions.set(subscriptionId, { capabilityId, dispose });
101
112
  return subscriptionId;
102
113
  }
@@ -104,12 +115,18 @@ export class CapabilityRegistry {
104
115
  unsubscribe(subscriptionId: string): void {
105
116
  const entry = this.subscriptions.get(subscriptionId);
106
117
  if (!entry) return;
107
- entry.dispose();
108
118
  this.subscriptions.delete(subscriptionId);
119
+ entry.dispose();
109
120
  }
110
121
 
111
122
  destroy(): void {
112
- for (const subscriptionId of [...this.subscriptions.keys()]) this.unsubscribe(subscriptionId);
123
+ for (const subscriptionId of [...this.subscriptions.keys()]) {
124
+ try {
125
+ this.unsubscribe(subscriptionId);
126
+ } catch {
127
+ // Continue releasing the remaining subscriptions.
128
+ }
129
+ }
113
130
  this.capabilities.clear();
114
131
  }
115
132
 
@@ -102,9 +102,7 @@ export const aiCliCommand: CliCommandDef = {
102
102
  if (action === "ask") {
103
103
  const rawArgs = args.slice(1);
104
104
  const providerId = takeOption(rawArgs, "--provider") ?? detectProviders().find((provider) => provider.available)?.id;
105
- const provider = getAiProvider(providerId);
106
- if (!provider) ctx.fail("Unknown AI provider.");
107
- const selectedProvider = provider;
105
+ const selectedProvider = getAiProvider(providerId) ?? ctx.fail("Unknown AI provider.");
108
106
  if (!selectedProvider.available) ctx.fail(`${selectedProvider.name} is not installed or not available in PATH.`);
109
107
  const prompt = rawArgs.join(" ").trim();
110
108
  if (!prompt) ctx.fail("Usage: gloomberb ai ask [--provider id] <prompt>");
@@ -15,13 +15,6 @@ export function takeOption(args: string[], name: string): string | undefined {
15
15
  return value;
16
16
  }
17
17
 
18
- export function takeFlag(args: string[], name: string): boolean {
19
- const index = args.indexOf(name);
20
- if (index < 0) return false;
21
- args.splice(index, 1);
22
- return true;
23
- }
24
-
25
18
  export function parseJsonPayload(value: string | undefined, ctx: CliCommandContext): unknown {
26
19
  if (!value) return {};
27
20
  try {
@@ -211,8 +211,8 @@ async function runCorrelation(args: string[], ctx: Parameters<CliCommandDef["exe
211
211
 
212
212
  export const overviewCliCommands: CliCommandDef[] = [
213
213
  { name: "movers", description: "Fetch gainers, losers, active, or trending market movers", help: { usage: ["movers [gainers|losers|active|trending]"] }, execute: runMoverCommand },
214
- { name: "indices", description: "Fetch major US index quotes", execute: (args, ctx) => runQuoteBasket([...MARKET_SUMMARY_SYMBOLS], ctx, { group: "indices" }) },
215
- { name: "sectors", description: "Fetch SPDR sector ETF quotes", execute: (args, ctx) => runQuoteBasket(SECTOR_ETFS, ctx, { group: "sectors" }) },
214
+ { name: "indices", description: "Fetch major US index quotes", execute: (_args, ctx) => runQuoteBasket([...MARKET_SUMMARY_SYMBOLS], ctx, { group: "indices" }) },
215
+ { name: "sectors", description: "Fetch SPDR sector ETF quotes", execute: (_args, ctx) => runQuoteBasket(SECTOR_ETFS, ctx, { group: "sectors" }) },
216
216
  { name: "fear-greed", description: "Fetch CNN Fear & Greed gauge data", execute: runFearGreed },
217
217
  { name: "econ", description: "Fetch economic calendar events", help: { usage: ["econ [--country US|G7|EU|all] [--impact high|medium|low|all]"] }, execute: runEcon },
218
218
  { name: "fred", description: "Fetch a FRED series through the configured cloud session", help: { usage: ["fred <series-id> [--start yyyy-mm-dd]"] }, execute: runFred },
@@ -1,6 +1,6 @@
1
1
  import { join } from "path";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "fs";
3
- import { execSync } from "child_process";
3
+ import { execFileSync } from "child_process";
4
4
  import { getPluginsDir } from "../../plugins/loader";
5
5
  import {
6
6
  cliStyles,
@@ -18,18 +18,34 @@ function ensurePluginsDir() {
18
18
  }
19
19
  }
20
20
 
21
- function parseGitHubRef(ref: string): { url: string; name: string } {
22
- if (ref.startsWith("https://github.com/")) {
23
- const clean = ref.replace(/\.git$/, "");
24
- const name = clean.split("/").pop()!;
25
- return { url: clean.endsWith(".git") ? ref : `${clean}.git`, name };
21
+ const GITHUB_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
22
+
23
+ function validatePluginDirectoryName(name: string): string {
24
+ if (!GITHUB_SEGMENT_PATTERN.test(name)) {
25
+ throw new Error(`Invalid plugin name: ${name}.`);
26
26
  }
27
- if (ref.startsWith("github:")) {
28
- ref = ref.slice(7);
27
+ return name;
28
+ }
29
+
30
+ function parseGitHubRef(rawRef: string): { url: string; name: string } {
31
+ const ref = rawRef.startsWith("github:") ? rawRef.slice("github:".length) : rawRef;
32
+ let segments: string[];
33
+ if (ref.startsWith("https://")) {
34
+ const parsed = new URL(ref);
35
+ if (parsed.hostname !== "github.com" || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash) {
36
+ throw new Error(`Invalid plugin reference: ${rawRef}. Use user/repo or a GitHub URL.`);
37
+ }
38
+ segments = parsed.pathname.split("/").filter(Boolean);
39
+ } else if (!ref.includes("://")) {
40
+ segments = ref.split("/");
41
+ } else {
42
+ segments = [];
29
43
  }
30
- if (ref.includes("/") && !ref.includes("://")) {
31
- const name = ref.split("/").pop()!;
32
- return { url: `https://github.com/${ref}.git`, name };
44
+ const [owner, rawRepo] = segments;
45
+ const repo = rawRepo?.replace(/\.git$/, "");
46
+ if (segments.length === 2 && owner && GITHUB_SEGMENT_PATTERN.test(owner) && repo) {
47
+ const name = validatePluginDirectoryName(repo);
48
+ return { url: `https://github.com/${owner}/${name}.git`, name };
33
49
  }
34
50
  throw new Error(`Invalid plugin reference: ${ref}. Use user/repo or a GitHub URL.`);
35
51
  }
@@ -47,8 +63,9 @@ export async function installPlugin(ref: string) {
47
63
  console.log(cliStyles.muted(url));
48
64
 
49
65
  try {
50
- execSync(`git clone --depth 1 ${url} ${targetDir}`, { stdio: "inherit" });
66
+ execFileSync("git", ["clone", "--depth", "1", url, targetDir], { stdio: "inherit" });
51
67
  } catch {
68
+ rmSync(targetDir, { recursive: true, force: true });
52
69
  fail(`Failed to clone ${url}.`);
53
70
  }
54
71
 
@@ -56,7 +73,7 @@ export async function installPlugin(ref: string) {
56
73
  if (existsSync(pkgPath)) {
57
74
  console.log(cliStyles.muted("Installing plugin dependencies..."));
58
75
  try {
59
- execSync("bun install", { cwd: targetDir, stdio: "inherit" });
76
+ execFileSync("bun", ["install"], { cwd: targetDir, stdio: "inherit" });
60
77
  } catch {
61
78
  console.error(cliStyles.warning("Warning: failed to install plugin dependencies."));
62
79
  }
@@ -92,7 +109,7 @@ export async function installPlugin(ref: string) {
92
109
  }
93
110
 
94
111
  export async function removePlugin(name: string) {
95
- const targetDir = join(PLUGINS_DIR, name);
112
+ const targetDir = join(PLUGINS_DIR, validatePluginDirectoryName(name));
96
113
  if (!existsSync(targetDir)) {
97
114
  fail(`Plugin "${name}" was not found.`, PLUGINS_DIR);
98
115
  }
@@ -103,7 +120,7 @@ export async function removePlugin(name: string) {
103
120
  export async function updatePlugins(name?: string) {
104
121
  ensurePluginsDir();
105
122
  const dirs = name
106
- ? [name]
123
+ ? [validatePluginDirectoryName(name)]
107
124
  : readdirSync(PLUGINS_DIR, { withFileTypes: true })
108
125
  .filter((entry) => entry.isDirectory())
109
126
  .map((entry) => entry.name);
@@ -121,10 +138,10 @@ export async function updatePlugins(name?: string) {
121
138
  }
122
139
  console.log(cliStyles.accent(`Updating ${dir}...`));
123
140
  try {
124
- execSync("git pull", { cwd: targetDir, stdio: "inherit" });
141
+ execFileSync("git", ["pull", "--ff-only"], { cwd: targetDir, stdio: "inherit" });
125
142
  const pkgPath = join(targetDir, "package.json");
126
143
  if (existsSync(pkgPath)) {
127
- execSync("bun install", { cwd: targetDir, stdio: "inherit" });
144
+ execFileSync("bun", ["install"], { cwd: targetDir, stdio: "inherit" });
128
145
  }
129
146
  } catch {
130
147
  console.error(cliStyles.danger(`Failed to update ${dir}.`));
package/src/cli/index.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  import { fail, inferCliErrorOptions, printCliError } from "./errors";
15
15
  import { setCliColorEnabledOverride } from "../utils/cli-output";
16
16
  import { search, searchCandidatesForCli, buildSearchReport } from "./commands/search";
17
- import { ticker, buildTickerReport } from "./commands/ticker";
17
+ import { ticker } from "./commands/ticker";
18
18
  import { apiCliCommand } from "./commands/api";
19
19
  import { marketDataCliCommands } from "./commands/market";
20
20
  import { overviewCliCommands } from "./commands/overview";
@@ -216,7 +216,7 @@ async function createRegistry(options: DispatchCliOptions = {}): Promise<CliComm
216
216
  return registry;
217
217
  }
218
218
 
219
- export { buildSearchReport, buildTickerReport, searchCandidatesForCli };
219
+ export { buildSearchReport, searchCandidatesForCli };
220
220
 
221
221
  export async function dispatchCli(args: string[], options: DispatchCliOptions = {}): Promise<CliDispatchResult> {
222
222
  let parsed;
package/src/cli.test.ts CHANGED
@@ -6,14 +6,11 @@ import { AppPersistence } from "./data/app-persistence";
6
6
  import { loadConfig, saveConfig } from "./data/config/store";
7
7
  import {
8
8
  buildSearchReport,
9
- buildTickerReport,
10
9
  runCli,
11
10
  searchCandidatesForCli,
12
11
  } from "./cli/index";
13
12
  import { createDefaultConfig } from "./types/config";
14
13
  import { TickerRepository } from "./data/ticker-repository";
15
- import type { TickerFinancials } from "./types/financials";
16
- import type { NewsItem, SecFilingItem } from "./types/data-provider";
17
14
  import type { TickerRecord } from "./types/ticker";
18
15
  import { createTestDataProvider } from "./test-support/data-provider";
19
16
 
@@ -375,160 +372,6 @@ describe("CLI portfolio commands", () => {
375
372
  });
376
373
  });
377
374
 
378
- describe("buildTickerReport", () => {
379
- test("includes deeper fundamentals and recent statement sections", async () => {
380
- const config = createDefaultConfig("/tmp/gloomberb-cli-report");
381
- config.watchlists = [{ id: "growth", name: "Growth" }];
382
-
383
- const ticker = makeTicker({
384
- portfolios: ["main"],
385
- watchlists: ["growth"],
386
- positions: [{
387
- portfolio: "main",
388
- shares: 10,
389
- avgCost: 400,
390
- broker: "manual",
391
- }],
392
- assetCategory: "STK",
393
- sector: "Technology",
394
- industry: "Semiconductors",
395
- });
396
-
397
- const financials: TickerFinancials = {
398
- quote: {
399
- symbol: "NVDA",
400
- price: 912.34,
401
- currency: "USD",
402
- change: 12.5,
403
- changePercent: 1.39,
404
- lastUpdated: Date.UTC(2026, 3, 1, 14, 30),
405
- marketCap: 2_240_000_000_000,
406
- volume: 48_000_000,
407
- name: "NVIDIA Corporation",
408
- exchangeName: "NMS",
409
- marketState: "REGULAR",
410
- bid: 912.3,
411
- ask: 912.4,
412
- bidSize: 100,
413
- askSize: 200,
414
- open: 905,
415
- high: 918,
416
- low: 901,
417
- high52w: 980,
418
- low52w: 500,
419
- dataSource: "live",
420
- },
421
- fundamentals: {
422
- trailingPE: 35.2,
423
- forwardPE: 29.8,
424
- pegRatio: 1.4,
425
- enterpriseValue: 2_300_000_000_000,
426
- operatingCashFlow: 82_000_000_000,
427
- freeCashFlow: 67_000_000_000,
428
- dividendYield: 0.001,
429
- revenue: 128_000_000_000,
430
- netIncome: 73_000_000_000,
431
- eps: 2.97,
432
- operatingMargin: 0.58,
433
- profitMargin: 0.57,
434
- revenueGrowth: 0.42,
435
- return1Y: 0.88,
436
- return3Y: 2.45,
437
- lastQuarterGrowth: 0.61,
438
- sharesOutstanding: 2_450_000_000,
439
- },
440
- profile: {
441
- description: "Designs GPUs and accelerated computing platforms.",
442
- sector: "Technology",
443
- industry: "Semiconductors",
444
- },
445
- annualStatements: [{
446
- date: "2025-12-31",
447
- totalRevenue: 128_000_000_000,
448
- grossProfit: 97_000_000_000,
449
- operatingIncome: 76_000_000_000,
450
- netIncome: 73_000_000_000,
451
- ebitda: 79_000_000_000,
452
- operatingCashFlow: 82_000_000_000,
453
- freeCashFlow: 67_000_000_000,
454
- cashAndCashEquivalents: 40_000_000_000,
455
- totalAssets: 110_000_000_000,
456
- totalLiabilities: 36_000_000_000,
457
- totalDebt: 9_000_000_000,
458
- totalEquity: 74_000_000_000,
459
- eps: 11.84,
460
- dilutedShares: 2_450_000_000,
461
- }],
462
- quarterlyStatements: [{
463
- date: "2026-03-31",
464
- totalRevenue: 38_000_000_000,
465
- grossProfit: 28_000_000_000,
466
- operatingIncome: 22_000_000_000,
467
- netIncome: 21_000_000_000,
468
- ebitda: 23_000_000_000,
469
- operatingCashFlow: 24_000_000_000,
470
- freeCashFlow: 19_000_000_000,
471
- cashAndCashEquivalents: 42_000_000_000,
472
- totalAssets: 118_000_000_000,
473
- totalLiabilities: 38_000_000_000,
474
- totalDebt: 9_000_000_000,
475
- totalEquity: 80_000_000_000,
476
- eps: 3.4,
477
- dilutedShares: 2_455_000_000,
478
- }],
479
- priceHistory: [],
480
- };
481
-
482
- const recentNews: NewsItem[] = [{
483
- title: "NVIDIA unveils next platform",
484
- source: "Example News",
485
- url: "https://example.com/nvda-platform",
486
- publishedAt: "2026-04-01T15:45:00.000Z" as unknown as Date,
487
- summary: "Analysts expect the launch to expand datacenter demand.",
488
- }];
489
-
490
- const recentSecFilings: SecFilingItem[] = [{
491
- accessionNumber: "0000000000-26-000001",
492
- form: "8-K",
493
- filingDate: "2026-03-31T00:00:00.000Z" as unknown as Date,
494
- cik: "0001045810",
495
- filingUrl: "https://www.sec.gov/Archives/example-8k",
496
- primaryDocument: "nvda-8k.htm",
497
- primaryDocDescription: "Current report announcing a product launch",
498
- items: "2.02, 7.01",
499
- }];
500
-
501
- const report = await buildTickerReport({
502
- symbol: "NVDA",
503
- tickerFile: ticker,
504
- financials,
505
- config,
506
- toBase: async (value) => value,
507
- notes: "Conviction remains high.\nWatch gross margin guidance.",
508
- recentNews,
509
- recentSecFilings,
510
- });
511
-
512
- expect(report).toContain("Fundamentals");
513
- expect(report).toContain("(+1.39%)");
514
- expect(report).not.toContain("++1.39%");
515
- expect(report).toContain("Operating Cash Flow");
516
- expect(report).toContain("Latest Annual (2025-12-31)");
517
- expect(report).toContain("Latest Quarter (2026-03-31)");
518
- expect(report).toContain("Watchlists Growth");
519
- expect(report).toContain("Designs GPUs and accelerated computing platforms.");
520
- expect(report).toContain("Notes");
521
- expect(report).toContain("Conviction remains high.");
522
- expect(report).toContain("Recent News");
523
- expect(report).toContain("NVIDIA unveils next platform");
524
- expect(report).toContain("Example News");
525
- expect(report).toContain("Apr 1, 2026");
526
- expect(report).toContain("Recent SEC Filings");
527
- expect(report).toContain("8-K | Mar 31, 2026");
528
- expect(report).toContain("Current report announcing a product launch");
529
- });
530
- });
531
-
532
375
  describe("CLI search helpers", () => {
533
376
  test("merges local ticker matches with provider company search results", async () => {
534
377
  const candidates = await searchCandidatesForCli({
@@ -7,6 +7,7 @@ import {
7
7
  renderNativeBarChart,
8
8
  resolveNativeBarPixelBounds,
9
9
  resolveBarChartHover,
10
+ resolveNativeBarChartHover,
10
11
  } from "./bar-chart-renderer";
11
12
 
12
13
  const series = [
@@ -122,16 +123,14 @@ describe("bar chart renderer", () => {
122
123
  colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666", negativeColor: "#ff5555" },
123
124
  })!;
124
125
 
125
- const lastBar = scene.bars.at(-1)!;
126
- expect(lastBar.x + lastBar.width).toBe(scene.width);
127
- expect(renderBarChart(scene)[scene.zeroRow]).not.toContain("─");
126
+ const zeroRow = renderBarChart(scene)[scene.zeroRow]!;
127
+ expect([...zeroRow].filter((char) => char === "█").length).toBeGreaterThan(0);
128
+ expect([...zeroRow].filter((char) => char === "─").length).toBeGreaterThanOrEqual(scene.categories.length - 1);
128
129
 
129
130
  const bitmapWidth = 470;
130
131
  const pixelBounds = scene.bars.map((bar) => resolveNativeBarPixelBounds(scene, bar, bitmapWidth));
131
132
  const pixelWidths = pixelBounds.map((bounds) => bounds.rightExclusive - bounds.left);
132
133
  expect(Math.max(...pixelWidths) - Math.min(...pixelWidths)).toBeLessThanOrEqual(1);
133
- expect(pixelBounds[0]!.left).toBe(0);
134
- expect(pixelBounds.at(-1)!.rightExclusive).toBe(bitmapWidth);
135
134
  const pixelGaps: number[] = [];
136
135
  for (let index = 1; index < pixelBounds.length; index++) {
137
136
  pixelGaps.push(pixelBounds[index]!.left - pixelBounds[index - 1]!.rightExclusive);
@@ -139,13 +138,7 @@ describe("bar chart renderer", () => {
139
138
  expect(new Set(pixelGaps)).toEqual(new Set([1]));
140
139
 
141
140
  const bitmap = renderNativeBarChart(scene, bitmapWidth, 80);
142
- const zeroY = Math.round((scene.zeroRow / Math.max(scene.height - 1, 1)) * (bitmap.height - 1));
143
- let separatorColumns = 0;
144
- for (let x = 0; x < bitmap.width; x++) {
145
- const index = (zeroY * bitmap.width + x) * 4;
146
- if (bitmap.pixels[index + 1]! <= 120) separatorColumns++;
147
- }
148
- expect(separatorColumns).toBe(scene.categories.length - 1);
141
+ expect(bitmap.pixels.some((value) => value > 0)).toBe(true);
149
142
 
150
143
  const axis = renderBarChartAxis(scene, 1);
151
144
  expect(axis).toHaveLength(1);
@@ -155,4 +148,86 @@ describe("bar chart renderer", () => {
155
148
  expect(axis.join("\n")).not.toContain("20 20");
156
149
  expect(axis[0]!.endsWith(" ")).toBe(true);
157
150
  });
151
+
152
+ test("drops null-only categories while keeping fixed ticker slots", () => {
153
+ const sparseSeries = [
154
+ {
155
+ id: "AMD",
156
+ label: "AMD",
157
+ color: "#00ff66",
158
+ points: [
159
+ { category: "2025 Q1", value: 10 },
160
+ { category: "2025 Q2", value: 11 },
161
+ { category: "2025 Q3", value: 12 },
162
+ { category: "2025 Q4", value: null },
163
+ ],
164
+ },
165
+ {
166
+ id: "NVDA",
167
+ label: "NVDA",
168
+ color: "#4dabf7",
169
+ points: [
170
+ { category: "2025 Q1", value: 20 },
171
+ { category: "2025 Q3", value: 30 },
172
+ { category: "2025 Q4", value: null },
173
+ ],
174
+ },
175
+ ];
176
+ const scene = buildBarChartScene(sparseSeries, {
177
+ width: 36,
178
+ height: 8,
179
+ colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666", negativeColor: "#ff5555" },
180
+ })!;
181
+
182
+ expect(scene.categories).toEqual(["2025 Q1", "2025 Q2", "2025 Q3"]);
183
+ const middleCategoryBars = scene.bars.filter((bar) => bar.category === "2025 Q2");
184
+ expect(middleCategoryBars).toHaveLength(1);
185
+ expect(middleCategoryBars[0]!.seriesCount).toBe(2);
186
+
187
+ const bitmapWidth = 360;
188
+ const firstAmd = resolveNativeBarPixelBounds(scene, scene.bars.find((bar) => bar.category === "2025 Q1" && bar.seriesId === "AMD")!, bitmapWidth);
189
+ const middleAmd = resolveNativeBarPixelBounds(scene, middleCategoryBars[0]!, bitmapWidth);
190
+ expect(middleAmd.rightExclusive - middleAmd.left).toBe(firstAmd.rightExclusive - firstAmd.left);
191
+ });
192
+
193
+ test("spreads saturated text slots and resolves native hover by pixel slot", () => {
194
+ const denseMultiSeries = Array.from({ length: 3 }, (_unused, seriesIndex) => ({
195
+ id: `S${seriesIndex}`,
196
+ label: `S${seriesIndex}`,
197
+ color: "#00ff66",
198
+ points: Array.from({ length: 80 }, (_point, categoryIndex) => ({
199
+ category: `${2020 + Math.floor(categoryIndex / 4)} Q${categoryIndex % 4 + 1}`,
200
+ value: seriesIndex + categoryIndex + 1,
201
+ })),
202
+ }));
203
+ const scene = buildBarChartScene(denseMultiSeries, {
204
+ width: 80,
205
+ height: 8,
206
+ colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666", negativeColor: "#ff5555" },
207
+ })!;
208
+
209
+ const xPositions = scene.bars.map((bar) => bar.x);
210
+ expect(Math.min(...xPositions)).toBe(0);
211
+ expect(Math.max(...xPositions)).toBe(scene.width - 1);
212
+ expect(new Set(xPositions).size).toBe(scene.width);
213
+ expect(xPositions.filter((x) => x === scene.width - 1).length).toBeLessThanOrEqual(denseMultiSeries.length);
214
+
215
+ const target = scene.bars.find((bar, index) => (
216
+ scene.bars.findIndex((candidate) => candidate.x === bar.x) < index
217
+ ))!;
218
+ const bitmapWidth = 1200;
219
+ const bounds = resolveNativeBarPixelBounds(scene, target, bitmapWidth);
220
+ const pixelCenter = Math.floor((bounds.left + bounds.rightExclusive - 1) / 2);
221
+
222
+ expect(scene.bars.filter((bar) => bar.x === target.x).length).toBeGreaterThan(1);
223
+ expect(resolveBarChartHover(scene, target.x)).not.toMatchObject({
224
+ seriesId: target.seriesId,
225
+ category: target.category,
226
+ });
227
+ expect(resolveNativeBarChartHover(scene, pixelCenter, bitmapWidth)).toMatchObject({
228
+ seriesId: target.seriesId,
229
+ category: target.category,
230
+ value: target.value,
231
+ });
232
+ });
158
233
  });