gloomberb 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) 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 +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +21 -3
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/utils/api-client.test.ts +128 -0
  124. package/src/utils/api-client.ts +112 -7
  125. package/src/utils/fuzzy-search.ts +12 -1
  126. package/src/utils/keyboard.ts +31 -0
  127. package/src/utils/ticker-search.test.ts +2 -2
  128. package/src/utils/ticker-search.ts +48 -1
  129. package/src/version.ts +1 -1
@@ -11,9 +11,11 @@ import {
11
11
  } from "../../../components";
12
12
  import { useShortcut } from "../../../react/input";
13
13
  import { blendHex, colors, priceColor } from "../../../theme/colors";
14
+ import { higherContrast } from "../../../theme/color-utils";
14
15
  import type { HolderData, HolderRecord } from "../../../types/financials";
15
16
  import type { GloomPlugin } from "../../../types/plugin";
16
17
  import { formatCompact, formatPercent, formatPercentRaw, padTo } from "../../../utils/format";
18
+ import { isPlainKey } from "../../../utils/keyboard";
17
19
  import { useAssetData, usePluginPaneState } from "../../plugin-runtime";
18
20
  import { createTickerSurfacePaneTemplate } from "../ticker-surface";
19
21
 
@@ -445,6 +447,14 @@ function desktopTileColor(row: HolderRow): string {
445
447
  return blendHex(colors.panel, row.changePercent > 0 ? colors.positive : colors.negative, intensity);
446
448
  }
447
449
 
450
+ function tileTextColor(backgroundColor: string): string {
451
+ return higherContrast(
452
+ higherContrast(colors.text, colors.textBright, backgroundColor),
453
+ colors.selectedText,
454
+ backgroundColor,
455
+ );
456
+ }
457
+
448
458
  function Tile({ tile, selected, currency, marketCap, onSelect }: {
449
459
  tile: TileLayout;
450
460
  selected: boolean;
@@ -455,7 +465,8 @@ function Tile({ tile, selected, currency, marketCap, onSelect }: {
455
465
  const renderWidth = Math.max(1, tile.width - (tile.width > 2 ? 1 : 0));
456
466
  const renderHeight = Math.max(1, tile.height - (tile.height > 2 ? 1 : 0));
457
467
  const innerWidth = Math.max(1, renderWidth - 1);
458
- const textColor = "#ffffff";
468
+ const backgroundColor = selected ? colors.selected : tileColor(tile.row);
469
+ const textColor = tileTextColor(backgroundColor);
459
470
  const attributes = selected ? TextAttributes.BOLD : TextAttributes.NONE;
460
471
  const label = tile.row.name;
461
472
  const amount = tile.row.value != null
@@ -471,7 +482,7 @@ function Tile({ tile, selected, currency, marketCap, onSelect }: {
471
482
  top={tile.y}
472
483
  width={renderWidth}
473
484
  height={renderHeight}
474
- backgroundColor={selected ? colors.selected : tileColor(tile.row)}
485
+ backgroundColor={backgroundColor}
475
486
  onMouseDown={(event: PreventableMouseEvent) => {
476
487
  event.preventDefault();
477
488
  onSelect();
@@ -516,6 +527,7 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
516
527
  const canShowLabel = tile.width >= 4 && tile.height >= 1.4;
517
528
  const isTiny = tile.width < 5 || tile.height < 2;
518
529
  const backgroundColor = desktopTileColor(tile.row);
530
+ const textColor = tileTextColor(backgroundColor);
519
531
  const style: CSSProperties = {
520
532
  position: "absolute",
521
533
  left: `calc(${pct(tile.x, chartWidth)} + 1px)`,
@@ -531,14 +543,14 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
531
543
  ? `1px solid ${colors.textBright}`
532
544
  : hovered
533
545
  ? `1px solid ${blendHex(colors.textBright, backgroundColor, 0.55)}`
534
- : "1px solid rgba(255,255,255,0.11)",
546
+ : `1px solid ${blendHex(backgroundColor, colors.textBright, 0.11)}`,
535
547
  boxShadow: selected
536
- ? "inset 0 0 0 1px rgba(255,255,255,0.55)"
548
+ ? `inset 0 0 0 1px ${blendHex(backgroundColor, colors.textBright, 0.55)}`
537
549
  : hovered
538
- ? "inset 0 0 0 1px rgba(255,255,255,0.18)"
550
+ ? `inset 0 0 0 1px ${blendHex(backgroundColor, colors.textBright, 0.18)}`
539
551
  : "none",
540
552
  backgroundColor,
541
- color: "#ffffff",
553
+ color: textColor,
542
554
  display: "flex",
543
555
  flexDirection: "column",
544
556
  alignItems: "stretch",
@@ -557,7 +569,7 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
557
569
  whiteSpace: "nowrap",
558
570
  lineHeight: 1.1,
559
571
  letterSpacing: 0,
560
- textShadow: "0 1px 1px rgba(0,0,0,0.35)",
572
+ textShadow: `0 1px 1px ${blendHex(backgroundColor, colors.bg, 0.35)}`,
561
573
  };
562
574
 
563
575
  return (
@@ -573,7 +585,7 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
573
585
  >
574
586
  {canShowLabel && (
575
587
  <Text
576
- fg="#ffffff"
588
+ fg={textColor}
577
589
  attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}
578
590
  style={{
579
591
  ...textStyle,
@@ -586,10 +598,10 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
586
598
  )}
587
599
  {canShowDetails && (
588
600
  <>
589
- <Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{amount}</Text>
590
- <Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{ownership ?? change}</Text>
601
+ <Text fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{amount}</Text>
602
+ <Text fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{ownership ?? change}</Text>
591
603
  {ownership && canShowChange && (
592
- <Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{change}</Text>
604
+ <Text fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{change}</Text>
593
605
  )}
594
606
  </>
595
607
  )}
@@ -810,7 +822,7 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
810
822
 
811
823
  useShortcut((event) => {
812
824
  if (!focused || viewMode !== "chart") return;
813
- if (event.name === "j" || event.name === "down") {
825
+ if (isPlainKey(event, "j", "down")) {
814
826
  event.preventDefault?.();
815
827
  event.stopPropagation?.();
816
828
  const nextIdx = Math.min((selectedIdx >= 0 ? selectedIdx : 0) + 1, sortedRows.length - 1);
@@ -818,7 +830,7 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
818
830
  if (nextRow) setSelectedId(nextRow.id);
819
831
  return;
820
832
  }
821
- if (event.name === "k" || event.name === "up") {
833
+ if (isPlainKey(event, "k", "up")) {
822
834
  event.preventDefault?.();
823
835
  event.stopPropagation?.();
824
836
  const nextIdx = Math.max((selectedIdx >= 0 ? selectedIdx : 0) - 1, 0);
@@ -1,4 +1,3 @@
1
- import { saveConfig } from "../../data/config-store";
2
1
  import { findPaneInstance, type LayoutConfig } from "../../types/config";
3
2
  import type { AppNotificationRequest, GloomPlugin, GloomPluginContext } from "../../types/plugin";
4
3
  import type { AppAction } from "../../state/app-context";
@@ -30,15 +29,10 @@ export function clearLayoutManagerDispatch() {
30
29
  getStateRef = null;
31
30
  }
32
31
 
33
- function persistLayout(ctx: Pick<GloomPluginContext, "getConfig">, layout: LayoutConfig) {
32
+ function persistLayout(_ctx: Pick<GloomPluginContext, "getConfig">, layout: LayoutConfig) {
34
33
  if (!dispatchRef) return;
35
34
  dispatchRef({ type: "PUSH_LAYOUT_HISTORY" });
36
35
  dispatchRef({ type: "UPDATE_LAYOUT", layout });
37
- const config = ctx.getConfig();
38
- const layouts = config.layouts.map((savedLayout, index) => (
39
- index === config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
40
- ));
41
- saveConfig({ ...config, layout, layouts }).catch(() => {});
42
36
  }
43
37
 
44
38
  function getFocusedPane(layout: LayoutConfig, focusedPaneId: string | null) {
@@ -39,7 +39,7 @@ const TopPane = createNewsPresetPane({
39
39
  paneKey: "top",
40
40
  title: "Top News",
41
41
  query: NEWS_QUERY_PRESETS.top,
42
- columns: ["rank", "time", "source", "title", "tickers", "importance"],
42
+ columns: ["time", "title", "tickers", "importance"],
43
43
  defaultSort: { columnId: "importance", direction: "desc" },
44
44
  emptyStateTitle: "No top stories yet",
45
45
  emptyStateHint: "Try refreshing later as new headlines are ranked.",
@@ -8,6 +8,7 @@ import { TickerBadge } from "../../../components/ticker-badge";
8
8
  import { ExternalLink, ExternalLinkText } from "../../../components/ui";
9
9
  import { collectNewsDisplayTickers } from "../../../news/ticker-symbols";
10
10
  import { useInlineTickers } from "../../../state/use-inline-tickers";
11
+ import { isPlainKey } from "../../../utils/keyboard";
11
12
  import { wrapTextLines } from "../../../utils/text-wrap";
12
13
 
13
14
  function hasStoryItems(article: MarketNewsItem | null): boolean {
@@ -196,13 +197,13 @@ export function NewsDetailView({ item, focused, width, showTitle = true }: {
196
197
 
197
198
  useShortcut((event) => {
198
199
  if (!focused) return;
199
- if (event.name === "j" || event.name === "down") {
200
+ if (isPlainKey(event, "j", "down")) {
200
201
  event.stopPropagation?.();
201
202
  event.preventDefault?.();
202
203
  scrollBy(1);
203
204
  return;
204
205
  }
205
- if (event.name === "k" || event.name === "up") {
206
+ if (isPlainKey(event, "k", "up")) {
206
207
  event.stopPropagation?.();
207
208
  event.preventDefault?.();
208
209
  scrollBy(-1);
@@ -45,6 +45,9 @@ export function NewsPresetPane({
45
45
  `${paneKey}:sort`,
46
46
  defaultSort,
47
47
  );
48
+ const effectiveSortPreference = columns.includes(sortPreference.columnId)
49
+ ? sortPreference
50
+ : defaultSort;
48
51
  const loadNewsStory = useLoadNewsStory();
49
52
  const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
50
53
  const { readArticleIds, markArticleRead } = useNewsReadState();
@@ -79,7 +82,7 @@ export function NewsPresetPane({
79
82
  readArticleIds={readArticleIds}
80
83
  selectedArticleId={selectedArticleId}
81
84
  setSelectedArticleId={setSelectedArticleId}
82
- sortPreference={sortPreference}
85
+ sortPreference={effectiveSortPreference}
83
86
  setSortPreference={setSortPreference}
84
87
  onOpenArticle={openArticle}
85
88
  onArticleRead={markArticleRead}
@@ -65,10 +65,16 @@ function samePersistedArticles(left: PersistedNewsArticle[], right: PersistedNew
65
65
  if (left.length !== right.length) return false;
66
66
  return left.every((article, index) => {
67
67
  const other = right[index];
68
- return other?.id === article.id && other.publishedAt === article.publishedAt;
68
+ return other?.id === article.id &&
69
+ other.publishedAt === article.publishedAt &&
70
+ storyItemsSignature(other.items) === storyItemsSignature(article.items);
69
71
  });
70
72
  }
71
73
 
74
+ function storyItemsSignature(items: PersistedNewsStoryItem[] | undefined): string {
75
+ return items?.map((item) => `${item.id}:${item.publishedAt}`).join("|") ?? "";
76
+ }
77
+
72
78
  export function usePersistedNewsArticles(key: string, articles: MarketNewsItem[]): MarketNewsItem[] {
73
79
  const [persistedArticles, setPersistedArticles] = usePluginPaneState<PersistedNewsArticle[]>(
74
80
  key,
@@ -5,8 +5,9 @@ import type { GloomPlugin } from "../../types/plugin";
5
5
  import type { OptionContract, OptionsChain } from "../../types/financials";
6
6
  import { usePaneTicker } from "../../state/app-context";
7
7
  import { blendHex, colors, hoverBg } from "../../theme/colors";
8
- import { blendForContrast, contrastRatio, higherContrast } from "../../theme/color-utils";
8
+ import { blendForContrast, contrastRatio } from "../../theme/color-utils";
9
9
  import { formatCompact, formatNumber } from "../../utils/format";
10
+ import { isPlainKey } from "../../utils/keyboard";
10
11
  import { formatMarketPrice } from "../../utils/market-format";
11
12
  import { formatExpDate, resolveOptionsTarget } from "../../utils/options";
12
13
  import { useOptionsQuery, useResolvedEntryValue } from "../../market-data/hooks";
@@ -48,15 +49,6 @@ type OptionColorRole = "call" | "put" | "price" | "activity" | "iv" | "strike";
48
49
 
49
50
  const OPTION_TEXT_MIN_CONTRAST = 4.5;
50
51
 
51
- const OPTION_BASE_COLORS: Record<OptionColorRole, string> = {
52
- call: "#5ed69a",
53
- put: "#ff9c7a",
54
- price: "#dfc05b",
55
- activity: "#35a7d6",
56
- iv: "#8bd878",
57
- strike: "#8fb7ff",
58
- };
59
-
60
52
  const OPTION_COLUMNS: Array<Omit<OptionColumn, "headerColor">> = [
61
53
  { id: "callOpenInterest", label: "C OI", width: 6, align: "right" },
62
54
  { id: "callVolume", label: "C VOL", width: 6, align: "right" },
@@ -227,7 +219,7 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
227
219
  return true;
228
220
  }
229
221
 
230
- if (event.name === "j" || event.name === "down") {
222
+ if (isPlainKey(event, "j", "down")) {
231
223
  if (strikes.length === 0) return true;
232
224
  event.preventDefault?.();
233
225
  event.stopPropagation?.();
@@ -236,7 +228,7 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
236
228
  setStrikeIdx((i) => Math.min(i + 1, strikes.length - 1));
237
229
  return true;
238
230
  }
239
- if (event.name === "k" || event.name === "up") {
231
+ if (isPlainKey(event, "k", "up")) {
240
232
  if (strikes.length === 0) return true;
241
233
  event.preventDefault?.();
242
234
  event.stopPropagation?.();
@@ -404,14 +396,13 @@ function mostReadableColor(surface: string, candidates: readonly string[]): stri
404
396
  }
405
397
 
406
398
  export function optionRoleColor(role: OptionColorRole, surface: string): string {
407
- const preferred = OPTION_BASE_COLORS[role];
408
- const themeColor = optionRoleThemeColor(role);
399
+ const preferred = optionRoleThemeColor(role);
409
400
  const fallback = mostReadableColor(surface, [
410
401
  preferred,
411
- themeColor,
412
402
  colors.text,
413
403
  colors.textBright,
414
- higherContrast("#ffffff", "#000000", surface),
404
+ colors.selectedText,
405
+ colors.neutral,
415
406
  ]);
416
407
  return blendForContrast(preferred, surface, fallback, OPTION_TEXT_MIN_CONTRAST);
417
408
  }
@@ -421,7 +412,8 @@ export function optionMutedColor(surface: string): string {
421
412
  colors.textDim,
422
413
  colors.text,
423
414
  colors.textBright,
424
- higherContrast("#ffffff", "#000000", surface),
415
+ colors.selectedText,
416
+ colors.neutral,
425
417
  ]);
426
418
  return blendForContrast(colors.textDim, surface, fallback, OPTION_TEXT_MIN_CONTRAST);
427
419
  }
@@ -23,6 +23,7 @@ import type { CliCommandContext, CliCommandDef } from "../../../../types/plugin"
23
23
  import type { TickerRecord } from "../../../../types/ticker";
24
24
  import {
25
25
  addTickerToPortfolio,
26
+ addTickerToWatchlist,
26
27
  createManualPortfolio,
27
28
  deleteManualPortfolio,
28
29
  findPortfolio,
@@ -415,7 +416,7 @@ async function deleteWatchlist(name: string, ctx: CliCommandContext) {
415
416
  persistence.close();
416
417
  }
417
418
 
418
- async function addTickerToWatchlist(watchlistName: string, symbol: string, ctx: CliCommandContext) {
419
+ async function addTickerToWatchlistCommand(watchlistName: string, symbol: string, ctx: CliCommandContext) {
419
420
  const { config, store, dataProvider, persistence } = await ctx.initMarketData();
420
421
  const watchlist = findWatchlist(config, watchlistName);
421
422
  if (!watchlist) {
@@ -424,19 +425,14 @@ async function addTickerToWatchlist(watchlistName: string, symbol: string, ctx:
424
425
 
425
426
  try {
426
427
  const ticker = await resolveTickerForCli(symbol, store, dataProvider);
427
- if (ticker.metadata.watchlists.includes(watchlist.id)) {
428
+ const result = addTickerToWatchlist(ticker, watchlist.id);
429
+ if (!result.changed) {
428
430
  console.log(cliStyles.warning(`${ticker.metadata.ticker} is already in "${watchlist.name}".`));
429
431
  persistence.close();
430
432
  return;
431
433
  }
432
434
 
433
- const nextTicker: TickerRecord = {
434
- ...ticker,
435
- metadata: {
436
- ...ticker.metadata,
437
- watchlists: [...ticker.metadata.watchlists, watchlist.id],
438
- },
439
- };
435
+ const nextTicker = result.ticker;
440
436
  await store.saveTicker(nextTicker);
441
437
  console.log(cliStyles.success(`Added ${nextTicker.metadata.ticker} to "${watchlist.name}".`));
442
438
  if (nextTicker.metadata.name) {
@@ -644,7 +640,7 @@ export const watchlistCliCommand: CliCommandDef = {
644
640
  const symbol = args.at(-1);
645
641
  const name = args.slice(1, -1).join(" ");
646
642
  if (!name || !symbol) ctx.fail("Usage: gloomberb watchlist add <watchlist> <ticker>");
647
- await addTickerToWatchlist(name!, symbol!, ctx);
643
+ await addTickerToWatchlistCommand(name!, symbol!, ctx);
648
644
  return;
649
645
  }
650
646
 
@@ -8,6 +8,7 @@ import { convertCurrency, formatCompact, formatNumber, formatPercentRaw } from "
8
8
  import { formatMarketCost, formatMarketPrice, formatMarketQuantity, formatSignedMarketPrice, type MarketFormatOptions } from "../../../utils/market-format";
9
9
  import { getActiveQuoteDisplay, marketStateDot, type ActiveQuoteDisplay } from "../../../utils/market-status";
10
10
  import { formatOptionTicker } from "../../../utils/options";
11
+ import { PRICE_SPARKLINE_COLUMN_ID } from "../../../components/price-sparkline-view";
11
12
 
12
13
  export interface ColumnContext {
13
14
  activeTab?: string;
@@ -293,6 +294,8 @@ export function getColumnValue(
293
294
  return { text: "—" };
294
295
  case "latency":
295
296
  return { text: formatQuoteAgeWithSource(quote, ctx.now) };
297
+ case PRICE_SPARKLINE_COLUMN_ID:
298
+ return { text: "" };
296
299
  default:
297
300
  return { text: "—" };
298
301
  }
@@ -387,6 +390,14 @@ export function getSortValue(
387
390
  return null;
388
391
  case "latency":
389
392
  return quote?.lastUpdated != null ? ctx.now - (clampQuoteTimestamp(quote.lastUpdated, ctx.now) ?? ctx.now) : null;
393
+ case PRICE_SPARKLINE_COLUMN_ID: {
394
+ const values = (financials?.priceHistory ?? [])
395
+ .map((point) => point.close)
396
+ .filter((value) => Number.isFinite(value));
397
+ const first = values[0];
398
+ const last = values.at(-1);
399
+ return first != null && last != null && first !== 0 ? ((last - first) / Math.abs(first)) * 100 : null;
400
+ }
390
401
  default:
391
402
  return null;
392
403
  }
@@ -121,6 +121,23 @@ export function addTickerToPortfolio(
121
121
  };
122
122
  }
123
123
 
124
+ export function addTickerToWatchlist(
125
+ ticker: TickerRecord,
126
+ watchlistId: string,
127
+ ): { changed: boolean; ticker: TickerRecord } {
128
+ if (ticker.metadata.watchlists.includes(watchlistId)) {
129
+ return { changed: false, ticker };
130
+ }
131
+
132
+ return {
133
+ changed: true,
134
+ ticker: replaceTickerMetadata(ticker, {
135
+ ...ticker.metadata,
136
+ watchlists: [...ticker.metadata.watchlists, watchlistId],
137
+ }),
138
+ };
139
+ }
140
+
124
141
  export function removeTickerFromPortfolio(
125
142
  ticker: TickerRecord,
126
143
  portfolioId: string,
@@ -46,17 +46,47 @@ import { getMostRecentQuoteUpdate } from "../../../utils/quote-time";
46
46
  import { priceColor } from "../../../theme/colors";
47
47
  import { PortfolioTickerTable, type QuoteFlashDirection } from "./table";
48
48
  import { useThrottledCursorSymbol } from "./use-throttled-cursor-symbol";
49
+ import { isManualPortfolio } from "./mutations";
50
+ import { QuickAddTickerInput, type QuickAddCollectionKind } from "./quick-add";
51
+ import { PRICE_SPARKLINE_COLUMN_ID } from "../../../components/price-sparkline-view";
49
52
 
50
53
  const VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS = 5 * 60_000;
51
54
  const VISIBLE_FINANCIAL_WARMUP_DELAY_MS = 350;
55
+ const VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT = 3;
52
56
  const STREAM_OVERSCAN_ROWS = 6;
57
+ const FUNDAMENTAL_COLUMN_IDS = new Set(["pe", "forward_pe", "dividend_yield"]);
53
58
  const sortValueCache = createRowValueCache<string, ReturnType<typeof getSortValue>>(5000);
54
59
 
55
- function needsVisibleFinancialWarmup(ticker: TickerRecord, financials: TickerFinancials | undefined): boolean {
60
+ interface VisibleWarmupRequirements {
61
+ fundamentals: boolean;
62
+ priceHistory: boolean;
63
+ }
64
+
65
+ function resolveVisibleWarmupRequirements(columns: ColumnConfig[]): VisibleWarmupRequirements {
66
+ return {
67
+ fundamentals: columns.some((column) => FUNDAMENTAL_COLUMN_IDS.has(column.id)),
68
+ priceHistory: columns.some((column) => column.id === PRICE_SPARKLINE_COLUMN_ID),
69
+ };
70
+ }
71
+
72
+ function visibleWarmupKey(kind: "quote" | "snapshot", ticker: TickerRecord): string {
73
+ return `${kind}:${ticker.metadata.ticker}:${ticker.metadata.exchange ?? ""}`;
74
+ }
75
+
76
+ function needsVisibleQuoteWarmup(ticker: TickerRecord, financials: TickerFinancials | undefined): boolean {
56
77
  if (ticker.metadata.assetCategory === "OPT") return false;
57
- if (!financials) return true;
58
- if (Object.keys(financials.fundamentals ?? {}).length === 0) return true;
59
- return financials.priceHistory.length === 0;
78
+ return !financials?.quote;
79
+ }
80
+
81
+ function needsVisibleSnapshotWarmup(
82
+ ticker: TickerRecord,
83
+ financials: TickerFinancials | undefined,
84
+ requirements: VisibleWarmupRequirements,
85
+ ): boolean {
86
+ if (ticker.metadata.assetCategory === "OPT") return false;
87
+ if (!financials?.quote) return false;
88
+ if (requirements.fundamentals && Object.keys(financials.fundamentals ?? {}).length === 0) return true;
89
+ return requirements.priceHistory && financials.priceHistory.length === 0;
60
90
  }
61
91
 
62
92
  export function selectStreamTickers(
@@ -209,6 +239,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
209
239
  const [now, setNow] = useState(Date.now());
210
240
  const [flashSymbols, setFlashSymbols] = useState<Map<string, QuoteFlashDirection>>(new Map());
211
241
  const [streamWindow, setStreamWindow] = useState({ start: 0, end: 24 });
242
+ const [quickAddFocused, setQuickAddFocused] = useState(false);
212
243
 
213
244
  const previousPricesRef = useRef<Map<string, number>>(new Map());
214
245
  const mountedRef = useRef(true);
@@ -235,6 +266,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
235
266
  );
236
267
  const activeCollectionId = resolveActiveCollectionId(currentCollectionId, visibleCollections);
237
268
  const isPortfolioTab = getCollectionTypeFromConfig(config, activeCollectionId) === "portfolio";
269
+ const activeCollectionEntry = visibleCollections.find((collection) => collection.id === activeCollectionId) ?? null;
238
270
  const currentPortfolio = useMemo(() => (
239
271
  isPortfolioTab
240
272
  ? config.portfolios.find((portfolio) => portfolio.id === activeCollectionId) ?? null
@@ -261,6 +293,10 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
261
293
  () => resolveVisibleColumns(paneSettings.columnIds, isPortfolioTab),
262
294
  [isPortfolioTab, paneSettings.columnIds],
263
295
  );
296
+ const visibleWarmupRequirements = useMemo(
297
+ () => resolveVisibleWarmupRequirements(columns),
298
+ [columns],
299
+ );
264
300
 
265
301
  const trackedCurrencies = useMemo(
266
302
  () => buildTrackedCurrencies(tickers, financialsMap, accountState, config.baseCurrency),
@@ -334,6 +370,9 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
334
370
  flushCursorSymbol(ticker.metadata.ticker);
335
371
  openTickerFloating(ticker.metadata.ticker);
336
372
  }, [flushCursorSymbol, openTickerFloating]);
373
+ const handleTickerAdded = useCallback((symbol: string) => {
374
+ setCursorSymbol(symbol, { immediate: true });
375
+ }, [setCursorSymbol]);
337
376
 
338
377
  const handleTableKeyDown = useCallback((event: DataTableKeyEvent) => {
339
378
  if (!focused) return;
@@ -441,31 +480,64 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
441
480
  if (!sharedCoordinator) return;
442
481
 
443
482
  const nowTimestamp = Date.now();
444
- const queue = visibleFinancialTickers.filter((ticker) => {
445
- const key = `${ticker.metadata.ticker}:${ticker.metadata.exchange ?? ""}`;
446
- if (warmupInFlightRef.current.has(key)) return false;
447
- if (nowTimestamp - (warmupAttemptRef.current.get(key) ?? 0) < VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS) return false;
448
- return needsVisibleFinancialWarmup(ticker, financialsMap.get(ticker.metadata.ticker));
449
- });
450
- if (queue.length === 0) return;
483
+ const quoteQueue: TickerRecord[] = [];
484
+ const snapshotQueue: TickerRecord[] = [];
485
+ for (const ticker of visibleFinancialTickers) {
486
+ const financials = financialsMap.get(ticker.metadata.ticker);
487
+ const quoteKey = visibleWarmupKey("quote", ticker);
488
+ if (
489
+ needsVisibleQuoteWarmup(ticker, financials)
490
+ && !warmupInFlightRef.current.has(quoteKey)
491
+ && nowTimestamp - (warmupAttemptRef.current.get(quoteKey) ?? 0) >= VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS
492
+ ) {
493
+ quoteQueue.push(ticker);
494
+ continue;
495
+ }
496
+
497
+ const snapshotKey = visibleWarmupKey("snapshot", ticker);
498
+ if (
499
+ needsVisibleSnapshotWarmup(ticker, financials, visibleWarmupRequirements)
500
+ && !warmupInFlightRef.current.has(snapshotKey)
501
+ && nowTimestamp - (warmupAttemptRef.current.get(snapshotKey) ?? 0) >= VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS
502
+ ) {
503
+ snapshotQueue.push(ticker);
504
+ }
505
+ }
506
+ const limitedSnapshotQueue = snapshotQueue.slice(0, VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT);
507
+ if (quoteQueue.length === 0 && limitedSnapshotQueue.length === 0) return;
451
508
 
452
509
  let cancelled = false;
453
510
  const runBatch = async (): Promise<void> => {
454
- const entries = queue.flatMap((ticker) => {
511
+ const quoteEntries = quoteQueue.flatMap((ticker) => {
512
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
513
+ if (!instrument) return [];
514
+ const key = visibleWarmupKey("quote", ticker);
515
+ warmupInFlightRef.current.add(key);
516
+ warmupAttemptRef.current.set(key, nowTimestamp);
517
+ return [{ key, instrument }];
518
+ });
519
+ const snapshotEntries = limitedSnapshotQueue.flatMap((ticker) => {
455
520
  const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
456
521
  if (!instrument) return [];
457
- const key = `${ticker.metadata.ticker}:${ticker.metadata.exchange ?? ""}`;
522
+ const key = visibleWarmupKey("snapshot", ticker);
458
523
  warmupInFlightRef.current.add(key);
459
524
  warmupAttemptRef.current.set(key, nowTimestamp);
460
525
  return [{ key, instrument }];
461
526
  });
462
- if (entries.length === 0) return;
527
+ if (quoteEntries.length === 0 && snapshotEntries.length === 0) return;
463
528
  try {
464
- await sharedCoordinator.loadSnapshotsBatch(entries.map((entry) => entry.instrument));
529
+ await Promise.allSettled([
530
+ quoteEntries.length > 0
531
+ ? sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument))
532
+ : Promise.resolve(),
533
+ snapshotEntries.length > 0
534
+ ? sharedCoordinator.loadSnapshotsBatch(snapshotEntries.map((entry) => entry.instrument))
535
+ : Promise.resolve(),
536
+ ]);
465
537
  } catch {
466
538
  // Best-effort warmup for visible rows only.
467
539
  } finally {
468
- for (const entry of entries) {
540
+ for (const entry of [...quoteEntries, ...snapshotEntries]) {
469
541
  warmupInFlightRef.current.delete(entry.key);
470
542
  }
471
543
  }
@@ -479,7 +551,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
479
551
  cancelled = true;
480
552
  clearTimeout(timeoutId);
481
553
  };
482
- }, [appActive, financialsMap, focused, sharedCoordinator, visibleFinancialTickers]);
554
+ }, [appActive, financialsMap, focused, sharedCoordinator, visibleFinancialTickers, visibleWarmupRequirements]);
483
555
 
484
556
  useQuoteStreaming(streamTargets);
485
557
 
@@ -555,6 +627,30 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
555
627
  : [],
556
628
  }), [cashDrawerExpanded, setCashDrawerExpanded, showCashDrawer, summaryFooterInfo]);
557
629
 
630
+ const quickAddCollectionKind = useMemo<QuickAddCollectionKind | null>(() => {
631
+ if (!activeCollectionId) return null;
632
+ const collectionType = getCollectionTypeFromConfig(config, activeCollectionId);
633
+ if (collectionType === "watchlist") return "watchlist";
634
+ if (collectionType === "portfolio" && currentPortfolio && isManualPortfolio(currentPortfolio)) {
635
+ return "portfolio";
636
+ }
637
+ return null;
638
+ }, [activeCollectionId, config, currentPortfolio]);
639
+ const showQuickAdd = !!(activeCollectionId && activeCollectionEntry && quickAddCollectionKind);
640
+ const quickAddHeight = showQuickAdd ? 1 : 0;
641
+ const tableHeight = Math.max(1, height - headerHeight - drawerHeight - quickAddHeight);
642
+ const quickAddRow = activeCollectionId && activeCollectionEntry && quickAddCollectionKind ? (
643
+ <QuickAddTickerInput
644
+ collectionId={activeCollectionId}
645
+ collectionKind={quickAddCollectionKind}
646
+ collectionName={activeCollectionEntry.name}
647
+ focused={focused}
648
+ width={width}
649
+ onAdded={handleTickerAdded}
650
+ onFocusChange={setQuickAddFocused}
651
+ />
652
+ ) : null;
653
+
558
654
  return (
559
655
  <Box flexDirection="column" width={width} height={height}>
560
656
  <Box flexDirection="column" height={headerHeight}>
@@ -575,7 +671,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
575
671
 
576
672
  <PortfolioTickerTable
577
673
  columns={columns}
578
- focused={focused}
674
+ focused={focused && !quickAddFocused}
579
675
  sortColumnId={activeSort.columnId}
580
676
  sortDirection={activeSort.direction}
581
677
  onHeaderClick={handleHeaderClick}
@@ -590,8 +686,11 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
590
686
  visibleRangeBuffer={3}
591
687
  resetScrollKey={activeCollectionId}
592
688
  onRowActivate={handleRowActivate}
689
+ rootHeight={tableHeight}
593
690
  />
594
691
 
692
+ {quickAddRow}
693
+
595
694
  {showCashDrawer && accountState && (
596
695
  <Box height={drawerHeight} paddingX={1}>
597
696
  <PortfolioCashMarginDrawer