gloomberb 0.7.2 → 0.7.4

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 (167) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +158 -47
  7. package/src/components/chart/chart-types.ts +20 -2
  8. package/src/components/chart/chart.test.ts +179 -13
  9. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  10. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  11. package/src/components/chart/comparison-stock-chart.tsx +129 -129
  12. package/src/components/chart/market-session.ts +187 -0
  13. package/src/components/chart/native/chart-rasterizer.ts +108 -19
  14. package/src/components/chart/price-axis-labels.tsx +25 -9
  15. package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
  16. package/src/components/chart/stock-chart.tsx +95 -49
  17. package/src/components/chart/time-axis-label.tsx +56 -2
  18. package/src/components/command-bar/command-bar.test.tsx +446 -120
  19. package/src/components/command-bar/command-bar.tsx +535 -384
  20. package/src/components/command-bar/command-registry.ts +16 -6
  21. package/src/components/command-bar/list-model.ts +8 -4
  22. package/src/components/command-bar/root-shortcuts.ts +4 -5
  23. package/src/components/command-bar/view-model.test.ts +21 -0
  24. package/src/components/command-bar/view-model.ts +17 -4
  25. package/src/components/command-bar/workflow-ops.ts +11 -32
  26. package/src/components/data-table-view.test.tsx +24 -1
  27. package/src/components/data-table-view.tsx +3 -2
  28. package/src/components/feed-data-table-stack-view.tsx +3 -2
  29. package/src/components/layout/detached-pane-shell.tsx +2 -2
  30. package/src/components/layout/floating-pane.tsx +9 -2
  31. package/src/components/layout/pane-footer.tsx +1 -1
  32. package/src/components/layout/pane-header.tsx +4 -2
  33. package/src/components/layout/pane-sizing.ts +12 -0
  34. package/src/components/layout/pane.tsx +24 -2
  35. package/src/components/layout/shell.tsx +24 -10
  36. package/src/components/layout/status-bar.test.tsx +4 -1
  37. package/src/components/layout/status-bar.tsx +4 -24
  38. package/src/components/markdown-text.tsx +53 -8
  39. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  40. package/src/components/pane-settings-dialog.tsx +9 -6
  41. package/src/components/pane-template-wizard.tsx +3 -2
  42. package/src/components/price-selector-dialog.tsx +3 -2
  43. package/src/components/price-sparkline-view.tsx +463 -0
  44. package/src/components/price-sparkline.ts +46 -0
  45. package/src/components/table-view-shared.tsx +21 -3
  46. package/src/components/ticker-list-table-view.tsx +3 -2
  47. package/src/components/ticker-list-table.tsx +15 -66
  48. package/src/components/toggle-list.tsx +3 -3
  49. package/src/components/ui/choice-dialog.tsx +3 -2
  50. package/src/components/ui/multi-select-dialog.tsx +3 -2
  51. package/src/components/ui/native-select.tsx +3 -3
  52. package/src/core/state/app-state.ts +210 -80
  53. package/src/core/state/session-persistence.ts +25 -0
  54. package/src/data/config-store-node.ts +113 -25
  55. package/src/data/config-store.test.ts +65 -3
  56. package/src/data/plugin-state-store.ts +39 -13
  57. package/src/data/sqlite-cache.test.ts +13 -0
  58. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  59. package/src/market-data/coordinator.test.ts +122 -2
  60. package/src/market-data/coordinator.ts +200 -56
  61. package/src/market-data/hooks.test.tsx +31 -0
  62. package/src/market-data/hooks.tsx +1 -0
  63. package/src/news/aggregator.test.ts +41 -0
  64. package/src/news/aggregator.ts +61 -9
  65. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  66. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  67. package/src/plugins/builtin/ai/settings.ts +3 -0
  68. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  69. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  70. package/src/plugins/builtin/chat-controller.ts +263 -48
  71. package/src/plugins/builtin/chat.test.tsx +312 -6
  72. package/src/plugins/builtin/chat.tsx +245 -66
  73. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  74. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  75. package/src/plugins/builtin/correlation/colors.ts +87 -0
  76. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  77. package/src/plugins/builtin/correlation/index.tsx +92 -21
  78. package/src/plugins/builtin/debug.tsx +4 -3
  79. package/src/plugins/builtin/econ/index.tsx +3 -2
  80. package/src/plugins/builtin/help.test.tsx +2 -2
  81. package/src/plugins/builtin/holders/index.tsx +25 -13
  82. package/src/plugins/builtin/layout-manager.tsx +1 -7
  83. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  84. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  85. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  86. package/src/plugins/builtin/news-wire/index.tsx +1 -3
  87. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  88. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  89. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  90. package/src/plugins/builtin/options.tsx +9 -17
  91. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  92. package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
  93. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  94. package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
  95. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  96. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  97. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  98. package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
  99. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  100. package/src/plugins/builtin/sectors/index.tsx +275 -43
  101. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  102. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  103. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  104. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  105. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
  106. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
  107. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  108. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  109. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  110. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  111. package/src/plugins/plugin-runtime.tsx +2 -2
  112. package/src/plugins/prediction-markets/settings.ts +4 -0
  113. package/src/plugins/registry.test.ts +55 -0
  114. package/src/plugins/registry.ts +11 -4
  115. package/src/plugins/ticker-navigation.test.ts +35 -5
  116. package/src/plugins/ticker-navigation.ts +16 -22
  117. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  118. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  119. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  120. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  121. package/src/renderers/electrobun/bun/index.ts +52 -13
  122. package/src/renderers/electrobun/view/app-services.ts +51 -9
  123. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  124. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  125. package/src/renderers/electrobun/view/main.tsx +0 -16
  126. package/src/renderers/electrobun/view/styles.css +23 -21
  127. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  128. package/src/renderers/opentui/host.tsx +2 -1
  129. package/src/renderers/opentui/start.tsx +1 -1
  130. package/src/renderers/opentui/test-utils.tsx +6 -5
  131. package/src/sources/gloomberb-cloud.test.ts +55 -2
  132. package/src/sources/gloomberb-cloud.ts +180 -29
  133. package/src/sources/provider-router.test.ts +567 -10
  134. package/src/sources/provider-router.ts +323 -93
  135. package/src/sources/yahoo-finance.test.ts +37 -0
  136. package/src/sources/yahoo-finance.ts +48 -2
  137. package/src/state/app-bootstrap.test.ts +50 -23
  138. package/src/state/app-bootstrap.ts +29 -13
  139. package/src/state/app-context.test.ts +113 -0
  140. package/src/state/app-context.tsx +8 -4
  141. package/src/state/session-persistence.test.ts +5 -3
  142. package/src/state/use-inline-tickers.ts +3 -0
  143. package/src/state/use-quote-streaming.ts +11 -0
  144. package/src/theme/colors.ts +13 -14
  145. package/src/types/config.ts +9 -4
  146. package/src/types/data-provider.ts +5 -1
  147. package/src/types/financials.ts +1 -0
  148. package/src/types/plugin.ts +8 -1
  149. package/src/ui/context-menu.tsx +4 -4
  150. package/src/utils/api-client.test.ts +248 -0
  151. package/src/utils/api-client.ts +178 -26
  152. package/src/utils/exchanges.test.ts +16 -0
  153. package/src/utils/exchanges.ts +33 -0
  154. package/src/utils/fuzzy-search.ts +12 -1
  155. package/src/utils/keyboard.ts +31 -0
  156. package/src/utils/options.ts +1 -1
  157. package/src/utils/price-history.test.ts +28 -1
  158. package/src/utils/price-history.ts +16 -0
  159. package/src/utils/quote-freshness.test.ts +150 -0
  160. package/src/utils/quote-freshness.ts +42 -1
  161. package/src/utils/quote-resolution.ts +2 -0
  162. package/src/utils/quote-time.test.ts +34 -0
  163. package/src/utils/quote-time.ts +18 -5
  164. package/src/utils/ticker-search.test.ts +2 -2
  165. package/src/utils/ticker-search.ts +48 -1
  166. package/src/version.ts +1 -1
  167. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -3,8 +3,10 @@ import { existsSync, rmSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { act, useReducer } from "react";
6
+ import { RGBA, TextAttributes } from "@opentui/core";
6
7
  import { testRender } from "../../renderers/opentui/test-utils";
7
8
  import { AppPersistence } from "../../data/app-persistence";
9
+ import { TickerRepository } from "../../data/ticker-repository";
8
10
  import { AppContext, appReducer, createInitialState, PaneInstanceProvider, type AppAction } from "../../state/app-context";
9
11
  import { AssetDataRouter } from "../../sources/provider-router";
10
12
  import { cloneLayout, createDefaultConfig, type AppConfig, type BrokerInstanceConfig } from "../../types/config";
@@ -12,9 +14,12 @@ import type { DataProvider } from "../../types/data-provider";
12
14
  import type { Quote } from "../../types/financials";
13
15
  import type { TickerRecord } from "../../types/ticker";
14
16
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
17
+ import { instrumentFromTicker } from "../../market-data/request-types";
15
18
  import { createTestPluginRuntime } from "../../test-support/plugin-runtime";
16
19
  import { PluginRenderProvider, type PluginRuntimeAccess } from "../plugin-runtime";
20
+ import { PluginRegistry, setSharedMarketDataForTests, setSharedRegistryForTests } from "../registry";
17
21
  import type { BrokerAdapter } from "../../types/broker";
22
+ import { colors } from "../../theme/colors";
18
23
  import { portfolioListPlugin } from "./portfolio-list";
19
24
 
20
25
  const TEST_PANE_ID = "portfolio-list:test";
@@ -24,6 +29,7 @@ let harnessDispatch: React.Dispatch<AppAction> | null = null;
24
29
  let sharedCoordinator: MarketDataCoordinator | null = null;
25
30
  let harnessState: ReturnType<typeof createInitialState> | null = null;
26
31
  const tempPaths: string[] = [];
32
+ const tempPersistences: AppPersistence[] = [];
27
33
 
28
34
  const PortfolioPane = portfolioListPlugin.panes![0]!.component as (props: {
29
35
  paneId: string;
@@ -154,6 +160,75 @@ function createPortfolioConfigWithColumns(
154
160
  }
155
161
  return config;
156
162
  }
163
+
164
+ function createManualCollectionConfig(collectionId: string): AppConfig {
165
+ const config = createDefaultConfig("/tmp/gloomberb-portfolio-list");
166
+ const layout = {
167
+ dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
168
+ instances: [{
169
+ instanceId: TEST_PANE_ID,
170
+ paneId: "portfolio-list",
171
+ binding: { kind: "none" as const },
172
+ params: { collectionId },
173
+ }],
174
+ floating: [],
175
+ };
176
+
177
+ return {
178
+ ...config,
179
+ layout,
180
+ layouts: [{ name: "Default", layout: cloneLayout(layout) }],
181
+ };
182
+ }
183
+
184
+ function installQuickAddRegistry(provider: DataProvider): PluginRegistry {
185
+ const persistence = new AppPersistence(createTempDbPath("quick-add"));
186
+ tempPersistences.push(persistence);
187
+ const registry = new PluginRegistry(provider, new TickerRepository(persistence.tickers), persistence);
188
+ registry.getConfigFn = () => harnessState?.config ?? createDefaultConfig("/tmp/gloomberb-portfolio-list");
189
+ return registry;
190
+ }
191
+
192
+ function createQuickAddProvider(match = true): DataProvider {
193
+ return {
194
+ id: "quick-add-test",
195
+ name: "Quick Add Test",
196
+ async getTickerFinancials() {
197
+ return {
198
+ annualStatements: [],
199
+ quarterlyStatements: [],
200
+ priceHistory: [],
201
+ quote: makeQuote({ symbol: "MSFT", price: 420, change: 5.2, changePercent: 1.25, name: "Microsoft" }),
202
+ };
203
+ },
204
+ async getQuote(symbol) {
205
+ if (match && symbol === "MSFT") {
206
+ return makeQuote({ symbol: "MSFT", price: 420, change: 5.2, changePercent: 1.25, name: "Microsoft" });
207
+ }
208
+ throw new Error(`No quote for ${symbol}`);
209
+ },
210
+ async getExchangeRate() {
211
+ return 1;
212
+ },
213
+ async search(query) {
214
+ if (!match || query !== "MSFT") return [];
215
+ return [{
216
+ symbol: "MSFT",
217
+ name: "Microsoft",
218
+ exchange: "NASDAQ",
219
+ currency: "USD",
220
+ type: "STK",
221
+ }];
222
+ },
223
+ async getArticleSummary() {
224
+ return null;
225
+ },
226
+ async getPriceHistory() {
227
+ return [];
228
+ },
229
+ };
230
+ }
231
+
157
232
  function createPortfolioState(
158
233
  config: AppConfig,
159
234
  collectionId: string,
@@ -193,6 +268,7 @@ function PortfolioHarness({
193
268
  exchangeRates,
194
269
  stateMutator,
195
270
  runtime = createTestPluginRuntime(),
271
+ paneHeight = 24,
196
272
  }: {
197
273
  config: AppConfig;
198
274
  collectionId: string;
@@ -203,6 +279,7 @@ function PortfolioHarness({
203
279
  exchangeRates?: Map<string, number>;
204
280
  stateMutator?: (state: ReturnType<typeof createInitialState>) => void;
205
281
  runtime?: PluginRuntimeAccess;
282
+ paneHeight?: number;
206
283
  }) {
207
284
  const initialState = createPortfolioState(config, collectionId, expanded, {
208
285
  ticker,
@@ -224,7 +301,7 @@ function PortfolioHarness({
224
301
  paneType="portfolio-list"
225
302
  focused
226
303
  width={100}
227
- height={24}
304
+ height={paneHeight}
228
305
  />
229
306
  </PluginRenderProvider>
230
307
  </PaneInstanceProvider>
@@ -241,6 +318,10 @@ async function flushFrame() {
241
318
  });
242
319
  }
243
320
 
321
+ function lineText(line: { spans: Array<{ text: string }> }) {
322
+ return line.spans.map((span) => span.text).join("");
323
+ }
324
+
244
325
  afterEach(async () => {
245
326
  if (testSetup) {
246
327
  testSetup.renderer.destroy();
@@ -249,7 +330,12 @@ afterEach(async () => {
249
330
  harnessDispatch = null;
250
331
  sharedCoordinator = null;
251
332
  setSharedMarketDataCoordinator(null);
333
+ setSharedMarketDataForTests(undefined);
334
+ setSharedRegistryForTests(undefined);
252
335
  harnessState = null;
336
+ for (const persistence of tempPersistences.splice(0)) {
337
+ persistence.close();
338
+ }
253
339
  for (const path of tempPaths.splice(0)) {
254
340
  if (existsSync(path)) rmSync(path, { force: true });
255
341
  }
@@ -291,6 +377,151 @@ describe("PortfolioListPane cash and margin UI", () => {
291
377
  expect(pinned).toEqual([{ symbol: "AAPL", options: { floating: true, paneType: "ticker-detail" } }]);
292
378
  });
293
379
 
380
+ test("quick-add validates and adds an exact watchlist ticker", async () => {
381
+ const config = createManualCollectionConfig("watchlist");
382
+ const notifications: Array<{ type?: string; body: string }> = [];
383
+ installQuickAddRegistry(createQuickAddProvider(true));
384
+
385
+ testSetup = await testRender(
386
+ <PortfolioHarness
387
+ config={config}
388
+ collectionId="watchlist"
389
+ ticker={makeTicker({ portfolios: [], watchlists: [], positions: [] })}
390
+ runtime={createTestPluginRuntime({
391
+ notify: (notification) => notifications.push(notification),
392
+ })}
393
+ paneHeight={12}
394
+ />,
395
+ { width: 100, height: 12 },
396
+ );
397
+
398
+ await flushFrame();
399
+ await act(async () => {
400
+ testSetup!.mockInput.pressKey("a");
401
+ await testSetup!.renderOnce();
402
+ });
403
+ await act(async () => {
404
+ await testSetup!.mockInput.typeText("MSFT");
405
+ await testSetup!.renderOnce();
406
+ });
407
+ await act(async () => {
408
+ await new Promise((resolve) => setTimeout(resolve, 360));
409
+ });
410
+ await flushFrame();
411
+
412
+ const previewFrame = testSetup.captureCharFrame();
413
+ expect(previewFrame).toContain("420");
414
+ expect(previewFrame).not.toMatch(/MSFT\s+420/);
415
+ expect(previewFrame).toContain("+1.25%");
416
+
417
+ await act(async () => {
418
+ testSetup!.mockInput.pressEnter();
419
+ await Promise.resolve();
420
+ await testSetup!.renderOnce();
421
+ });
422
+ await flushFrame();
423
+
424
+ expect(harnessState?.tickers.get("MSFT")?.metadata.watchlists).toEqual(["watchlist"]);
425
+ expect(notifications.at(-1)).toMatchObject({
426
+ type: "success",
427
+ body: "Added MSFT to Watchlist.",
428
+ });
429
+ });
430
+
431
+ test("quick-add adds an exact ticker to a manual portfolio", async () => {
432
+ const config = createManualCollectionConfig("main");
433
+ const notifications: Array<{ type?: string; body: string }> = [];
434
+ installQuickAddRegistry(createQuickAddProvider(true));
435
+
436
+ testSetup = await testRender(
437
+ <PortfolioHarness
438
+ config={config}
439
+ collectionId="main"
440
+ ticker={makeTicker({ portfolios: [], watchlists: [], positions: [] })}
441
+ runtime={createTestPluginRuntime({
442
+ notify: (notification) => notifications.push(notification),
443
+ })}
444
+ paneHeight={12}
445
+ />,
446
+ { width: 100, height: 12 },
447
+ );
448
+
449
+ await flushFrame();
450
+ await act(async () => {
451
+ testSetup!.mockInput.pressKey("n");
452
+ await testSetup!.renderOnce();
453
+ });
454
+ await act(async () => {
455
+ await testSetup!.mockInput.typeText("MSFT");
456
+ await testSetup!.renderOnce();
457
+ });
458
+ await act(async () => {
459
+ await new Promise((resolve) => setTimeout(resolve, 360));
460
+ });
461
+ await flushFrame();
462
+
463
+ await act(async () => {
464
+ testSetup!.mockInput.pressEnter();
465
+ await Promise.resolve();
466
+ await testSetup!.renderOnce();
467
+ });
468
+ await flushFrame();
469
+
470
+ expect(harnessState?.tickers.get("MSFT")?.metadata.portfolios).toEqual(["main"]);
471
+ expect(notifications.at(-1)).toMatchObject({
472
+ type: "success",
473
+ body: "Added MSFT to Main Portfolio.",
474
+ });
475
+ });
476
+
477
+ test("quick-add rejects unresolved ticker input", async () => {
478
+ const config = createManualCollectionConfig("watchlist");
479
+ const notifications: Array<{ type?: string; body: string }> = [];
480
+ installQuickAddRegistry(createQuickAddProvider(false));
481
+
482
+ testSetup = await testRender(
483
+ <PortfolioHarness
484
+ config={config}
485
+ collectionId="watchlist"
486
+ ticker={makeTicker({ portfolios: [], watchlists: [], positions: [] })}
487
+ runtime={createTestPluginRuntime({
488
+ notify: (notification) => notifications.push(notification),
489
+ })}
490
+ paneHeight={12}
491
+ />,
492
+ { width: 100, height: 12 },
493
+ );
494
+
495
+ await flushFrame();
496
+ await act(async () => {
497
+ testSetup!.mockInput.pressKey("n");
498
+ await testSetup!.renderOnce();
499
+ });
500
+ await act(async () => {
501
+ await testSetup!.mockInput.typeText("NOPE");
502
+ await testSetup!.renderOnce();
503
+ });
504
+ await act(async () => {
505
+ await new Promise((resolve) => setTimeout(resolve, 360));
506
+ });
507
+ await flushFrame();
508
+
509
+ expect(testSetup.captureCharFrame()).toContain("No exact ticker match");
510
+
511
+ await act(async () => {
512
+ testSetup!.mockInput.pressEnter();
513
+ await Promise.resolve();
514
+ await testSetup!.renderOnce();
515
+ });
516
+ await flushFrame();
517
+
518
+ expect(harnessState?.tickers.has("NOPE")).toBe(false);
519
+ expect(notifications.at(-1)).toMatchObject({
520
+ type: "error",
521
+ body: "No exact ticker match",
522
+ });
523
+ });
524
+
294
525
  test("keeps non-broker portfolios unchanged", async () => {
295
526
  const config = createDefaultConfig("/tmp/gloomberb-portfolio-list");
296
527
  const layout = {
@@ -317,6 +548,43 @@ describe("PortfolioListPane cash and margin UI", () => {
317
548
  expect(frame).not.toContain("Cash & Margin");
318
549
  });
319
550
 
551
+ test("renders one-month sparkline column when price history is loaded", async () => {
552
+ const config = createPortfolioConfigWithColumns(
553
+ "broker:ibkr-flex:DU12345",
554
+ ["ticker", "price", "sparkline"],
555
+ [createBrokerInstance("flex")],
556
+ );
557
+
558
+ testSetup = await testRender(
559
+ <PortfolioHarness
560
+ config={config}
561
+ collectionId="broker:ibkr-flex:DU12345"
562
+ stateMutator={(state) => {
563
+ state.financials = new Map([[
564
+ "AAPL",
565
+ {
566
+ annualStatements: [],
567
+ quarterlyStatements: [],
568
+ quote: makeQuote(),
569
+ priceHistory: [118, 121, 119, 124, 127, 126, 130].map((close, index) => ({
570
+ date: `2026-03-${20 + index}T00:00:00Z` as unknown as Date,
571
+ close,
572
+ })),
573
+ },
574
+ ]]);
575
+ }}
576
+ />,
577
+ { width: 100, height: 12 },
578
+ );
579
+
580
+ await flushFrame();
581
+
582
+ const frame = testSetup.captureCharFrame();
583
+ expect(frame).toContain("1M");
584
+ const row = frame.split("\n").find((line) => line.includes("AAPL")) ?? "";
585
+ expect(row).toMatch(/[\u2800-\u28ff]/);
586
+ });
587
+
320
588
  test("keeps native price and avg cost while converting market value and pnl to base currency", async () => {
321
589
  const portfolioId = "broker:ibkr-flex:DU12345";
322
590
  const config = createPortfolioConfigWithColumns(
@@ -738,6 +1006,8 @@ describe("PortfolioListPane cash and margin UI", () => {
738
1006
  ["ticker", "market_cap", "pe", "forward_pe"],
739
1007
  [createBrokerInstance("flex")],
740
1008
  );
1009
+ const seededTicker = makeTicker();
1010
+ const seededQuote = makeQuote();
741
1011
  let calls = 0;
742
1012
  let resolveFinancials!: (value: {
743
1013
  annualStatements: [];
@@ -782,10 +1052,26 @@ describe("PortfolioListPane cash and margin UI", () => {
782
1052
  },
783
1053
  };
784
1054
  sharedCoordinator = new MarketDataCoordinator(provider);
1055
+ const instrument = instrumentFromTicker(seededTicker, seededTicker.metadata.ticker);
1056
+ if (!instrument) throw new Error("expected ticker instrument");
1057
+ sharedCoordinator.primeCachedFinancials([{
1058
+ instrument,
1059
+ financials: {
1060
+ annualStatements: [],
1061
+ quarterlyStatements: [],
1062
+ priceHistory: [],
1063
+ quote: seededQuote,
1064
+ },
1065
+ }]);
785
1066
  setSharedMarketDataCoordinator(sharedCoordinator);
786
1067
 
787
1068
  testSetup = await testRender(
788
- <PortfolioHarness config={config} collectionId="broker:ibkr-flex:DU12345" />,
1069
+ <PortfolioHarness
1070
+ config={config}
1071
+ collectionId="broker:ibkr-flex:DU12345"
1072
+ ticker={seededTicker}
1073
+ quote={seededQuote}
1074
+ />,
789
1075
  { width: 100, height: 12 },
790
1076
  );
791
1077
 
@@ -842,10 +1128,11 @@ describe("PortfolioListPane cash and margin UI", () => {
842
1128
  quote: makeQuote({
843
1129
  symbol: "AAPL",
844
1130
  price: 125,
845
- marketCap: undefined,
1131
+ marketCap: 2_000_000_000,
846
1132
  }),
847
1133
  fundamentals: {
848
1134
  trailingPE: 25,
1135
+ forwardPE: 22,
849
1136
  },
850
1137
  profile: {
851
1138
  sector: "Technology",
@@ -1157,7 +1444,7 @@ describe("PortfolioListPane cash and margin UI", () => {
1157
1444
  expect(frame).toContain("+5.41%");
1158
1445
  });
1159
1446
 
1160
- test("quote flash keeps the existing row background intact", async () => {
1447
+ test("quote flash dims cells without changing quote sign color or row background", async () => {
1161
1448
  const config = createPortfolioConfigWithColumns(
1162
1449
  "broker:ibkr-flex:DU12345",
1163
1450
  ["ticker", "price", "change", "latency"],
@@ -1174,7 +1461,18 @@ describe("PortfolioListPane cash and margin UI", () => {
1174
1461
  ["MSFT", makeTicker({ ticker: "MSFT", name: "Microsoft" })],
1175
1462
  ]);
1176
1463
  state.financials = new Map([
1177
- ["AAPL", { annualStatements: [], quarterlyStatements: [], priceHistory: [], quote: makeQuote() }],
1464
+ ["AAPL", {
1465
+ annualStatements: [],
1466
+ quarterlyStatements: [],
1467
+ priceHistory: [],
1468
+ quote: makeQuote({
1469
+ price: 125,
1470
+ bid: 124.95,
1471
+ ask: 125.05,
1472
+ change: -5,
1473
+ changePercent: -4.17,
1474
+ }),
1475
+ }],
1178
1476
  ["MSFT", {
1179
1477
  annualStatements: [],
1180
1478
  quarterlyStatements: [],
@@ -1203,7 +1501,7 @@ describe("PortfolioListPane cash and margin UI", () => {
1203
1501
 
1204
1502
  await flushFrame();
1205
1503
  const beforeFrame = testSetup.captureSpans();
1206
- const beforeLine = beforeFrame.lines.find((line) => line.spans.map((span) => span.text).join("").includes("AAPL"));
1504
+ const beforeLine = beforeFrame.lines.find((line) => lineText(line).includes("AAPL"));
1207
1505
  expect(beforeLine).toBeDefined();
1208
1506
 
1209
1507
  await act(async () => {
@@ -1214,8 +1512,8 @@ describe("PortfolioListPane cash and margin UI", () => {
1214
1512
  price: 126,
1215
1513
  bid: 125.95,
1216
1514
  ask: 126.05,
1217
- change: 6,
1218
- changePercent: 5,
1515
+ change: -4,
1516
+ changePercent: -3.08,
1219
1517
  lastUpdated: Date.now() + 1_000,
1220
1518
  }),
1221
1519
  });
@@ -1223,11 +1521,15 @@ describe("PortfolioListPane cash and margin UI", () => {
1223
1521
  await flushFrame();
1224
1522
 
1225
1523
  const frame = testSetup.captureSpans();
1226
- const aaplLine = frame.lines.find((line) => line.spans.map((span) => span.text).join("").includes("AAPL"));
1524
+ const aaplLine = frame.lines.find((line) => lineText(line).includes("AAPL"));
1227
1525
  expect(aaplLine).toBeDefined();
1228
1526
 
1229
1527
  const beforeBackgrounds = beforeLine!.spans.map((span) => span.bg.toInts().join(","));
1230
1528
  const afterBackgrounds = aaplLine!.spans.map((span) => span.bg.toInts().join(","));
1231
1529
  expect(afterBackgrounds).toEqual(beforeBackgrounds);
1530
+ const priceSpan = aaplLine!.spans.find((span) => span.text.includes("126"));
1531
+ expect(priceSpan).toBeDefined();
1532
+ expect(priceSpan!.fg.toInts().join(",")).toBe(RGBA.fromHex(colors.negative).toInts().join(","));
1533
+ expect(priceSpan!.attributes & TextAttributes.DIM).not.toBe(0);
1232
1534
  });
1233
1535
  });
@@ -1,10 +1,13 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act } from "react";
2
3
  import { testRender } from "../../renderers/opentui/test-utils";
3
4
  import type { TickerFinancials } from "../../types/financials";
4
5
  import type { TickerRecord } from "../../types/ticker";
5
6
  import { createDefaultConfig } from "../../types/config";
6
7
  import { AppContext, createInitialState, PaneInstanceProvider } from "../../state/app-context";
7
8
  import { QuoteMonitorPane } from "./ticker-detail";
9
+ import { PluginRenderProvider, type PluginRuntimeAccess } from "../plugin-runtime";
10
+ import type { PinTickerOptions } from "../../types/plugin";
8
11
 
9
12
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
10
13
 
@@ -15,27 +18,57 @@ afterEach(() => {
15
18
  }
16
19
  });
17
20
 
18
- function createQuoteMonitorHarness() {
19
- const config = createDefaultConfig("/tmp/gloomberb-test");
20
- (config.layout as typeof config.layout & { docked: Array<{ instanceId: string; columnIndex: number; order: number }> }).docked = [
21
- { instanceId: "ticker-detail:main", columnIndex: 0, order: 0 },
22
- { instanceId: "quote-monitor:test", columnIndex: 1, order: 0 },
23
- ];
24
- config.layout.instances.push({
25
- instanceId: "quote-monitor:test",
26
- paneId: "quote-monitor",
27
- title: "MSFT",
28
- binding: { kind: "fixed", symbol: "MSFT" },
29
- });
21
+ interface PinTickerCall {
22
+ symbol: string;
23
+ options?: PinTickerOptions;
24
+ }
30
25
 
31
- const state = createInitialState(config);
26
+ function makeRuntime(options: {
27
+ pinCalls?: PinTickerCall[];
28
+ settingsCalls?: Array<string | undefined>;
29
+ } = {}): PluginRuntimeAccess {
30
+ return {
31
+ getMarketData: () => null,
32
+ getCapability: () => null,
33
+ getBrokerAdapter: () => null,
34
+ connectBrokerInstance: async () => {},
35
+ updateBrokerInstance: async () => {},
36
+ syncBrokerInstance: async () => {},
37
+ removeBrokerInstance: async () => {},
38
+ pinTicker: (symbol, pinOptions) => {
39
+ options.pinCalls?.push({ symbol, options: pinOptions });
40
+ },
41
+ navigateTicker: () => {},
42
+ selectTicker: () => {},
43
+ switchTab: () => {},
44
+ switchPanel: () => {},
45
+ openCommandBar: () => {},
46
+ showPane: () => {},
47
+ hidePane: () => {},
48
+ openPaneSettings: (paneId) => {
49
+ options.settingsCalls?.push(paneId);
50
+ },
51
+ openPluginCommandWorkflow: () => {},
52
+ notify: () => {},
53
+ subscribeResumeState: () => () => {},
54
+ getResumeState: () => null,
55
+ setResumeState: () => {},
56
+ deleteResumeState: () => {},
57
+ getConfigState: () => null,
58
+ setConfigState: async () => {},
59
+ setConfigStates: async () => {},
60
+ deleteConfigState: async () => {},
61
+ getConfigStateKeys: () => [],
62
+ };
63
+ }
32
64
 
33
- const ticker: TickerRecord = {
65
+ function makeTicker(symbol: string, name: string): TickerRecord {
66
+ return {
34
67
  metadata: {
35
- ticker: "MSFT",
68
+ ticker: symbol,
36
69
  exchange: "NASDAQ",
37
70
  currency: "USD",
38
- name: "Microsoft",
71
+ name,
39
72
  portfolios: [],
40
73
  watchlists: [],
41
74
  positions: [],
@@ -43,28 +76,71 @@ function createQuoteMonitorHarness() {
43
76
  tags: [],
44
77
  },
45
78
  };
79
+ }
46
80
 
47
- const financials: TickerFinancials = {
81
+ function makeFinancials(symbol: string, price: number, change: number, changePercent: number): TickerFinancials {
82
+ return {
48
83
  quote: {
49
- symbol: "MSFT",
50
- price: 356.15,
84
+ symbol,
85
+ price,
51
86
  currency: "USD",
52
- change: -9.82,
53
- changePercent: -2.68,
87
+ change,
88
+ changePercent,
54
89
  lastUpdated: Date.now(),
55
90
  },
56
91
  annualStatements: [],
57
92
  quarterlyStatements: [],
58
- priceHistory: [],
93
+ priceHistory: Array.from({ length: 12 }, (_, index) => ({
94
+ date: new Date(Date.UTC(2026, 0, index + 1)),
95
+ close: price - 6 + index,
96
+ })),
59
97
  };
98
+ }
99
+
100
+ function createQuoteMonitorHarness(options: {
101
+ symbols?: string[];
102
+ pinCalls?: PinTickerCall[];
103
+ settingsCalls?: Array<string | undefined>;
104
+ } = {}) {
105
+ const symbols = options.symbols ?? ["MSFT"];
106
+ const config = createDefaultConfig("/tmp/gloomberb-test");
107
+ (config.layout as typeof config.layout & { docked: Array<{ instanceId: string; columnIndex: number; order: number }> }).docked = [
108
+ { instanceId: "ticker-detail:main", columnIndex: 0, order: 0 },
109
+ { instanceId: "quote-monitor:test", columnIndex: 1, order: 0 },
110
+ ];
111
+ config.layout.instances.push({
112
+ instanceId: "quote-monitor:test",
113
+ paneId: "quote-monitor",
114
+ title: symbols.join(" · "),
115
+ binding: { kind: "fixed", symbol: symbols[0]! },
116
+ settings: {
117
+ symbol: symbols[0]!,
118
+ symbols,
119
+ symbolsText: symbols.join(", "),
120
+ },
121
+ });
122
+
123
+ const state = createInitialState(config);
60
124
 
61
- state.tickers = new Map([["MSFT", ticker]]);
62
- state.financials = new Map([["MSFT", financials]]);
125
+ const tickers = [
126
+ makeTicker("MSFT", "Microsoft"),
127
+ makeTicker("AAPL", "Apple"),
128
+ ];
129
+ state.tickers = new Map(tickers.map((ticker) => [ticker.metadata.ticker, ticker]));
130
+ state.financials = new Map([
131
+ ["MSFT", makeFinancials("MSFT", 356.15, -9.82, -2.68)],
132
+ ["AAPL", makeFinancials("AAPL", 202.12, 3.05, 1.53)],
133
+ ]);
63
134
 
64
135
  return (
65
136
  <AppContext value={{ state, dispatch: () => {} }}>
66
137
  <PaneInstanceProvider paneId="quote-monitor:test">
67
- <QuoteMonitorPane paneId="quote-monitor:test" paneType="quote-monitor" focused width={72} height={7} />
138
+ <PluginRenderProvider pluginId="ticker-detail" runtime={makeRuntime({
139
+ pinCalls: options.pinCalls,
140
+ settingsCalls: options.settingsCalls,
141
+ })}>
142
+ <QuoteMonitorPane paneId="quote-monitor:test" paneType="quote-monitor" focused width={72} height={7} />
143
+ </PluginRenderProvider>
68
144
  </PaneInstanceProvider>
69
145
  </AppContext>
70
146
  );
@@ -84,6 +160,63 @@ describe("QuoteMonitorPane", () => {
84
160
  expect(frame).toContain("$356.15");
85
161
  expect(frame).toContain("-2.68%");
86
162
  expect(frame).toContain("-9.82");
163
+ expect(frame).toMatch(/[⠁-⣿]/);
87
164
  });
88
165
 
166
+ test("renders multiple configured tickers", async () => {
167
+ testSetup = await testRender(createQuoteMonitorHarness({ symbols: ["MSFT", "AAPL"] }), {
168
+ width: 72,
169
+ height: 8,
170
+ });
171
+
172
+ await testSetup.renderOnce();
173
+
174
+ const frame = testSetup.captureCharFrame();
175
+ expect(frame).toContain("MSFT");
176
+ expect(frame).toContain("AAPL");
177
+ expect(frame).toContain("$356.15");
178
+ expect(frame).toContain("$202.12");
179
+ });
180
+
181
+ test("opens a ticker detail pane on the second card click", async () => {
182
+ const pinCalls: PinTickerCall[] = [];
183
+ testSetup = await testRender(createQuoteMonitorHarness({ pinCalls }), {
184
+ width: 72,
185
+ height: 7,
186
+ });
187
+
188
+ await testSetup.renderOnce();
189
+ const frame = testSetup.captureCharFrame();
190
+ const row = frame.split("\n").findIndex((line) => line.includes("MSFT"));
191
+ expect(row).toBeGreaterThanOrEqual(0);
192
+
193
+ await act(async () => {
194
+ await testSetup!.mockMouse.click(3, row);
195
+ await testSetup!.renderOnce();
196
+ await testSetup!.mockMouse.click(3, row);
197
+ await testSetup!.renderOnce();
198
+ });
199
+
200
+ expect(pinCalls).toEqual([{
201
+ symbol: "MSFT",
202
+ options: { paneType: "ticker-detail", floating: true },
203
+ }]);
204
+ });
205
+
206
+ test("opens pane settings when t is pressed", async () => {
207
+ const settingsCalls: Array<string | undefined> = [];
208
+ testSetup = await testRender(createQuoteMonitorHarness({ settingsCalls }), {
209
+ width: 72,
210
+ height: 7,
211
+ });
212
+
213
+ await testSetup.renderOnce();
214
+
215
+ await act(async () => {
216
+ testSetup!.mockInput.pressKey("t");
217
+ await testSetup!.renderOnce();
218
+ });
219
+
220
+ expect(settingsCalls).toEqual(["quote-monitor:test"]);
221
+ });
89
222
  });