gloomberb 0.6.1 → 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 (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -12,7 +12,7 @@ import {
12
12
  import { useShortcut } from "../../../react/input";
13
13
  import { blendHex, colors, priceColor } from "../../../theme/colors";
14
14
  import type { HolderData, HolderRecord } from "../../../types/financials";
15
- import type { DetailTabProps, GloomPlugin, PaneProps } from "../../../types/plugin";
15
+ import type { GloomPlugin } from "../../../types/plugin";
16
16
  import { formatCompact, formatPercent, formatPercentRaw, padTo } from "../../../utils/format";
17
17
  import { useAssetData, usePluginPaneState } from "../../plugin-runtime";
18
18
  import { createTickerSurfacePaneTemplate } from "../ticker-surface";
@@ -21,6 +21,7 @@ type ViewMode = "table" | "chart";
21
21
  type HolderColumnId = "holder" | "value" | "shares" | "changeShares" | "changePercent" | "percentHeld" | "reportDate";
22
22
  type HolderColumn = DataTableColumn & { id: HolderColumnId };
23
23
  type SortDirection = "asc" | "desc";
24
+ type PreventableMouseEvent = { preventDefault(): void };
24
25
 
25
26
  interface SortPreference {
26
27
  columnId: HolderColumnId;
@@ -89,6 +90,23 @@ function formatMaybePercent(value: number | undefined): string {
89
90
  return Math.abs(value) <= 1 ? formatPercent(value) : formatPercentRaw(value);
90
91
  }
91
92
 
93
+ export function resolveHolderOwnershipPercent(row: Pick<HolderRecord, "percentHeld" | "value">, marketCap: number | undefined): number | undefined {
94
+ if (row.percentHeld != null) return row.percentHeld;
95
+ if (row.value == null || row.value < 0 || marketCap == null || marketCap <= 0) return undefined;
96
+ return row.value / marketCap;
97
+ }
98
+
99
+ export function formatHolderOwnershipPercent(value: number | undefined): string {
100
+ if (value == null) return "-";
101
+ const percent = Math.abs(value) <= 1 ? value * 100 : value;
102
+ return `${percent.toFixed(2)}%`;
103
+ }
104
+
105
+ function formatHolderOwnershipLine(row: HolderRow, marketCap: number | undefined): string | null {
106
+ const ownership = resolveHolderOwnershipPercent(row, marketCap);
107
+ return ownership == null ? null : `${formatHolderOwnershipPercent(ownership)} held`;
108
+ }
109
+
92
110
  function formatSignedCompact(value: number | undefined): string {
93
111
  if (value == null) return "-";
94
112
  const sign = value > 0 ? "+" : "";
@@ -132,7 +150,7 @@ function buildColumns(width: number): HolderColumn[] {
132
150
  ];
133
151
  }
134
152
 
135
- function sortValue(row: HolderRow, columnId: HolderColumnId): string | number | null {
153
+ function sortValue(row: HolderRow, columnId: HolderColumnId, marketCap?: number): string | number | null {
136
154
  switch (columnId) {
137
155
  case "holder":
138
156
  return row.name;
@@ -145,7 +163,7 @@ function sortValue(row: HolderRow, columnId: HolderColumnId): string | number |
145
163
  case "changePercent":
146
164
  return row.changePercent ?? null;
147
165
  case "percentHeld":
148
- return row.percentHeld ?? null;
166
+ return resolveHolderOwnershipPercent(row, marketCap) ?? null;
149
167
  case "reportDate":
150
168
  return row.reportDate ?? null;
151
169
  }
@@ -166,10 +184,10 @@ function compareSortValues(
166
184
  return direction === "asc" ? comparison : -comparison;
167
185
  }
168
186
 
169
- function sortRows(rows: HolderRow[], preference: SortPreference): HolderRow[] {
187
+ function sortRows(rows: HolderRow[], preference: SortPreference, marketCap?: number): HolderRow[] {
170
188
  return [...rows].sort((left, right) => compareSortValues(
171
- sortValue(left, preference.columnId),
172
- sortValue(right, preference.columnId),
189
+ sortValue(left, preference.columnId, marketCap),
190
+ sortValue(right, preference.columnId, marketCap),
173
191
  preference.direction,
174
192
  ));
175
193
  }
@@ -427,10 +445,11 @@ function desktopTileColor(row: HolderRow): string {
427
445
  return blendHex(colors.panel, row.changePercent > 0 ? colors.positive : colors.negative, intensity);
428
446
  }
429
447
 
430
- function Tile({ tile, selected, currency, onSelect }: {
448
+ function Tile({ tile, selected, currency, marketCap, onSelect }: {
431
449
  tile: TileLayout;
432
450
  selected: boolean;
433
451
  currency: string;
452
+ marketCap?: number;
434
453
  onSelect: () => void;
435
454
  }) {
436
455
  const renderWidth = Math.max(1, tile.width - (tile.width > 2 ? 1 : 0));
@@ -442,6 +461,7 @@ function Tile({ tile, selected, currency, onSelect }: {
442
461
  const amount = tile.row.value != null
443
462
  ? formatMoneyCompact(tile.row.value, currency)
444
463
  : formatCompact(tile.row.shares);
464
+ const ownership = formatHolderOwnershipLine(tile.row, marketCap);
445
465
  const change = tile.row.changePercent != null ? formatMaybePercent(tile.row.changePercent) : "No change";
446
466
 
447
467
  return (
@@ -452,7 +472,7 @@ function Tile({ tile, selected, currency, onSelect }: {
452
472
  width={renderWidth}
453
473
  height={renderHeight}
454
474
  backgroundColor={selected ? colors.selected : tileColor(tile.row)}
455
- onMouseDown={(event) => {
475
+ onMouseDown={(event: PreventableMouseEvent) => {
456
476
  event.preventDefault();
457
477
  onSelect();
458
478
  }}
@@ -462,6 +482,9 @@ function Tile({ tile, selected, currency, onSelect }: {
462
482
  <Text fg={textColor} attributes={attributes}>{padTo(amount, innerWidth)}</Text>
463
483
  )}
464
484
  {renderHeight >= 3 && (
485
+ <Text fg={textColor} attributes={attributes}>{padTo(ownership ?? change, innerWidth)}</Text>
486
+ )}
487
+ {ownership && renderHeight >= 4 && (
465
488
  <Text fg={textColor} attributes={attributes}>{padTo(change, innerWidth)}</Text>
466
489
  )}
467
490
  </Box>
@@ -472,21 +495,24 @@ function pct(value: number, total: number): string {
472
495
  return `${total > 0 ? value / total * 100 : 0}%`;
473
496
  }
474
497
 
475
- function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currency, onSelect, onHover }: {
498
+ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currency, marketCap, onSelect, onHover }: {
476
499
  tile: FloatTileLayout;
477
500
  chartWidth: number;
478
501
  chartHeight: number;
479
502
  selected: boolean;
480
503
  hovered: boolean;
481
504
  currency: string;
505
+ marketCap?: number;
482
506
  onSelect: () => void;
483
507
  onHover: (hovered: boolean) => void;
484
508
  }) {
485
509
  const amount = tile.row.value != null
486
510
  ? formatMoneyCompact(tile.row.value, currency)
487
511
  : formatCompact(tile.row.shares);
512
+ const ownership = formatHolderOwnershipLine(tile.row, marketCap);
488
513
  const change = tile.row.changePercent != null ? formatMaybePercent(tile.row.changePercent) : "No change";
489
514
  const canShowDetails = tile.width >= 7 && tile.height >= 3;
515
+ const canShowChange = tile.height >= 3.7;
490
516
  const canShowLabel = tile.width >= 4 && tile.height >= 1.4;
491
517
  const isTiny = tile.width < 5 || tile.height < 2;
492
518
  const backgroundColor = desktopTileColor(tile.row);
@@ -538,7 +564,7 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
538
564
  <Box
539
565
  data-gloom-role="holders-desktop-tile"
540
566
  style={style}
541
- onMouseDown={(event) => {
567
+ onMouseDown={(event: PreventableMouseEvent) => {
542
568
  event.preventDefault();
543
569
  onSelect();
544
570
  }}
@@ -561,20 +587,24 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
561
587
  {canShowDetails && (
562
588
  <>
563
589
  <Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{amount}</Text>
564
- <Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{change}</Text>
590
+ <Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{ownership ?? change}</Text>
591
+ {ownership && canShowChange && (
592
+ <Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{change}</Text>
593
+ )}
565
594
  </>
566
595
  )}
567
596
  </Box>
568
597
  );
569
598
  }
570
599
 
571
- function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, currency, cellAspect }: {
600
+ function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, currency, marketCap, cellAspect }: {
572
601
  rows: HolderRow[];
573
602
  width: number;
574
603
  height: number;
575
604
  selectedId: string | null;
576
605
  onSelect: (row: HolderRow) => void;
577
606
  currency: string;
607
+ marketCap?: number;
578
608
  cellAspect: number;
579
609
  }) {
580
610
  const [hoveredId, setHoveredId] = useState<string | null>(null);
@@ -620,6 +650,7 @@ function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, curr
620
650
  selected={tile.row.id === selectedId}
621
651
  hovered={tile.row.id === hoveredId}
622
652
  currency={currency}
653
+ marketCap={marketCap}
623
654
  onSelect={() => onSelect(tile.row)}
624
655
  onHover={(isHovered) => setHoveredId((current) => (isHovered ? tile.row.id : current === tile.row.id ? null : current))}
625
656
  />
@@ -629,13 +660,14 @@ function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, curr
629
660
  );
630
661
  }
631
662
 
632
- function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }: {
663
+ function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency, marketCap }: {
633
664
  rows: HolderRow[];
634
665
  width: number;
635
666
  height: number;
636
667
  selectedId: string | null;
637
668
  onSelect: (row: HolderRow) => void;
638
669
  currency: string;
670
+ marketCap?: number;
639
671
  }) {
640
672
  const { cellWidthPx = 8, cellHeightPx = 18, nativePaneChrome } = useUiCapabilities();
641
673
  const chartWidth = Math.max(1, width - 2);
@@ -651,6 +683,7 @@ function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }:
651
683
  selectedId={selectedId}
652
684
  onSelect={onSelect}
653
685
  currency={currency}
686
+ marketCap={marketCap}
654
687
  cellAspect={cellAspect}
655
688
  />
656
689
  );
@@ -673,6 +706,7 @@ function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }:
673
706
  tile={tile}
674
707
  selected={tile.row.id === selectedId}
675
708
  currency={currency}
709
+ marketCap={marketCap}
676
710
  onSelect={() => onSelect(tile.row)}
677
711
  />
678
712
  ))}
@@ -681,8 +715,9 @@ function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }:
681
715
  );
682
716
  }
683
717
 
684
- export function HoldersView({ focused, width, height }: Pick<PaneProps, "focused" | "width" | "height">) {
685
- const { symbol, ticker } = usePaneTicker();
718
+ function HoldersView({ focused, width, height }: { focused: boolean; width: number; height: number }) {
719
+ const { nativePaneChrome } = useUiCapabilities();
720
+ const { symbol, ticker, financials } = usePaneTicker();
686
721
  const dataProvider = useAssetData();
687
722
  const [viewMode, setViewMode] = usePluginPaneState<ViewMode>("viewMode", "chart");
688
723
  const [sortPreference, setSortPreference] = usePluginPaneState<SortPreference>("sortPreference", DEFAULT_SORT);
@@ -693,9 +728,11 @@ export function HoldersView({ focused, width, height }: Pick<PaneProps, "focused
693
728
  const fetchGenRef = useRef(0);
694
729
 
695
730
  const currency = data?.currency ?? ticker?.metadata.currency ?? "USD";
731
+ const quoteMarketCap = financials?.quote?.marketCap;
732
+ const marketCap = financials?.quote?.currency && financials.quote.currency !== currency ? undefined : quoteMarketCap;
696
733
  const exchange = ticker?.metadata.exchange ?? "";
697
734
  const rows = useMemo(() => buildRows(data), [data]);
698
- const sortedRows = useMemo(() => sortRows(rows, sortPreference), [rows, sortPreference]);
735
+ const sortedRows = useMemo(() => sortRows(rows, sortPreference, marketCap), [marketCap, rows, sortPreference]);
699
736
  const columns = useMemo(() => buildColumns(width), [width]);
700
737
  const selectedIdx = selectedId
701
738
  ? sortedRows.findIndex((row) => row.id === selectedId)
@@ -831,11 +868,14 @@ export function HoldersView({ focused, width, height }: Pick<PaneProps, "focused
831
868
  color: selectedColor ?? (row.changePercent != null ? priceColor(row.changePercent) : colors.textDim),
832
869
  };
833
870
  case "percentHeld":
834
- return { text: formatMaybePercent(row.percentHeld), color: selectedColor ?? colors.textDim };
871
+ return {
872
+ text: formatHolderOwnershipPercent(resolveHolderOwnershipPercent(row, marketCap)),
873
+ color: selectedColor ?? colors.textDim,
874
+ };
835
875
  case "reportDate":
836
876
  return { text: displayDate(row.reportDate), color: selectedColor ?? colors.textDim };
837
877
  }
838
- }, [currency]);
878
+ }, [currency, marketCap]);
839
879
 
840
880
  usePaneFooter("holders", () => ({
841
881
  info: [
@@ -853,6 +893,7 @@ export function HoldersView({ focused, width, height }: Pick<PaneProps, "focused
853
893
  : loading
854
894
  ? "Loading holders..."
855
895
  : error ?? "No holders available";
896
+ const chartHeight = Math.max(1, height - 1 - (nativePaneChrome ? 1 : 0));
856
897
 
857
898
  return (
858
899
  <Box flexDirection="column" width={width} height={height}>
@@ -863,6 +904,7 @@ export function HoldersView({ focused, width, height }: Pick<PaneProps, "focused
863
904
  onSelect={(value) => setViewMode(value as ViewMode)}
864
905
  compact
865
906
  variant="bare"
907
+ focused={focused}
866
908
  />
867
909
  </Box>
868
910
 
@@ -889,24 +931,17 @@ export function HoldersView({ focused, width, height }: Pick<PaneProps, "focused
889
931
  <HoldersTreemap
890
932
  rows={sortedRows}
891
933
  width={width}
892
- height={Math.max(1, height - 1)}
934
+ height={chartHeight}
893
935
  selectedId={selectedId}
894
936
  onSelect={(row) => setSelectedId(row.id)}
895
937
  currency={currency}
938
+ marketCap={marketCap}
896
939
  />
897
940
  )}
898
941
  </Box>
899
942
  );
900
943
  }
901
944
 
902
- export function HoldersPane({ focused, width, height }: PaneProps) {
903
- return <HoldersView focused={focused} width={width} height={height} />;
904
- }
905
-
906
- export function HoldersDetailTab({ focused, width, height }: DetailTabProps) {
907
- return <HoldersView focused={focused} width={width} height={height} />;
908
- }
909
-
910
945
  export const holdersPlugin: GloomPlugin = {
911
946
  id: "holders",
912
947
  name: "Holders",
@@ -919,7 +954,7 @@ export const holdersPlugin: GloomPlugin = {
919
954
  id: "holders",
920
955
  name: "Holders",
921
956
  order: 42,
922
- component: HoldersDetailTab,
957
+ component: HoldersView,
923
958
  isVisible: ({ ticker }) => !!ticker,
924
959
  });
925
960
  },
@@ -929,7 +964,7 @@ export const holdersPlugin: GloomPlugin = {
929
964
  id: "holders",
930
965
  name: "Holders",
931
966
  icon: "H",
932
- component: HoldersPane,
967
+ component: HoldersView,
933
968
  defaultPosition: "right",
934
969
  defaultMode: "floating",
935
970
  defaultFloatingSize: { width: 105, height: 34 },
@@ -1,6 +1,6 @@
1
- import { Box, Text } from "../../../ui";
1
+ import { Text } from "../../../ui";
2
2
  import { useCallback, useEffect, useMemo, useState } from "react";
3
- import type { DetailTabProps, GloomPlugin, PaneProps } from "../../../types/plugin";
3
+ import type { GloomPlugin } from "../../../types/plugin";
4
4
  import type { SecFilingItem } from "../../../types/data-provider";
5
5
  import {
6
6
  useResolvedEntryValue,
@@ -14,50 +14,19 @@ import { FeedDataTableStackView, Spinner, useExternalLinkFooter, type FeedDataTa
14
14
  import { usePluginPaneState } from "../../plugin-runtime";
15
15
  import { isUsEquityTicker } from "../../../utils/sec";
16
16
  import { formatCompact, formatCurrency } from "../../../utils/format";
17
- import { parseForm4Xml, transactionTypeLabel, type InsiderTransaction } from "./insider-data";
17
+ import { parseForm4Xml, type InsiderTransaction } from "./insider-data";
18
18
  import { createTickerSurfacePaneTemplate } from "../ticker-surface";
19
+ import {
20
+ buildInsiderTransactionDetailBody,
21
+ buildInsiderTransactionTitle,
22
+ formatFilingFormLabel,
23
+ formatFilingShortDate,
24
+ renderFilingNotice,
25
+ } from "../sec-filing-display";
19
26
 
20
27
  const FORM4_LIMIT = 20;
21
28
  const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
22
29
 
23
- const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
24
-
25
- function formatDate(d: Date | string | number): string {
26
- const date = d instanceof Date ? d : new Date(d);
27
- if (isNaN(date.getTime())) return "—";
28
- return `${MONTH_NAMES[date.getMonth()]} ${String(date.getDate()).padStart(2, " ")} ${date.getFullYear()}`;
29
- }
30
-
31
- function wrapLines(text: string, width: number): string[] {
32
- const words = text.trim().split(/\s+/);
33
- const lines: string[] = [];
34
- let current = "";
35
- for (const word of words) {
36
- if (!current) { current = word; continue; }
37
- if (current.length + 1 + word.length <= width) {
38
- current = `${current} ${word}`;
39
- } else {
40
- lines.push(current);
41
- current = word;
42
- }
43
- }
44
- if (current) lines.push(current);
45
- return lines;
46
- }
47
-
48
- function renderNotice(message: string, width: number) {
49
- const lines = wrapLines(message, width - 4);
50
- return (
51
- <Box flexDirection="column" paddingX={1} paddingY={1}>
52
- {lines.map((line, i) => (
53
- <Box key={i} height={1}>
54
- <Text fg={colors.textDim}>{line}</Text>
55
- </Box>
56
- ))}
57
- </Box>
58
- );
59
- }
60
-
61
30
  interface ParsedFiling {
62
31
  filing: SecFilingItem;
63
32
  transaction: InsiderTransaction | null;
@@ -99,46 +68,18 @@ function truncateText(text: string, width: number): string {
99
68
  return `${text.slice(0, width - 3)}...`;
100
69
  }
101
70
 
102
- function formatFilingForm(form: string): string {
103
- const value = form.trim();
104
- return value ? `FORM ${value}` : "FORM 4";
105
- }
106
-
107
- function buildTransactionTitle(transaction: InsiderTransaction): string {
108
- const type = transactionTypeLabel(transaction.transactionType);
109
- const price = transaction.pricePerShare != null
110
- ? ` @ ${formatCurrency(transaction.pricePerShare)}`
111
- : "";
112
- const value = transaction.totalValue != null
113
- ? ` | ${formatCurrency(transaction.totalValue)}`
114
- : "";
115
- return `${type} ${formatCompact(transaction.shares)} shares${price}${value}`;
116
- }
117
-
118
- function buildTransactionDetailBody(transaction: InsiderTransaction): string {
119
- const lines = [
120
- `Transaction: ${transactionTypeLabel(transaction.transactionType)}`,
121
- `Date: ${formatDate(transaction.filingDate)}`,
122
- `Shares: ${formatCompact(transaction.shares)}`,
123
- `Price/Share: ${transaction.pricePerShare != null ? formatCurrency(transaction.pricePerShare) : "—"}`,
124
- `Total Value: ${transaction.totalValue != null ? formatCurrency(transaction.totalValue) : "—"}`,
125
- `Shares Owned After: ${transaction.sharesOwned != null ? formatCompact(transaction.sharesOwned) : "—"}`,
126
- ];
127
- return lines.join("\n");
128
- }
129
-
130
71
  function toFeedItems(parsed: ParsedFiling[]): FeedDataTableItem[] {
131
72
  return parsed.map(({ filing, transaction, isLoading }) => {
132
73
  const filingMeta = [
133
- `Filed ${formatDate(filing.filingDate)}`,
74
+ `Filed ${formatFilingShortDate(filing.filingDate)}`,
134
75
  `Accession ${filing.accessionNumber}`,
135
- formatFilingForm(filing.form),
76
+ formatFilingFormLabel(filing.form),
136
77
  ];
137
78
 
138
79
  if (!transaction) {
139
80
  return {
140
81
  id: filing.accessionNumber,
141
- eyebrow: formatFilingForm(filing.form),
82
+ eyebrow: formatFilingFormLabel(filing.form),
142
83
  title: isLoading ? "Loading Form 4 filing..." : "Form 4 transaction unavailable",
143
84
  timestamp: filing.filingDate,
144
85
  detailTitle: isLoading ? "Loading Form 4 filing..." : "Form 4 transaction unavailable",
@@ -152,7 +93,7 @@ function toFeedItems(parsed: ParsedFiling[]): FeedDataTableItem[] {
152
93
  return {
153
94
  id: filing.accessionNumber,
154
95
  eyebrow: transaction.reportedName,
155
- title: buildTransactionTitle(transaction),
96
+ title: buildInsiderTransactionTitle(transaction),
156
97
  timestamp: transaction.filingDate,
157
98
  preview: transaction.title || undefined,
158
99
  detailTitle: transaction.reportedName,
@@ -160,12 +101,12 @@ function toFeedItems(parsed: ParsedFiling[]): FeedDataTableItem[] {
160
101
  ...(transaction.title ? [transaction.title] : []),
161
102
  ...filingMeta,
162
103
  ],
163
- detailBody: buildTransactionDetailBody(transaction),
104
+ detailBody: buildInsiderTransactionDetailBody(transaction),
164
105
  };
165
106
  });
166
107
  }
167
108
 
168
- export function InsiderView({ width, height, focused }: Pick<DetailTabProps, "width" | "height" | "focused">) {
109
+ function InsiderView({ width, height, focused }: { width: number; height: number; focused: boolean }) {
169
110
  const { ticker } = usePaneTicker();
170
111
  const tickerKey = ticker?.metadata.ticker ?? "none";
171
112
  const [selectedIdx, setSelectedIdx] = usePluginPaneState<number>(`insider:selectedIdx:${tickerKey}`, 0);
@@ -290,18 +231,18 @@ export function InsiderView({ width, height, focused }: Pick<DetailTabProps, "wi
290
231
  registrationId: "insider",
291
232
  focused,
292
233
  url: error ? null : openFiling?.filingUrl,
293
- source: openFiling?.form ? formatFilingForm(openFiling.form) : null,
234
+ source: openFiling?.form ? formatFilingFormLabel(openFiling.form) : null,
294
235
  info: footerInfo,
295
236
  hints: footerHints,
296
237
  label: "filing",
297
238
  });
298
239
 
299
240
  if (!ticker) return <Text fg={colors.textDim}>Select a ticker to view insider activity.</Text>;
300
- if (!eligibleTicker) return renderNotice("Insider transactions are only shown for US equities.", width);
241
+ if (!eligibleTicker) return renderFilingNotice("Insider transactions are only shown for US equities.", width);
301
242
  if (loading && allFilings.length === 0) return <Spinner label="Loading insider filings..." />;
302
- if (error) return renderNotice(`Error: ${error}`, width);
243
+ if (error) return renderFilingNotice(`Error: ${error}`, width);
303
244
  if (!loading && form4Filings.length === 0) {
304
- return renderNotice(`No Form 4 filings found for ${ticker.metadata.ticker}.`, width);
245
+ return renderFilingNotice(`No Form 4 filings found for ${ticker.metadata.ticker}.`, width);
305
246
  }
306
247
 
307
248
  return (
@@ -321,14 +262,6 @@ export function InsiderView({ width, height, focused }: Pick<DetailTabProps, "wi
321
262
  );
322
263
  }
323
264
 
324
- function InsiderTab(props: DetailTabProps) {
325
- return <InsiderView {...props} />;
326
- }
327
-
328
- function InsiderPane({ focused, width, height }: PaneProps) {
329
- return <InsiderView focused={focused} width={width} height={height} />;
330
- }
331
-
332
265
  export const insiderPlugin: GloomPlugin = {
333
266
  id: "insider",
334
267
  name: "Insider Trading",
@@ -340,7 +273,7 @@ export const insiderPlugin: GloomPlugin = {
340
273
  id: "insider",
341
274
  name: "Insider",
342
275
  icon: "I",
343
- component: InsiderPane,
276
+ component: InsiderView,
344
277
  defaultPosition: "right",
345
278
  defaultMode: "floating",
346
279
  defaultFloatingSize: { width: 100, height: 30 },
@@ -364,7 +297,7 @@ export const insiderPlugin: GloomPlugin = {
364
297
  id: "insider",
365
298
  name: "Insider",
366
299
  order: 47,
367
- component: InsiderTab,
300
+ component: InsiderView,
368
301
  isVisible: ({ ticker }) => isUsEquityTicker(ticker),
369
302
  });
370
303
  },
@@ -191,12 +191,13 @@ const INDEX_SHORT: Record<string, string> = {
191
191
  "^RUT": "RUT",
192
192
  };
193
193
 
194
- export function MarketMoversPane({ focused, width, height }: PaneProps) {
194
+ function MarketMoversPane({ focused, width, height }: PaneProps) {
195
195
  const dataProvider = useAssetData();
196
196
  const { pinTicker } = usePluginTickerActions();
197
197
  const [activeTab, setActiveTab] = useState<TabId>("gainers");
198
198
  const [quotes, setQuotes] = useState<ScreenerQuote[]>([]);
199
199
  const [loading, setLoading] = useState(false);
200
+ const [loadError, setLoadError] = useState<string | null>(null);
200
201
  const [selectedSymbol, setSelectedSymbol] = useState<string | null>(null);
201
202
  const [sortPreference, setSortPreference] = useState<MarketMoverSortPreference>(DEFAULT_SORT_PREFERENCE);
202
203
  const [summaryQuotes, setSummaryQuotes] = useState<MarketSummaryQuote[]>([]);
@@ -260,12 +261,14 @@ export function MarketMoversPane({ focused, width, height }: PaneProps) {
260
261
  if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
261
262
  setQuotes(cached.data);
262
263
  setSelectedSymbol(null);
264
+ setLoadError(null);
263
265
  return;
264
266
  }
265
267
 
266
268
  fetchGenRef.current += 1;
267
269
  const gen = fetchGenRef.current;
268
270
  setLoading(true);
271
+ setLoadError(null);
269
272
 
270
273
  try {
271
274
  let data: ScreenerQuote[];
@@ -318,7 +321,12 @@ export function MarketMoversPane({ focused, width, height }: PaneProps) {
318
321
  cacheRef.current.set(tab, { data, fetchedAt: Date.now() });
319
322
  setQuotes(data);
320
323
  setSelectedSymbol(null);
321
- } catch { /* leave existing data */ }
324
+ setLoadError(null);
325
+ } catch (error) {
326
+ if (fetchGenRef.current === gen) {
327
+ setLoadError(error instanceof Error ? error.message : "Market movers unavailable");
328
+ }
329
+ }
322
330
  finally {
323
331
  if (fetchGenRef.current === gen) setLoading(false);
324
332
  }
@@ -337,22 +345,6 @@ export function MarketMoversPane({ focused, width, height }: PaneProps) {
337
345
  const handleTableKeyDown = useCallback((event: DataTableKeyEvent) => {
338
346
  const key = event.name;
339
347
 
340
- if (key === "tab" || key === "right" || key === "l") {
341
- event.preventDefault?.();
342
- event.stopPropagation?.();
343
- const currentTabIdx = TABS.findIndex((t) => t.id === activeTab);
344
- setActiveTab(TABS[(currentTabIdx + 1) % TABS.length]!.id);
345
- setSelectedSymbol(null);
346
- return true;
347
- }
348
- if (key === "left" || key === "h") {
349
- event.preventDefault?.();
350
- event.stopPropagation?.();
351
- const currentTabIdx = TABS.findIndex((t) => t.id === activeTab);
352
- setActiveTab(TABS[(currentTabIdx - 1 + TABS.length) % TABS.length]!.id);
353
- setSelectedSymbol(null);
354
- return true;
355
- }
356
348
  if (key === "r") {
357
349
  event.preventDefault?.();
358
350
  event.stopPropagation?.();
@@ -443,6 +435,7 @@ export function MarketMoversPane({ focused, width, height }: PaneProps) {
443
435
  }}
444
436
  compact
445
437
  variant="bare"
438
+ focused={focused}
446
439
  />
447
440
  </Box>
448
441
 
@@ -461,7 +454,8 @@ export function MarketMoversPane({ focused, width, height }: PaneProps) {
461
454
  onSelect={(row) => setSelectedSymbol(row.symbol)}
462
455
  onActivate={(row) => openSymbol(row.symbol)}
463
456
  renderCell={renderCell}
464
- emptyStateTitle={loading ? "Loading movers..." : "No data"}
457
+ emptyStateTitle={loading ? "Loading movers..." : loadError ?? "No data"}
458
+ emptyStateHint={loadError ? "Yahoo Finance did not return market movers." : undefined}
465
459
  />
466
460
  </Box>
467
461
  );
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { parseScreenerResponse, parseTrendingResponse } from "./screener";
2
+ import {
3
+ createYahooScreenerApi,
4
+ fetchScreener,
5
+ parseScreenerResponse,
6
+ parseTrendingResponse,
7
+ } from "./screener";
3
8
 
4
9
  const SAMPLE_SCREENER_RESPONSE = {
5
10
  finance: {
@@ -123,3 +128,36 @@ describe("parseTrendingResponse", () => {
123
128
  expect(results[0]!.symbol).toBe("SPY");
124
129
  });
125
130
  });
131
+
132
+ describe("fetchScreener", () => {
133
+ test("falls back to the secondary Yahoo host when the primary host fails", async () => {
134
+ const requestedHosts: string[] = [];
135
+ const userAgents: string[] = [];
136
+ const api = createYahooScreenerApi(async (url, init) => {
137
+ const parsed = new URL(url);
138
+ requestedHosts.push(parsed.host);
139
+ userAgents.push(String((init?.headers as Record<string, string> | undefined)?.["User-Agent"] ?? ""));
140
+
141
+ if (parsed.host === "query2.finance.yahoo.com") {
142
+ return new Response("Forbidden", { status: 403 });
143
+ }
144
+
145
+ expect(parsed.pathname).toBe("/v1/finance/screener/predefined/saved");
146
+ expect(parsed.searchParams.get("formatted")).toBe("false");
147
+ expect(parsed.searchParams.get("lang")).toBe("en-US");
148
+ expect(parsed.searchParams.get("region")).toBe("US");
149
+ expect(parsed.searchParams.get("scrIds")).toBe("day_gainers");
150
+ expect(parsed.searchParams.get("count")).toBe("2");
151
+ return Response.json(SAMPLE_SCREENER_RESPONSE);
152
+ });
153
+
154
+ const results = await fetchScreener("day_gainers", 2, api);
155
+
156
+ expect(requestedHosts).toEqual([
157
+ "query2.finance.yahoo.com",
158
+ "query1.finance.yahoo.com",
159
+ ]);
160
+ expect(userAgents.every((agent) => agent.includes("Mozilla/5.0"))).toBe(true);
161
+ expect(results.map((result) => result.symbol)).toEqual(["AAPL", "MSFT"]);
162
+ });
163
+ });