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
@@ -863,6 +863,7 @@ export function TwitterFeedPane({ focused, width, height }: PaneProps) {
863
863
  variant="pill"
864
864
  closeMode="active"
865
865
  onAdd={editorState ? undefined : openCreateEditor}
866
+ focused={focused && !editorState}
866
867
  />
867
868
  </Box>
868
869
 
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import { Box, ScrollBox, Text, TextAttributes } from "../../../ui";
2
+ import { Box, ScrollBox, Text, TextAttributes, useUiHost } from "../../../ui";
3
3
  import { useShortcut } from "../../../react/input";
4
4
  import { SpeedometerGauge, StaticChartSurface, usePaneFooter, type SpeedometerSegment } from "../../../components";
5
5
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
@@ -16,6 +16,8 @@ import {
16
16
  } from "./fear-greed-data";
17
17
 
18
18
  const CACHE_TTL_MS = 5 * 60 * 1000;
19
+ const DESKTOP_SUMMARY_STACK_WIDTH = 84;
20
+ const CHART_META_STACK_WIDTH = 84;
19
21
  const FEAR_GREED_GAUGE_SEGMENTS: SpeedometerSegment[] = [
20
22
  { from: 0, to: 24.999, label: "EXTREME FEAR", color: colors.negative },
21
23
  { from: 25, to: 44.999, label: "FEAR", color: colors.warning },
@@ -134,19 +136,37 @@ function SentimentBadge({ rating }: { rating: FearGreedRating }) {
134
136
  );
135
137
  }
136
138
 
137
- function PreviousScoreGrid({ data, width }: { data: FearGreedData; width: number }) {
139
+ function PreviousScoreGrid({ data, width, layout = "grid" }: { data: FearGreedData; width: number; layout?: "grid" | "rail" }) {
138
140
  const items = [
139
141
  { label: "Previous close", value: data.overall.previousClose },
140
142
  { label: "1 week ago", value: data.overall.previousWeek },
141
143
  { label: "1 month ago", value: data.overall.previousMonth },
142
144
  { label: "1 year ago", value: data.overall.previousYear },
143
145
  ];
144
- const columns = width >= 84 ? 4 : 2;
146
+ const columns = width >= 84 ? 4 : width >= 42 ? 2 : 1;
145
147
  const columnWidth = Math.max(18, Math.floor((width - 2) / columns));
146
148
  const rows = Array.from({ length: Math.ceil(items.length / columns) }, (_, rowIndex) => (
147
149
  items.slice(rowIndex * columns, rowIndex * columns + columns)
148
150
  ));
149
151
 
152
+ if (layout === "rail") {
153
+ return (
154
+ <Box flexDirection="column" width={width} flexShrink={0}>
155
+ {items.map((item) => {
156
+ const value = item.value;
157
+ const color = value == null ? colors.textDim : ratingColor(value < 25 ? "extreme fear" : value < 45 ? "fear" : value <= 55 ? "neutral" : "greed");
158
+ return (
159
+ <Box key={item.label} flexDirection="row" height={1}>
160
+ <Text fg={colors.textDim}>{item.label}: </Text>
161
+ <Box flexGrow={1} />
162
+ <Text fg={color} attributes={TextAttributes.BOLD}>{formatScore(value)}</Text>
163
+ </Box>
164
+ );
165
+ })}
166
+ </Box>
167
+ );
168
+ }
169
+
150
170
  return (
151
171
  <Box flexDirection="column" paddingX={1} marginTop={1}>
152
172
  {rows.map((row, rowIndex) => (
@@ -184,7 +204,6 @@ function chartOverlay(indicator: FearGreedIndicator): ChartIndicatorOverlays | n
184
204
 
185
205
  function SentimentChart({
186
206
  title,
187
- subtitle,
188
207
  rating,
189
208
  score,
190
209
  points,
@@ -197,7 +216,6 @@ function SentimentChart({
197
216
  overlays,
198
217
  }: {
199
218
  title: string;
200
- subtitle: string;
201
219
  rating: FearGreedRating;
202
220
  score: number | null;
203
221
  points: FearGreedIndicator["points"];
@@ -209,6 +227,8 @@ function SentimentChart({
209
227
  secondaryValue?: number | null;
210
228
  overlays?: ChartIndicatorOverlays | null;
211
229
  }) {
230
+ const isDesktopWeb = useUiHost().kind === "desktop-web";
231
+ const stackMeta = width < CHART_META_STACK_WIDTH;
212
232
  const chartWidth = Math.max(24, width - 2);
213
233
  const chartHeight = width >= 96 ? 12 : 10;
214
234
  const color = ratingColor(rating);
@@ -222,36 +242,42 @@ function SentimentChart({
222
242
  const latest = points.length > 0 ? points[points.length - 1]!.close : null;
223
243
 
224
244
  return (
225
- <Box flexDirection="column" marginTop={2} paddingX={1}>
245
+ <Box flexDirection="column" marginTop={isDesktopWeb ? 1 : 2} paddingX={1}>
226
246
  <Box flexDirection="row" height={1}>
227
247
  <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{title.toUpperCase()}</Text>
228
248
  <Box flexGrow={1} />
229
249
  <SentimentBadge rating={rating} />
230
250
  </Box>
231
- <Box height={1} marginTop={1}>
232
- <Text fg={colors.text} attributes={TextAttributes.BOLD}>{subtitle}</Text>
233
- </Box>
234
- <Box flexDirection="row" height={1}>
235
- <Text fg={color}>● </Text>
236
- <Text fg={colors.textDim}>{primaryLabel}</Text>
237
- {secondaryLabel ? (
238
- <>
239
- <Text fg={colors.warning}> ● </Text>
240
- <Text fg={colors.textDim}>{secondaryLabel}</Text>
241
- </>
242
- ) : null}
243
- <Box flexGrow={1} />
244
- <Text fg={colors.textDim}>score </Text>
245
- <Text fg={color} attributes={TextAttributes.BOLD}>{formatScore(score)}</Text>
246
- <Text fg={colors.textDim}> latest </Text>
247
- <Text fg={colors.text}>{formatIndicatorValue(latest, valueFormat)}</Text>
248
- {secondaryLabel && secondaryValue != null ? (
249
- <>
250
- <Text fg={colors.textDim}> avg </Text>
251
- <Text fg={colors.text}>{formatIndicatorValue(secondaryValue, valueFormat)}</Text>
252
- </>
253
- ) : null}
254
- </Box>
251
+ {stackMeta ? (
252
+ <>
253
+ <Box flexDirection="row" height={1} overflow="hidden">
254
+ <SeriesLegend color={color} primaryLabel={primaryLabel} secondaryLabel={secondaryLabel} />
255
+ </Box>
256
+ <Box flexDirection="row" height={1} overflow="hidden">
257
+ <ChartStats
258
+ color={color}
259
+ latest={latest}
260
+ score={score}
261
+ secondaryLabel={secondaryLabel}
262
+ secondaryValue={secondaryValue}
263
+ valueFormat={valueFormat}
264
+ />
265
+ </Box>
266
+ </>
267
+ ) : (
268
+ <Box flexDirection="row" height={1} overflow="hidden">
269
+ <SeriesLegend color={color} primaryLabel={primaryLabel} secondaryLabel={secondaryLabel} />
270
+ <Box flexGrow={1} />
271
+ <ChartStats
272
+ color={color}
273
+ latest={latest}
274
+ score={score}
275
+ secondaryLabel={secondaryLabel}
276
+ secondaryValue={secondaryValue}
277
+ valueFormat={valueFormat}
278
+ />
279
+ </Box>
280
+ )}
255
281
  {points.length >= 2 ? (
256
282
  <Box marginTop={1}>
257
283
  <StaticChartSurface
@@ -279,6 +305,60 @@ function SentimentChart({
279
305
  );
280
306
  }
281
307
 
308
+ function SeriesLegend({
309
+ color,
310
+ primaryLabel,
311
+ secondaryLabel,
312
+ }: {
313
+ color: string;
314
+ primaryLabel: string;
315
+ secondaryLabel?: string;
316
+ }) {
317
+ return (
318
+ <>
319
+ <Text fg={color}>● </Text>
320
+ <Text fg={colors.textDim}>{primaryLabel}</Text>
321
+ {secondaryLabel ? (
322
+ <>
323
+ <Text fg={colors.warning}> ● </Text>
324
+ <Text fg={colors.textDim}>{secondaryLabel}</Text>
325
+ </>
326
+ ) : null}
327
+ </>
328
+ );
329
+ }
330
+
331
+ function ChartStats({
332
+ color,
333
+ latest,
334
+ score,
335
+ secondaryLabel,
336
+ secondaryValue,
337
+ valueFormat,
338
+ }: {
339
+ color: string;
340
+ latest: number | null;
341
+ score: number | null;
342
+ secondaryLabel?: string;
343
+ secondaryValue?: number | null;
344
+ valueFormat: FearGreedValueFormat;
345
+ }) {
346
+ return (
347
+ <>
348
+ <Text fg={colors.textDim}>score </Text>
349
+ <Text fg={color} attributes={TextAttributes.BOLD}>{formatScore(score)}</Text>
350
+ <Text fg={colors.textDim}> latest </Text>
351
+ <Text fg={colors.text}>{formatIndicatorValue(latest, valueFormat)}</Text>
352
+ {secondaryLabel && secondaryValue != null ? (
353
+ <>
354
+ <Text fg={colors.textDim}> avg </Text>
355
+ <Text fg={colors.text}>{formatIndicatorValue(secondaryValue, valueFormat)}</Text>
356
+ </>
357
+ ) : null}
358
+ </>
359
+ );
360
+ }
361
+
282
362
  function IndexHistoryChart({ data, width }: { data: FearGreedData; width: number }) {
283
363
  const indicator: FearGreedIndicator = {
284
364
  definition: {
@@ -301,7 +381,6 @@ function IndexHistoryChart({ data, width }: { data: FearGreedData; width: number
301
381
  return (
302
382
  <SentimentChart
303
383
  title={indicator.definition.title}
304
- subtitle={indicator.definition.subtitle}
305
384
  rating={indicator.rating}
306
385
  score={indicator.score}
307
386
  points={indicator.points}
@@ -314,6 +393,7 @@ function IndexHistoryChart({ data, width }: { data: FearGreedData; width: number
314
393
  }
315
394
 
316
395
  function FearGreedPane({ paneId, focused, width, height }: PaneProps) {
396
+ const isDesktopWeb = useUiHost().kind === "desktop-web";
317
397
  const [data, setData] = useState<FearGreedData | null>(sharedCache?.data ?? null);
318
398
  const [loading, setLoading] = useState(!sharedCache);
319
399
  const [error, setError] = useState<string | null>(null);
@@ -354,6 +434,11 @@ function FearGreedPane({ paneId, focused, width, height }: PaneProps) {
354
434
  void load(true);
355
435
  }, [load]);
356
436
 
437
+ const stackDesktopSummary = isDesktopWeb && width < DESKTOP_SUMMARY_STACK_WIDTH;
438
+ const desktopSummaryRailWidth = stackDesktopSummary ? Math.max(18, Math.min(width - 2, 42)) : 26;
439
+ const desktopSummaryGaugeMaxWidth = Math.max(1, Math.min(width - 2, 50));
440
+ const desktopSummaryGaugeMinWidth = Math.min(34, desktopSummaryGaugeMaxWidth);
441
+
357
442
  useShortcut((event) => {
358
443
  if (!focused) return;
359
444
  if (event.name === "r") {
@@ -402,18 +487,43 @@ function FearGreedPane({ paneId, focused, width, height }: PaneProps) {
402
487
  <Box flexDirection="column" width={width} height={height}>
403
488
  <ScrollBox flexGrow={1} scrollY focusable={false}>
404
489
  <Box flexDirection="column" paddingBottom={1}>
405
- <SpeedometerGauge
406
- value={data.overall.score}
407
- valueLabel={ratingLabel(data.overall.rating)}
408
- width={width}
409
- segments={FEAR_GREED_GAUGE_SEGMENTS}
410
- />
490
+ {isDesktopWeb ? (
491
+ <Box
492
+ flexDirection={stackDesktopSummary ? "column" : "row"}
493
+ alignItems="center"
494
+ justifyContent="center"
495
+ gap={stackDesktopSummary ? 0 : 4}
496
+ paddingX={1}
497
+ >
498
+ <SpeedometerGauge
499
+ value={data.overall.score}
500
+ valueLabel={ratingLabel(data.overall.rating)}
501
+ width={width}
502
+ segments={FEAR_GREED_GAUGE_SEGMENTS}
503
+ minWidth={stackDesktopSummary ? desktopSummaryGaugeMinWidth : undefined}
504
+ maxWidth={stackDesktopSummary ? desktopSummaryGaugeMaxWidth : undefined}
505
+ compact={stackDesktopSummary}
506
+ />
507
+ <Box marginTop={0}>
508
+ <PreviousScoreGrid data={data} width={desktopSummaryRailWidth} layout="rail" />
509
+ </Box>
510
+ </Box>
511
+ ) : (
512
+ <>
513
+ <SpeedometerGauge
514
+ value={data.overall.score}
515
+ valueLabel={ratingLabel(data.overall.rating)}
516
+ width={width}
517
+ segments={FEAR_GREED_GAUGE_SEGMENTS}
518
+ />
519
+ <PreviousScoreGrid data={data} width={width} />
520
+ </>
521
+ )}
411
522
  {loading ? (
412
523
  <Box height={1} paddingX={1} marginTop={1} justifyContent="center">
413
524
  <Text fg={colors.textMuted}>refreshing...</Text>
414
525
  </Box>
415
526
  ) : null}
416
- <PreviousScoreGrid data={data} width={width} />
417
527
  {error ? (
418
528
  <Box paddingX={1} marginTop={1}>
419
529
  <Text fg={colors.warning}>{error}</Text>
@@ -427,7 +537,6 @@ function FearGreedPane({ paneId, focused, width, height }: PaneProps) {
427
537
  <SentimentChart
428
538
  key={indicator.definition.id}
429
539
  title={indicator.definition.title}
430
- subtitle={indicator.definition.subtitle}
431
540
  rating={indicator.rating}
432
541
  score={indicator.score}
433
542
  points={indicator.points}
@@ -1,5 +1,10 @@
1
1
  import { expect, test } from "bun:test";
2
- import { buildTreemap, buildTreemapRects } from "./index";
2
+ import {
3
+ buildTreemap,
4
+ buildTreemapRects,
5
+ formatHolderOwnershipPercent,
6
+ resolveHolderOwnershipPercent,
7
+ } from "./index";
3
8
  import type { HolderRecord } from "../../../types/financials";
4
9
 
5
10
  function holder(name: string, value: number): HolderRecord & { id: string } {
@@ -43,3 +48,10 @@ test("groups large holders using terminal cell aspect instead of full-height str
43
48
  expect(rects[1]!.height).toBeLessThan(40);
44
49
  expect(rects[0]!.width).toBeCloseTo(rects[1]!.width, 8);
45
50
  });
51
+
52
+ test("resolves holder ownership from provider percent before value over market cap", () => {
53
+ expect(resolveHolderOwnershipPercent({ percentHeld: 0.085, value: 120 }, 1_000)).toBe(0.085);
54
+ expect(resolveHolderOwnershipPercent({ value: 120 }, 1_000)).toBe(0.12);
55
+ expect(resolveHolderOwnershipPercent({ value: 120 }, undefined)).toBeUndefined();
56
+ expect(formatHolderOwnershipPercent(0.085)).toBe("8.50%");
57
+ });
@@ -90,6 +90,23 @@ function formatMaybePercent(value: number | undefined): string {
90
90
  return Math.abs(value) <= 1 ? formatPercent(value) : formatPercentRaw(value);
91
91
  }
92
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
+
93
110
  function formatSignedCompact(value: number | undefined): string {
94
111
  if (value == null) return "-";
95
112
  const sign = value > 0 ? "+" : "";
@@ -133,7 +150,7 @@ function buildColumns(width: number): HolderColumn[] {
133
150
  ];
134
151
  }
135
152
 
136
- function sortValue(row: HolderRow, columnId: HolderColumnId): string | number | null {
153
+ function sortValue(row: HolderRow, columnId: HolderColumnId, marketCap?: number): string | number | null {
137
154
  switch (columnId) {
138
155
  case "holder":
139
156
  return row.name;
@@ -146,7 +163,7 @@ function sortValue(row: HolderRow, columnId: HolderColumnId): string | number |
146
163
  case "changePercent":
147
164
  return row.changePercent ?? null;
148
165
  case "percentHeld":
149
- return row.percentHeld ?? null;
166
+ return resolveHolderOwnershipPercent(row, marketCap) ?? null;
150
167
  case "reportDate":
151
168
  return row.reportDate ?? null;
152
169
  }
@@ -167,10 +184,10 @@ function compareSortValues(
167
184
  return direction === "asc" ? comparison : -comparison;
168
185
  }
169
186
 
170
- function sortRows(rows: HolderRow[], preference: SortPreference): HolderRow[] {
187
+ function sortRows(rows: HolderRow[], preference: SortPreference, marketCap?: number): HolderRow[] {
171
188
  return [...rows].sort((left, right) => compareSortValues(
172
- sortValue(left, preference.columnId),
173
- sortValue(right, preference.columnId),
189
+ sortValue(left, preference.columnId, marketCap),
190
+ sortValue(right, preference.columnId, marketCap),
174
191
  preference.direction,
175
192
  ));
176
193
  }
@@ -428,10 +445,11 @@ function desktopTileColor(row: HolderRow): string {
428
445
  return blendHex(colors.panel, row.changePercent > 0 ? colors.positive : colors.negative, intensity);
429
446
  }
430
447
 
431
- function Tile({ tile, selected, currency, onSelect }: {
448
+ function Tile({ tile, selected, currency, marketCap, onSelect }: {
432
449
  tile: TileLayout;
433
450
  selected: boolean;
434
451
  currency: string;
452
+ marketCap?: number;
435
453
  onSelect: () => void;
436
454
  }) {
437
455
  const renderWidth = Math.max(1, tile.width - (tile.width > 2 ? 1 : 0));
@@ -443,6 +461,7 @@ function Tile({ tile, selected, currency, onSelect }: {
443
461
  const amount = tile.row.value != null
444
462
  ? formatMoneyCompact(tile.row.value, currency)
445
463
  : formatCompact(tile.row.shares);
464
+ const ownership = formatHolderOwnershipLine(tile.row, marketCap);
446
465
  const change = tile.row.changePercent != null ? formatMaybePercent(tile.row.changePercent) : "No change";
447
466
 
448
467
  return (
@@ -463,6 +482,9 @@ function Tile({ tile, selected, currency, onSelect }: {
463
482
  <Text fg={textColor} attributes={attributes}>{padTo(amount, innerWidth)}</Text>
464
483
  )}
465
484
  {renderHeight >= 3 && (
485
+ <Text fg={textColor} attributes={attributes}>{padTo(ownership ?? change, innerWidth)}</Text>
486
+ )}
487
+ {ownership && renderHeight >= 4 && (
466
488
  <Text fg={textColor} attributes={attributes}>{padTo(change, innerWidth)}</Text>
467
489
  )}
468
490
  </Box>
@@ -473,21 +495,24 @@ function pct(value: number, total: number): string {
473
495
  return `${total > 0 ? value / total * 100 : 0}%`;
474
496
  }
475
497
 
476
- function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currency, onSelect, onHover }: {
498
+ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currency, marketCap, onSelect, onHover }: {
477
499
  tile: FloatTileLayout;
478
500
  chartWidth: number;
479
501
  chartHeight: number;
480
502
  selected: boolean;
481
503
  hovered: boolean;
482
504
  currency: string;
505
+ marketCap?: number;
483
506
  onSelect: () => void;
484
507
  onHover: (hovered: boolean) => void;
485
508
  }) {
486
509
  const amount = tile.row.value != null
487
510
  ? formatMoneyCompact(tile.row.value, currency)
488
511
  : formatCompact(tile.row.shares);
512
+ const ownership = formatHolderOwnershipLine(tile.row, marketCap);
489
513
  const change = tile.row.changePercent != null ? formatMaybePercent(tile.row.changePercent) : "No change";
490
514
  const canShowDetails = tile.width >= 7 && tile.height >= 3;
515
+ const canShowChange = tile.height >= 3.7;
491
516
  const canShowLabel = tile.width >= 4 && tile.height >= 1.4;
492
517
  const isTiny = tile.width < 5 || tile.height < 2;
493
518
  const backgroundColor = desktopTileColor(tile.row);
@@ -562,20 +587,24 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
562
587
  {canShowDetails && (
563
588
  <>
564
589
  <Text fg="#ffffff" style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{amount}</Text>
565
- <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
+ )}
566
594
  </>
567
595
  )}
568
596
  </Box>
569
597
  );
570
598
  }
571
599
 
572
- function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, currency, cellAspect }: {
600
+ function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, currency, marketCap, cellAspect }: {
573
601
  rows: HolderRow[];
574
602
  width: number;
575
603
  height: number;
576
604
  selectedId: string | null;
577
605
  onSelect: (row: HolderRow) => void;
578
606
  currency: string;
607
+ marketCap?: number;
579
608
  cellAspect: number;
580
609
  }) {
581
610
  const [hoveredId, setHoveredId] = useState<string | null>(null);
@@ -621,6 +650,7 @@ function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, curr
621
650
  selected={tile.row.id === selectedId}
622
651
  hovered={tile.row.id === hoveredId}
623
652
  currency={currency}
653
+ marketCap={marketCap}
624
654
  onSelect={() => onSelect(tile.row)}
625
655
  onHover={(isHovered) => setHoveredId((current) => (isHovered ? tile.row.id : current === tile.row.id ? null : current))}
626
656
  />
@@ -630,13 +660,14 @@ function DesktopHoldersTreemap({ rows, width, height, selectedId, onSelect, curr
630
660
  );
631
661
  }
632
662
 
633
- function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }: {
663
+ function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency, marketCap }: {
634
664
  rows: HolderRow[];
635
665
  width: number;
636
666
  height: number;
637
667
  selectedId: string | null;
638
668
  onSelect: (row: HolderRow) => void;
639
669
  currency: string;
670
+ marketCap?: number;
640
671
  }) {
641
672
  const { cellWidthPx = 8, cellHeightPx = 18, nativePaneChrome } = useUiCapabilities();
642
673
  const chartWidth = Math.max(1, width - 2);
@@ -652,6 +683,7 @@ function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }:
652
683
  selectedId={selectedId}
653
684
  onSelect={onSelect}
654
685
  currency={currency}
686
+ marketCap={marketCap}
655
687
  cellAspect={cellAspect}
656
688
  />
657
689
  );
@@ -674,6 +706,7 @@ function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }:
674
706
  tile={tile}
675
707
  selected={tile.row.id === selectedId}
676
708
  currency={currency}
709
+ marketCap={marketCap}
677
710
  onSelect={() => onSelect(tile.row)}
678
711
  />
679
712
  ))}
@@ -684,7 +717,7 @@ function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }:
684
717
 
685
718
  function HoldersView({ focused, width, height }: { focused: boolean; width: number; height: number }) {
686
719
  const { nativePaneChrome } = useUiCapabilities();
687
- const { symbol, ticker } = usePaneTicker();
720
+ const { symbol, ticker, financials } = usePaneTicker();
688
721
  const dataProvider = useAssetData();
689
722
  const [viewMode, setViewMode] = usePluginPaneState<ViewMode>("viewMode", "chart");
690
723
  const [sortPreference, setSortPreference] = usePluginPaneState<SortPreference>("sortPreference", DEFAULT_SORT);
@@ -695,9 +728,11 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
695
728
  const fetchGenRef = useRef(0);
696
729
 
697
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;
698
733
  const exchange = ticker?.metadata.exchange ?? "";
699
734
  const rows = useMemo(() => buildRows(data), [data]);
700
- const sortedRows = useMemo(() => sortRows(rows, sortPreference), [rows, sortPreference]);
735
+ const sortedRows = useMemo(() => sortRows(rows, sortPreference, marketCap), [marketCap, rows, sortPreference]);
701
736
  const columns = useMemo(() => buildColumns(width), [width]);
702
737
  const selectedIdx = selectedId
703
738
  ? sortedRows.findIndex((row) => row.id === selectedId)
@@ -833,11 +868,14 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
833
868
  color: selectedColor ?? (row.changePercent != null ? priceColor(row.changePercent) : colors.textDim),
834
869
  };
835
870
  case "percentHeld":
836
- return { text: formatMaybePercent(row.percentHeld), color: selectedColor ?? colors.textDim };
871
+ return {
872
+ text: formatHolderOwnershipPercent(resolveHolderOwnershipPercent(row, marketCap)),
873
+ color: selectedColor ?? colors.textDim,
874
+ };
837
875
  case "reportDate":
838
876
  return { text: displayDate(row.reportDate), color: selectedColor ?? colors.textDim };
839
877
  }
840
- }, [currency]);
878
+ }, [currency, marketCap]);
841
879
 
842
880
  usePaneFooter("holders", () => ({
843
881
  info: [
@@ -866,6 +904,7 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
866
904
  onSelect={(value) => setViewMode(value as ViewMode)}
867
905
  compact
868
906
  variant="bare"
907
+ focused={focused}
869
908
  />
870
909
  </Box>
871
910
 
@@ -896,6 +935,7 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
896
935
  selectedId={selectedId}
897
936
  onSelect={(row) => setSelectedId(row.id)}
898
937
  currency={currency}
938
+ marketCap={marketCap}
899
939
  />
900
940
  )}
901
941
  </Box>
@@ -197,6 +197,7 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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
+ });