gloomberb 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +63 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +6 -0
  118. package/src/utils/api-client.test.ts +32 -0
  119. package/src/utils/api-client.ts +119 -18
  120. package/src/utils/double-escape-close.test.ts +33 -0
  121. package/src/utils/double-escape-close.ts +40 -0
  122. package/src/utils/throttled-fetch.test.ts +22 -0
  123. package/src/utils/throttled-fetch.ts +3 -1
  124. package/src/utils/ticker-search.ts +53 -9
  125. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Box, ContextMenuProvider, useNativeRenderer, useRendererHost } from "./ui";
2
2
  import { ToastViewport, useToastHost } from "./ui/toast";
3
- import { useState, useEffect, useCallback, useRef, useMemo } from "react";
3
+ import { useState, useEffect, useCallback, useRef, useMemo, type ReactNode } from "react";
4
4
  import { useShortcut } from "./react/input";
5
5
  import {
6
6
  AppProvider,
@@ -23,7 +23,7 @@ import { OnboardingWizard } from "./components/onboarding/onboarding-wizard";
23
23
  import { useDialog, useDialogState } from "./ui/dialog";
24
24
  import { PluginRegistry } from "./plugins/registry";
25
25
  import type { TickerRepository } from "./data/ticker-repository";
26
- import { colors, syncTheme } from "./theme/colors";
26
+ import { ThemeProvider, useThemeColors } from "./theme/theme-context";
27
27
  import {
28
28
  createPaneInstance,
29
29
  findPaneInstance,
@@ -45,7 +45,7 @@ import type { TickerRecord } from "./types/ticker";
45
45
  import type { DataProvider } from "./types/data-provider";
46
46
  import type { TickerFinancials } from "./types/financials";
47
47
  import type { BrokerAccount } from "./types/trading";
48
- import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopWindowBridge } from "./types/desktop-window";
48
+ import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemePreviewState, DesktopWindowBridge } from "./types/desktop-window";
49
49
  import type { DesktopApplicationMenuBridge } from "./types/desktop-menu";
50
50
  import { resolveTickerSearch, upsertTickerFromSearchResult } from "./utils/ticker-search";
51
51
 
@@ -95,6 +95,10 @@ import {
95
95
  createPaneTemplateOrThrow,
96
96
  } from "./components/command-bar/workflow-ops";
97
97
  import { getPaneTemplateDisplayLabel } from "./components/command-bar/pane-template-display";
98
+ import {
99
+ resolveTickerNavigationDetailPane,
100
+ shouldFocusTickerNavigationTarget,
101
+ } from "./plugins/ticker-navigation";
98
102
  import { debugLog } from "./utils/debug-log";
99
103
  import type { MarketDataCoordinator } from "./market-data/coordinator";
100
104
  import { instrumentFromTicker } from "./market-data/request-types";
@@ -136,6 +140,15 @@ interface AppInnerProps {
136
140
  desktopApplicationMenuBridge?: DesktopApplicationMenuBridge;
137
141
  }
138
142
 
143
+ function ThemedAppRoot({ children }: { children: ReactNode }) {
144
+ const themeColors = useThemeColors();
145
+ return (
146
+ <Box flexDirection="column" flexGrow={1} backgroundColor={themeColors.bg}>
147
+ {children}
148
+ </Box>
149
+ );
150
+ }
151
+
139
152
  function AppInner({
140
153
  pluginRegistry,
141
154
  tickerRepository,
@@ -511,6 +524,96 @@ function AppInner({
511
524
  });
512
525
  }, [performRefreshQuote]);
513
526
 
527
+ const refreshTickersBatch = useCallback((entries: Array<{ ticker: TickerRecord; priority: number }>) => {
528
+ const runnable = entries.filter(({ ticker }) => {
529
+ const symbol = ticker.metadata.ticker;
530
+ return !refreshInFlight.has(symbol) && !pendingRefreshesRef.current.financials.has(symbol);
531
+ });
532
+ if (runnable.length === 0) return;
533
+ for (const { ticker } of runnable) {
534
+ pendingRefreshesRef.current.financials.add(ticker.metadata.ticker);
535
+ }
536
+ const priority = Math.min(...runnable.map((entry) => entry.priority));
537
+ refreshQueueRef.current.queue.enqueue({
538
+ key: `financials-batch:${priority}:${runnable.map(({ ticker }) => ticker.metadata.ticker).join(",")}`,
539
+ priority,
540
+ run: async () => {
541
+ const instrumentEntries = runnable.flatMap(({ ticker }) => {
542
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
543
+ return instrument ? [{ ticker, instrument }] : [];
544
+ });
545
+ for (const { ticker } of runnable) {
546
+ const symbol = ticker.metadata.ticker;
547
+ refreshInFlight.add(symbol);
548
+ dispatch({ type: "SET_REFRESHING", symbol, refreshing: true });
549
+ }
550
+ try {
551
+ const entries = await marketData.loadSnapshotsBatch(instrumentEntries.map((entry) => entry.instrument));
552
+ entries.forEach((entry, index) => {
553
+ const ticker = instrumentEntries[index]?.ticker;
554
+ const data = entry.data ?? entry.lastGoodData;
555
+ if (ticker && data) {
556
+ pluginRegistry.events.emit("ticker:refreshed", { symbol: ticker.metadata.ticker, financials: data });
557
+ const currency = data.quote?.currency;
558
+ if (currency) void marketData.loadFxRate(currency).catch(() => {});
559
+ }
560
+ });
561
+ void marketData.loadFxRate(state.config.baseCurrency).catch(() => {});
562
+ } finally {
563
+ for (const { ticker } of runnable) {
564
+ const symbol = ticker.metadata.ticker;
565
+ refreshInFlight.delete(symbol);
566
+ pendingRefreshesRef.current.financials.delete(symbol);
567
+ dispatch({ type: "SET_REFRESHING", symbol, refreshing: false });
568
+ }
569
+ }
570
+ },
571
+ });
572
+ }, [dispatch, marketData, pluginRegistry.events, state.config.baseCurrency]);
573
+
574
+ const refreshQuotesBatch = useCallback((entries: Array<{ ticker: TickerRecord; priority: number }>) => {
575
+ const runnable = entries.filter(({ ticker }) => {
576
+ const symbol = ticker.metadata.ticker;
577
+ return !refreshInFlight.has(symbol)
578
+ && !quoteRefreshInFlight.has(symbol)
579
+ && !pendingRefreshesRef.current.financials.has(symbol)
580
+ && !pendingRefreshesRef.current.quotes.has(symbol);
581
+ });
582
+ if (runnable.length === 0) return;
583
+ for (const { ticker } of runnable) {
584
+ pendingRefreshesRef.current.quotes.add(ticker.metadata.ticker);
585
+ }
586
+ const priority = Math.min(...runnable.map((entry) => entry.priority));
587
+ refreshQueueRef.current.queue.enqueue({
588
+ key: `quotes-batch:${priority}:${runnable.map(({ ticker }) => ticker.metadata.ticker).join(",")}`,
589
+ priority,
590
+ run: async () => {
591
+ const instrumentEntries = runnable.flatMap(({ ticker }) => {
592
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
593
+ return instrument ? [{ ticker, instrument }] : [];
594
+ });
595
+ for (const { ticker } of runnable) {
596
+ quoteRefreshInFlight.add(ticker.metadata.ticker);
597
+ }
598
+ try {
599
+ const entries = await marketData.loadQuotesBatch(instrumentEntries.map((entry) => entry.instrument));
600
+ entries.forEach((entry) => {
601
+ const quote = entry.data ?? entry.lastGoodData;
602
+ const currency = quote?.currency;
603
+ if (currency) void marketData.loadFxRate(currency).catch(() => {});
604
+ });
605
+ void marketData.loadFxRate(state.config.baseCurrency).catch(() => {});
606
+ } finally {
607
+ for (const { ticker } of runnable) {
608
+ const symbol = ticker.metadata.ticker;
609
+ quoteRefreshInFlight.delete(symbol);
610
+ pendingRefreshesRef.current.quotes.delete(symbol);
611
+ }
612
+ }
613
+ },
614
+ });
615
+ }, [marketData, state.config.baseCurrency]);
616
+
514
617
  const primeCachedFinancials = useCallback((entries: Array<{ ticker: TickerRecord; financials: TickerFinancials }>) => {
515
618
  const primeEntries = entries.flatMap(({ ticker, financials }) => {
516
619
  const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
@@ -721,6 +824,8 @@ function AppInner({
721
824
  primeCachedFinancials,
722
825
  refreshTicker,
723
826
  refreshQuote,
827
+ refreshTickersBatch,
828
+ refreshQuotesBatch,
724
829
  autoImportBrokerPositions,
725
830
  persistedBrokerAccounts,
726
831
  }), {
@@ -732,7 +837,7 @@ function AppInner({
732
837
  // Will show empty state
733
838
  }
734
839
  })();
735
- }, [autoImportBrokerPositions, dataProvider, dispatch, primeCachedFinancials, tickerRepository, refreshQuote, refreshTicker, sessionSnapshot, state.config, state.initialized]);
840
+ }, [autoImportBrokerPositions, dataProvider, dispatch, primeCachedFinancials, tickerRepository, refreshQuote, refreshQuotesBatch, refreshTicker, refreshTickersBatch, sessionSnapshot, state.config, state.initialized]);
736
841
 
737
842
  const focusedTickerSymbol = getFocusedTickerSymbol(state);
738
843
 
@@ -1273,8 +1378,9 @@ function AppInner({
1273
1378
  activatePane(instance.instanceId, nextLayout);
1274
1379
  };
1275
1380
 
1276
- pluginRegistry.navigateTickerFn = (rawSymbol) => {
1381
+ pluginRegistry.navigateTickerFn = (rawSymbol, options) => {
1277
1382
  if (isDetachedWindow) return;
1383
+ const sourcePaneId = options?.sourcePaneId ?? stateRef.current.focusedPaneId;
1278
1384
  (async () => {
1279
1385
  try {
1280
1386
  // Resolve or create the ticker in the local database
@@ -1297,45 +1403,31 @@ function AppInner({
1297
1403
  }
1298
1404
  }
1299
1405
 
1300
- // Active panel resolution — navigate the focused or linked detail pane:
1301
- // 1. If the focused pane IS a ticker-detail, retarget it directly
1302
- // 2. If a ticker-detail follows the focused pane, retarget that
1406
+ // Active panel resolution — navigate the initiating or linked detail pane:
1407
+ // 1. If the source pane IS a ticker-detail, retarget it directly
1408
+ // 2. If a ticker-detail follows the source pane, retarget that
1303
1409
  // 3. Any follow-mode ticker-detail in the layout
1304
1410
  // 4. Any ticker-detail in the layout
1305
1411
  // 5. Fall back to pinning a new pane
1306
1412
  const currentState = stateRef.current;
1307
1413
  const currentLayout = currentState.config.layout;
1308
- const focused = currentState.focusedPaneId;
1309
-
1310
- const focusedInstance = focused
1311
- ? findPaneInstance(currentLayout, focused)
1312
- : null;
1313
-
1314
- const detailPane =
1315
- (focusedInstance?.paneId === "ticker-detail" && isPaneInLayout(currentLayout, focusedInstance.instanceId)
1316
- ? focusedInstance
1317
- : null)
1318
- ?? currentLayout.instances.find((inst) =>
1319
- inst.paneId === "ticker-detail"
1320
- && inst.binding?.kind === "follow"
1321
- && inst.binding.sourceInstanceId === focused
1322
- && isPaneInLayout(currentLayout, inst.instanceId),
1323
- )
1324
- ?? currentLayout.instances.find((inst) =>
1325
- inst.paneId === "ticker-detail"
1326
- && inst.binding?.kind === "follow"
1327
- && isPaneInLayout(currentLayout, inst.instanceId),
1328
- )
1329
- ?? currentLayout.instances.find((inst) =>
1330
- inst.paneId === "ticker-detail"
1331
- && isPaneInLayout(currentLayout, inst.instanceId),
1332
- );
1414
+ const detailPane = resolveTickerNavigationDetailPane(currentLayout, sourcePaneId);
1415
+ const focusIfStillOwned = (paneId: string, layout: LayoutConfig) => {
1416
+ if (!shouldFocusTickerNavigationTarget({
1417
+ sourcePaneId,
1418
+ currentFocusedPaneId: stateRef.current.focusedPaneId,
1419
+ targetPaneId: paneId,
1420
+ })) {
1421
+ return;
1422
+ }
1423
+ activatePane(paneId, layout);
1424
+ };
1333
1425
 
1334
1426
  if (detailPane) {
1335
1427
  if (detailPane.binding?.kind === "follow") {
1336
1428
  const sourceId = detailPane.binding.sourceInstanceId;
1337
1429
  dispatch({ type: "UPDATE_PANE_STATE", paneId: sourceId, patch: { cursorSymbol: symbol } });
1338
- activatePane(detailPane.instanceId, currentLayout);
1430
+ focusIfStillOwned(detailPane.instanceId, currentLayout);
1339
1431
  } else {
1340
1432
  const nextLayout = {
1341
1433
  ...currentLayout,
@@ -1346,9 +1438,13 @@ function AppInner({
1346
1438
  )),
1347
1439
  };
1348
1440
  persistLayout(nextLayout);
1349
- activatePane(detailPane.instanceId, nextLayout);
1441
+ focusIfStillOwned(detailPane.instanceId, nextLayout);
1350
1442
  }
1351
- } else {
1443
+ } else if (shouldFocusTickerNavigationTarget({
1444
+ sourcePaneId,
1445
+ currentFocusedPaneId: stateRef.current.focusedPaneId,
1446
+ targetPaneId: null,
1447
+ })) {
1352
1448
  pluginRegistry.pinTicker(symbol, { floating: false });
1353
1449
  }
1354
1450
  } catch (err) {
@@ -1497,20 +1593,20 @@ function AppInner({
1497
1593
  if (desktopWindowBridge?.kind === "detached" && desktopWindowBridge.paneId) {
1498
1594
  return (
1499
1595
  <ContextMenuProvider pluginRegistry={pluginRegistry}>
1500
- <Box flexDirection="column" flexGrow={1} backgroundColor={colors.bg}>
1596
+ <ThemedAppRoot>
1501
1597
  <DetachedPaneShell
1502
1598
  pluginRegistry={pluginRegistry}
1503
1599
  desktopWindowBridge={{ ...desktopWindowBridge, kind: "detached", paneId: desktopWindowBridge.paneId }}
1504
1600
  />
1505
1601
  <ToastViewport position="bottom-right" />
1506
- </Box>
1602
+ </ThemedAppRoot>
1507
1603
  </ContextMenuProvider>
1508
1604
  );
1509
1605
  }
1510
1606
 
1511
1607
  return (
1512
1608
  <ContextMenuProvider pluginRegistry={pluginRegistry}>
1513
- <Box flexDirection="column" flexGrow={1} backgroundColor={colors.bg}>
1609
+ <ThemedAppRoot>
1514
1610
  <Header />
1515
1611
  <Shell
1516
1612
  pluginRegistry={pluginRegistry}
@@ -1528,7 +1624,7 @@ function AppInner({
1528
1624
  />
1529
1625
  )}
1530
1626
  <ToastViewport position="bottom-right" />
1531
- </Box>
1627
+ </ThemedAppRoot>
1532
1628
  </ContextMenuProvider>
1533
1629
  );
1534
1630
  }
@@ -1540,6 +1636,7 @@ interface AppProps {
1540
1636
  desktopWindowBridge?: DesktopWindowBridge;
1541
1637
  desktopApplicationMenuBridge?: DesktopApplicationMenuBridge;
1542
1638
  desktopSnapshot?: DesktopSharedStateSnapshot | null;
1639
+ desktopThemePreview?: DesktopThemePreviewState | null;
1543
1640
  }
1544
1641
 
1545
1642
  export function App({
@@ -1549,6 +1646,7 @@ export function App({
1549
1646
  desktopWindowBridge,
1550
1647
  desktopApplicationMenuBridge,
1551
1648
  desktopSnapshot = null,
1649
+ desktopThemePreview = null,
1552
1650
  }: AppProps) {
1553
1651
  const renderer = useNativeRenderer();
1554
1652
  const effectiveInitialConfig = useMemo(() => {
@@ -1579,9 +1677,6 @@ export function App({
1579
1677
  });
1580
1678
  const [showOnboarding, setShowOnboarding] = useState(!effectiveInitialConfig.onboardingComplete);
1581
1679
 
1582
- // Keep the shared palette aligned before this render builds any JSX that reads `colors`.
1583
- syncTheme(config.theme);
1584
-
1585
1680
  useEffect(() => bindAppActivity(renderer), [renderer]);
1586
1681
 
1587
1682
  const services = useMemo(() => {
@@ -1637,14 +1732,16 @@ export function App({
1637
1732
 
1638
1733
  if (showOnboarding) {
1639
1734
  return (
1640
- <OnboardingWizard
1641
- config={config}
1642
- pluginRegistry={services.pluginRegistry}
1643
- onComplete={(updatedConfig) => {
1644
- setConfig(updatedConfig);
1645
- setShowOnboarding(false);
1646
- }}
1647
- />
1735
+ <ThemeProvider themeId={config.theme}>
1736
+ <OnboardingWizard
1737
+ config={config}
1738
+ pluginRegistry={services.pluginRegistry}
1739
+ onComplete={(updatedConfig) => {
1740
+ setConfig(updatedConfig);
1741
+ setShowOnboarding(false);
1742
+ }}
1743
+ />
1744
+ </ThemeProvider>
1648
1745
  );
1649
1746
  }
1650
1747
 
@@ -1655,6 +1752,7 @@ export function App({
1655
1752
  sessionSnapshot={sessionSnapshot}
1656
1753
  desktopBridge={desktopWindowBridge}
1657
1754
  desktopSnapshot={desktopSnapshot}
1755
+ initialThemePreview={desktopThemePreview}
1658
1756
  >
1659
1757
  <AppInner
1660
1758
  pluginRegistry={services.pluginRegistry}
@@ -26,6 +26,8 @@ export function assetDataProvider(provider: AssetDataProvider): AssetDataCapabil
26
26
  operations: {
27
27
  canProvide: op((input: any) => provider.canProvide?.(input.ticker, input.exchange, input.context) ?? true, "query"),
28
28
  getCachedFinancialsForTargets: op((input: any) => provider.getCachedFinancialsForTargets?.(input.targets ?? [], input.options) ?? new Map()),
29
+ getQuotesBatch: op((input: any) => provider.getQuotesBatch?.(input.targets ?? [], input.options) ?? Promise.resolve([])),
30
+ getTickerFinancialsBatch: op((input: any) => provider.getTickerFinancialsBatch?.(input.targets ?? [], input.options) ?? Promise.resolve([])),
29
31
  getTickerFinancials: op((input: any) => provider.getTickerFinancials(input.ticker, input.exchange, input.context)),
30
32
  getQuote: op((input: any) => provider.getQuote(input.ticker, input.exchange, input.context)),
31
33
  getExchangeRate: op((input: any) => provider.getExchangeRate(input.fromCurrency)),
@@ -95,6 +97,7 @@ export function newsProvider(options: {
95
97
  supports: op((input: any) => options.provider.supports?.(input.query) ?? true, "query"),
96
98
  getCachedNews: op((input: any) => options.provider.getCachedNews?.(input.query) ?? [], "query"),
97
99
  fetchNews: op((input: any) => options.provider.fetchNews(input.query), "query"),
100
+ fetchNewsStory: op((input: any) => options.provider.fetchNewsStory?.(input.storyId) ?? null, "query"),
98
101
  },
99
102
  };
100
103
  }
@@ -157,10 +157,14 @@ export function createPixelBuffer(width: number, heightPixels: number): PixelBuf
157
157
  }
158
158
 
159
159
  function setPixel(buf: PixelBuffer, x: number, y: number, color: string, layer: number) {
160
- if (x >= 0 && x < buf.width && y >= 0 && y < buf.height) {
161
- const existing = buf.pixels[y]![x];
160
+ const px = Math.round(x);
161
+ const py = Math.round(y);
162
+ if (px >= 0 && px < buf.width && py >= 0 && py < buf.height) {
163
+ const row = buf.pixels[py];
164
+ if (!row) return;
165
+ const existing = row[px];
162
166
  if (!existing || layer >= existing.layer) {
163
- buf.pixels[y]![x] = { color, layer };
167
+ row[px] = { color, layer };
164
168
  }
165
169
  }
166
170
  }
@@ -178,17 +182,22 @@ export function drawLine(
178
182
  color: string,
179
183
  layer: number = LAYER_DATA,
180
184
  ) {
181
- let dx = Math.abs(x1 - x0);
182
- let dy = Math.abs(y1 - y0);
183
- const sx = x0 < x1 ? 1 : -1;
184
- const sy = y0 < y1 ? 1 : -1;
185
+ const startX = Math.round(x0);
186
+ const startY = Math.round(y0);
187
+ const endX = Math.round(x1);
188
+ const endY = Math.round(y1);
189
+ if (![startX, startY, endX, endY].every(Number.isFinite)) return;
190
+ let dx = Math.abs(endX - startX);
191
+ let dy = Math.abs(endY - startY);
192
+ const sx = startX < endX ? 1 : -1;
193
+ const sy = startY < endY ? 1 : -1;
185
194
  let err = dx - dy;
186
- let x = x0;
187
- let y = y0;
195
+ let x = startX;
196
+ let y = startY;
188
197
 
189
198
  while (true) {
190
199
  setPixel(buf, x, y, color, layer);
191
- if (x === x1 && y === y1) break;
200
+ if (x === endX && y === endY) break;
192
201
  const e2 = 2 * err;
193
202
  if (e2 > -dy) {
194
203
  err -= dy;
@@ -492,11 +501,14 @@ export function drawGridLines(
492
501
  yPositions: number[],
493
502
  color: string,
494
503
  ) {
495
- for (const y of yPositions) {
504
+ for (const rawY of yPositions) {
505
+ const y = Math.round(rawY);
496
506
  if (y < 0 || y >= buf.height) continue;
497
507
  // Dotted horizontal line — dot every 6th column (≈ every 3 terminal columns)
508
+ const row = buf.pixels[y];
509
+ if (!row) continue;
498
510
  for (let x = 0; x < buf.width; x++) {
499
- if (x % 6 === 0 && !buf.pixels[y]![x]) {
511
+ if (x % 6 === 0 && !row[x]) {
500
512
  setPixel(buf, x, y, color, LAYER_GRID);
501
513
  }
502
514
  }
@@ -7,6 +7,8 @@ import {
7
7
  } from "./chart-data";
8
8
  import { stepCursorTowards } from "./cursor-motion";
9
9
  import { buildChartScene, buildCursorTimeAxisSegments, buildTimeAxis, formatAxisValue, formatCursorAxisValue, renderChart, resolveChartAxisWidth, resolveChartPalette } from "./chart-renderer";
10
+ import { buildCursorTimeAxisOverlay } from "./time-axis-label";
11
+ import { buildCursorPriceAxisOverlay } from "./price-axis-labels";
10
12
  import type { PricePoint } from "../../types/financials";
11
13
  import type { ChartRenderMode } from "./chart-types";
12
14
 
@@ -429,6 +431,47 @@ describe("renderChart", () => {
429
431
  expect(segments.find((segment) => segment.highlighted)?.text).toBe("12:30");
430
432
  });
431
433
 
434
+ test("positions desktop cursor x-axis overlay from exact pixels", () => {
435
+ const dates = Array.from({ length: 13 }, (_, index) => new Date(2026, 0, 5, 9, 30 + index * 30));
436
+ const width = 72;
437
+ const cursorPixelX = 291.5;
438
+ const cellWidthPx = 8;
439
+ const segments = buildCursorTimeAxisSegments({
440
+ timeLabels: buildTimeAxis(dates, width),
441
+ width,
442
+ cursorColumn: 36,
443
+ cursorDate: dates[6]!,
444
+ dates,
445
+ });
446
+ const overlay = buildCursorTimeAxisOverlay({
447
+ segments,
448
+ width,
449
+ cursorPixelX,
450
+ cellWidthPx,
451
+ });
452
+
453
+ expect(overlay.label).toBe("12:30");
454
+ expect(overlay.baseText).toHaveLength(width);
455
+ expect(overlay.leftPercent).toBeCloseTo((cursorPixelX / (width * cellWidthPx - 1)) * 100, 5);
456
+ });
457
+
458
+ test("positions desktop cursor y-axis overlay from exact pixels", () => {
459
+ const height = 8;
460
+ const cellHeightPx = 18;
461
+ const cursorPixelY = 45.25;
462
+ const overlay = buildCursorPriceAxisOverlay({
463
+ axisWidth: 7,
464
+ axisSectionWidth: 8,
465
+ height,
466
+ cursorPixelY,
467
+ cursorLabel: "$214.03",
468
+ cellHeightPx,
469
+ });
470
+
471
+ expect(overlay.labelText).toBe("$214.03 ");
472
+ expect(overlay.topPercent).toBeCloseTo((cursorPixelY / (height * cellHeightPx - 1)) * 100, 5);
473
+ });
474
+
432
475
  test("shows second precision when the visible window reaches second-level data", () => {
433
476
  const dates = Array.from({ length: 6 }, (_, index) => new Date(2026, 0, 5, 9, 30, index * 10));
434
477
 
@@ -119,6 +119,32 @@ describe("renderComparisonChart", () => {
119
119
  expect(result.activeDate?.toISOString()).toBe("2024-01-03T00:00:00.000Z");
120
120
  });
121
121
 
122
+ test("renders when precise desktop layout passes a fractional chart height", () => {
123
+ const projection = projectComparisonChartData([
124
+ makeSeries("AAPL", "#00ff00", [10, 12, 11, 13]),
125
+ ], 12, {
126
+ panOffset: 0,
127
+ zoomLevel: 1,
128
+ renderMode: "area",
129
+ }, "percent");
130
+
131
+ const result = renderComparisonChart(projection, {
132
+ width: 12,
133
+ height: 6.5,
134
+ cursorX: null,
135
+ cursorY: null,
136
+ selectedSymbol: "AAPL",
137
+ colors: {
138
+ bgColor: "#000000",
139
+ gridColor: "#333333",
140
+ crosshairColor: "#ffffff",
141
+ },
142
+ });
143
+
144
+ expect(result.lines.length).toBeGreaterThan(0);
145
+ expect(textLines(result).some((line) => /[^\s]/.test(line))).toBe(true);
146
+ });
147
+
122
148
  test("keeps terminal comparison chart cells transparent", () => {
123
149
  const projection = projectComparisonChartData([
124
150
  makeSeries("AAPL", "#00ff00", [10, 12, 11, 13]),
@@ -80,6 +80,11 @@ function clamp(value: number, min: number, max: number): number {
80
80
  return Math.max(min, Math.min(max, value));
81
81
  }
82
82
 
83
+ function normalizeCount(value: number, min: number): number {
84
+ if (!Number.isFinite(value)) return min;
85
+ return Math.max(Math.floor(value), min);
86
+ }
87
+
83
88
  function getComparisonDotX(index: number, count: number, width: number): number {
84
89
  if (count <= 1) return Math.round(Math.max(width - 1, 0) / 2);
85
90
  return Math.round((index / (count - 1)) * Math.max(width - 1, 0));
@@ -260,15 +265,17 @@ export function buildComparisonChartScene(
260
265
 
261
266
  const min = Math.min(...values);
262
267
  const max = Math.max(...values);
268
+ const width = normalizeCount(opts.width, 1);
269
+ const height = normalizeCount(opts.height, 1);
263
270
  const selectedSeries = getSelectedSeries(projection.series, opts.selectedSymbol);
264
- const activeIdx = getActiveIndex(projection.dates.length, opts.width, opts.cursorX);
271
+ const activeIdx = getActiveIndex(projection.dates.length, width, opts.cursorX);
265
272
  const activeDate = projection.dates[activeIdx] ?? projection.dates[projection.dates.length - 1]!;
266
273
  const selectedPoint = selectedSeries?.points[activeIdx] ?? null;
267
- const chartRows = opts.height;
274
+ const chartRows = height;
268
275
  const range = max - min || 1;
269
276
  const cursorX = opts.cursorX === null
270
277
  ? null
271
- : clamp(opts.cursorX, 0, Math.max(opts.width - 1, 0));
278
+ : clamp(opts.cursorX, 0, Math.max(width - 1, 0));
272
279
  const fallbackValue = selectedPoint?.value ?? selectedSeries?.latestValue ?? min;
273
280
  const cursorY = cursorX === null
274
281
  ? null
@@ -282,7 +289,7 @@ export function buildComparisonChartScene(
282
289
  const cursorColumn = cursorX === null ? null : Math.round(cursorX);
283
290
  const cursorDotX = cursorX === null
284
291
  ? null
285
- : Math.round((cursorX / Math.max(opts.width - 1, 1)) * Math.max(opts.width * 2 - 1, 0));
292
+ : Math.round((cursorX / Math.max(width - 1, 1)) * Math.max(width * 2 - 1, 0));
286
293
  const cursorRow = cursorY === null ? null : Math.round(cursorY);
287
294
  const crosshairValue = cursorY === null
288
295
  ? null
@@ -291,8 +298,8 @@ export function buildComparisonChartScene(
291
298
  return {
292
299
  dates: projection.dates,
293
300
  series: projection.series,
294
- width: opts.width,
295
- height: opts.height,
301
+ width,
302
+ height,
296
303
  chartRows,
297
304
  mode: projection.effectiveMode,
298
305
  axisMode: projection.effectiveAxisMode,
@@ -8,6 +8,7 @@ import { buildChartKey } from "../../market-data/selectors";
8
8
  import { useAppSelector, usePaneSettingValue } from "../../state/app-context";
9
9
  import { usePaneFooter, type PaneHint } from "../layout/pane-footer";
10
10
  import { blendHex, colors, getComparisonSeriesColor, priceColor } from "../../theme/colors";
11
+ import { useThemeColors } from "../../theme/theme-context";
11
12
  import type { BrokerContractRef } from "../../types/instrument";
12
13
  import type { PricePoint } from "../../types/financials";
13
14
  import { formatPercentRaw } from "../../utils/format";
@@ -95,8 +96,9 @@ import {
95
96
  resolveNativeSurfaceVisibleRect,
96
97
  type NativeSurfaceRenderableNode,
97
98
  } from "./native/surface-visibility";
98
- import { formatAxisCell, formatDateShort, resolveChartAxisWidth, type StyledContent } from "./chart-renderer";
99
+ import { formatDateShort, resolveChartAxisWidth, type StyledContent } from "./chart-renderer";
99
100
  import { TimeAxisLabel } from "./time-axis-label";
101
+ import { PriceAxisLabels } from "./price-axis-labels";
100
102
 
101
103
  const MODE_CHIPS: Record<ComparisonChartRenderMode, string> = {
102
104
  area: "A",
@@ -516,6 +518,7 @@ function ComparisonStockChartView({
516
518
  onOpenSymbol,
517
519
  onEditTickers,
518
520
  }: ComparisonStockChartViewProps) {
521
+ useThemeColors();
519
522
  const renderer = useNativeRenderer();
520
523
  const { canvasCharts, cellWidthPx = 8, cellHeightPx = 18, pixelRatio = 1 } = useUiCapabilities();
521
524
  const nativeSurfaceManager = useMemo(() => getNativeSurfaceManager(renderer), [renderer]);
@@ -1827,17 +1830,16 @@ function ComparisonStockChartView({
1827
1830
  );
1828
1831
 
1829
1832
  const axisBox = (
1830
- <Box width={axisSectionWidth} height={chartHeight} flexDirection="column">
1831
- {Array.from({ length: chartHeight }, (_, row) => {
1832
- const isCursorRow = cursorAxisLabel !== null && cursorRow === row;
1833
- const label = isCursorRow ? cursorAxisLabel : (axisLabels.get(row) ?? null);
1834
- return (
1835
- <Text key={row} fg={isCursorRow ? chartColors.crosshairColor : colors.textDim}>
1836
- {formatAxisCell(label, axisWidth).padEnd(axisSectionWidth)}
1837
- </Text>
1838
- );
1839
- })}
1840
- </Box>
1833
+ <PriceAxisLabels
1834
+ axisLabels={axisLabels}
1835
+ axisWidth={axisWidth}
1836
+ axisSectionWidth={axisSectionWidth}
1837
+ height={chartHeight}
1838
+ cursorRow={cursorRow}
1839
+ cursorPixelY={hasDisplayCursor ? displayCursor.pixelY : null}
1840
+ cursorLabel={cursorAxisLabel}
1841
+ cursorColor={chartColors.crosshairColor}
1842
+ />
1841
1843
  );
1842
1844
 
1843
1845
  const legendRowsData = Array.from({ length: Math.ceil(symbols.length / legendColumns) }, (_, rowIndex) => (
@@ -1947,6 +1949,7 @@ function ComparisonStockChartView({
1947
1949
  timeLabels={timeAxisLabel}
1948
1950
  width={chartWidth}
1949
1951
  cursorColumn={cursorTimeAxisColumn}
1952
+ cursorPixelX={hasDisplayCursor ? displayCursor.pixelX : null}
1950
1953
  cursorDate={cursorTimeAxisDate}
1951
1954
  dates={projection.dates}
1952
1955
  cursorColor={chartColors.crosshairColor}