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
@@ -0,0 +1,587 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Box, ScrollBox, Text, TextAttributes, useUiHost } from "../../../ui";
3
+ import { useShortcut } from "../../../react/input";
4
+ import { SpeedometerGauge, StaticChartSurface, usePaneFooter, type SpeedometerSegment } from "../../../components";
5
+ import type { GloomPlugin, PaneProps } from "../../../types/plugin";
6
+ import { colors, blendHex } from "../../../theme/colors";
7
+ import { resolveChartPalette } from "../../../components/chart/chart-renderer";
8
+ import type { ChartIndicatorOverlays } from "../../../components/chart/chart-types";
9
+ import { formatNumber } from "../../../utils/format";
10
+ import {
11
+ fetchFearGreedData,
12
+ type FearGreedData,
13
+ type FearGreedIndicator,
14
+ type FearGreedRating,
15
+ type FearGreedValueFormat,
16
+ } from "./fear-greed-data";
17
+
18
+ const CACHE_TTL_MS = 5 * 60 * 1000;
19
+ const DESKTOP_SUMMARY_STACK_WIDTH = 84;
20
+ const CHART_META_STACK_WIDTH = 84;
21
+ const FEAR_GREED_GAUGE_SEGMENTS: SpeedometerSegment[] = [
22
+ { from: 0, to: 24.999, label: "EXTREME FEAR", color: colors.negative },
23
+ { from: 25, to: 44.999, label: "FEAR", color: colors.warning },
24
+ { from: 45, to: 55, label: "NEUTRAL", color: colors.neutral },
25
+ { from: 55.001, to: 75, label: "GREED", color: colors.positive },
26
+ { from: 75.001, to: 100, label: "EXTREME GREED", color: colors.positive },
27
+ ];
28
+
29
+ let sharedCache: { data: FearGreedData; fetchedAt: number } | null = null;
30
+ let activeFetch: Promise<FearGreedData> | null = null;
31
+
32
+ async function loadFearGreed(force = false): Promise<FearGreedData> {
33
+ if (!force && sharedCache && Date.now() - sharedCache.fetchedAt < CACHE_TTL_MS) {
34
+ return sharedCache.data;
35
+ }
36
+ if (activeFetch) return activeFetch;
37
+
38
+ activeFetch = fetchFearGreedData()
39
+ .then((data) => {
40
+ sharedCache = { data, fetchedAt: Date.now() };
41
+ return data;
42
+ })
43
+ .finally(() => {
44
+ activeFetch = null;
45
+ });
46
+ return activeFetch;
47
+ }
48
+
49
+ function ratingLabel(rating: FearGreedRating): string {
50
+ return rating.toUpperCase();
51
+ }
52
+
53
+ function ratingColor(rating: FearGreedRating): string {
54
+ switch (rating) {
55
+ case "extreme fear":
56
+ return colors.negative;
57
+ case "fear":
58
+ return colors.warning;
59
+ case "neutral":
60
+ return colors.neutral;
61
+ case "greed":
62
+ case "extreme greed":
63
+ return colors.positive;
64
+ }
65
+ }
66
+
67
+ function ratingTrend(rating: FearGreedRating): "positive" | "negative" | "neutral" {
68
+ switch (rating) {
69
+ case "extreme fear":
70
+ case "fear":
71
+ return "negative";
72
+ case "neutral":
73
+ return "neutral";
74
+ case "greed":
75
+ case "extreme greed":
76
+ return "positive";
77
+ }
78
+ }
79
+
80
+ function formatScore(score: number | null | undefined): string {
81
+ if (score == null || Number.isNaN(score)) return "--";
82
+ return String(Math.round(score));
83
+ }
84
+
85
+ function formatIndicatorValue(value: number | null | undefined, format: FearGreedValueFormat): string {
86
+ if (value == null || Number.isNaN(value)) return "--";
87
+ switch (format) {
88
+ case "score":
89
+ return formatScore(value);
90
+ case "percent":
91
+ return `${formatNumber(value, 2)}%`;
92
+ case "ratio":
93
+ return value.toFixed(2);
94
+ case "number": {
95
+ const abs = Math.abs(value);
96
+ if (abs >= 1000) return formatNumber(value, 0);
97
+ if (abs >= 100) return formatNumber(value, 1);
98
+ return formatNumber(value, 2);
99
+ }
100
+ }
101
+ }
102
+
103
+ function formatAxisValue(format: FearGreedValueFormat): (value: number) => string {
104
+ return (value) => formatIndicatorValue(value, format);
105
+ }
106
+
107
+ function formatUpdatedAt(date: Date | null): string {
108
+ if (!date) return "Last updated --";
109
+ const parts = new Intl.DateTimeFormat("en-US", {
110
+ timeZone: "America/New_York",
111
+ month: "short",
112
+ day: "numeric",
113
+ hour: "numeric",
114
+ minute: "2-digit",
115
+ hour12: true,
116
+ }).formatToParts(date);
117
+ const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
118
+ return `Last updated ${part("month")} ${part("day")} at ${part("hour")}:${part("minute")} ${part("dayPeriod")} ET`;
119
+ }
120
+
121
+ function formatAge(ms: number): string {
122
+ const secs = Math.max(0, Math.floor(ms / 1000));
123
+ if (secs < 60) return `${secs}s ago`;
124
+ const mins = Math.floor(secs / 60);
125
+ if (mins < 60) return `${mins}m ago`;
126
+ const hours = Math.floor(mins / 60);
127
+ return `${hours}h ago`;
128
+ }
129
+
130
+ function SentimentBadge({ rating }: { rating: FearGreedRating }) {
131
+ const color = ratingColor(rating);
132
+ return (
133
+ <Box height={1} paddingX={1} backgroundColor={blendHex(colors.bg, color, 0.28)}>
134
+ <Text fg={color} attributes={TextAttributes.BOLD}>{ratingLabel(rating)}</Text>
135
+ </Box>
136
+ );
137
+ }
138
+
139
+ function PreviousScoreGrid({ data, width, layout = "grid" }: { data: FearGreedData; width: number; layout?: "grid" | "rail" }) {
140
+ const items = [
141
+ { label: "Previous close", value: data.overall.previousClose },
142
+ { label: "1 week ago", value: data.overall.previousWeek },
143
+ { label: "1 month ago", value: data.overall.previousMonth },
144
+ { label: "1 year ago", value: data.overall.previousYear },
145
+ ];
146
+ const columns = width >= 84 ? 4 : width >= 42 ? 2 : 1;
147
+ const columnWidth = Math.max(18, Math.floor((width - 2) / columns));
148
+ const rows = Array.from({ length: Math.ceil(items.length / columns) }, (_, rowIndex) => (
149
+ items.slice(rowIndex * columns, rowIndex * columns + columns)
150
+ ));
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
+
170
+ return (
171
+ <Box flexDirection="column" paddingX={1} marginTop={1}>
172
+ {rows.map((row, rowIndex) => (
173
+ <Box key={rowIndex} flexDirection="row" height={1}>
174
+ {row.map((item) => {
175
+ const value = item.value;
176
+ const color = value == null ? colors.textDim : ratingColor(value < 25 ? "extreme fear" : value < 45 ? "fear" : value <= 55 ? "neutral" : "greed");
177
+ return (
178
+ <Box key={item.label} width={columnWidth} flexShrink={0} flexDirection="row">
179
+ <Text fg={colors.textDim}>{item.label}: </Text>
180
+ <Text fg={color} attributes={TextAttributes.BOLD}>{formatScore(value)}</Text>
181
+ </Box>
182
+ );
183
+ })}
184
+ </Box>
185
+ ))}
186
+ </Box>
187
+ );
188
+ }
189
+
190
+ function chartOverlay(indicator: FearGreedIndicator): ChartIndicatorOverlays | null {
191
+ if (indicator.secondaryPoints.length === 0) return null;
192
+ return {
193
+ smaLines: [{
194
+ period: 0,
195
+ points: indicator.secondaryPoints,
196
+ color: colors.warning,
197
+ }],
198
+ emaLines: [],
199
+ bollinger: null,
200
+ rsi: null,
201
+ macd: null,
202
+ };
203
+ }
204
+
205
+ function SentimentChart({
206
+ title,
207
+ rating,
208
+ score,
209
+ points,
210
+ width,
211
+ valueFormat,
212
+ updatedAt,
213
+ primaryLabel,
214
+ secondaryLabel,
215
+ secondaryValue,
216
+ overlays,
217
+ }: {
218
+ title: string;
219
+ rating: FearGreedRating;
220
+ score: number | null;
221
+ points: FearGreedIndicator["points"];
222
+ width: number;
223
+ valueFormat: FearGreedValueFormat;
224
+ updatedAt: Date | null;
225
+ primaryLabel: string;
226
+ secondaryLabel?: string;
227
+ secondaryValue?: number | null;
228
+ overlays?: ChartIndicatorOverlays | null;
229
+ }) {
230
+ const isDesktopWeb = useUiHost().kind === "desktop-web";
231
+ const stackMeta = width < CHART_META_STACK_WIDTH;
232
+ const chartWidth = Math.max(24, width - 2);
233
+ const chartHeight = width >= 96 ? 12 : 10;
234
+ const color = ratingColor(rating);
235
+ const basePalette = resolveChartPalette(colors, ratingTrend(rating));
236
+ const palette = {
237
+ ...basePalette,
238
+ lineColor: color,
239
+ fillColor: blendHex(colors.bg, color, 0.18),
240
+ gridColor: blendHex(colors.bg, colors.border, 0.55),
241
+ };
242
+ const latest = points.length > 0 ? points[points.length - 1]!.close : null;
243
+
244
+ return (
245
+ <Box flexDirection="column" marginTop={isDesktopWeb ? 1 : 2} paddingX={1}>
246
+ <Box flexDirection="row" height={1}>
247
+ <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{title.toUpperCase()}</Text>
248
+ <Box flexGrow={1} />
249
+ <SentimentBadge rating={rating} />
250
+ </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
+ )}
281
+ {points.length >= 2 ? (
282
+ <Box marginTop={1}>
283
+ <StaticChartSurface
284
+ points={points}
285
+ width={chartWidth}
286
+ height={chartHeight}
287
+ mode="line"
288
+ colors={palette}
289
+ indicators={overlays}
290
+ showTimeAxis
291
+ timeAxisColor={colors.textDim}
292
+ yAxisColor={colors.textDim}
293
+ formatYAxisValue={formatAxisValue(valueFormat)}
294
+ />
295
+ </Box>
296
+ ) : (
297
+ <Box height={chartHeight} marginTop={1} justifyContent="center" alignItems="center">
298
+ <Text fg={colors.textMuted}>Not enough chart data</Text>
299
+ </Box>
300
+ )}
301
+ <Box height={1} marginTop={1}>
302
+ <Text fg={colors.textDim}>{formatUpdatedAt(updatedAt)}</Text>
303
+ </Box>
304
+ </Box>
305
+ );
306
+ }
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
+
362
+ function IndexHistoryChart({ data, width }: { data: FearGreedData; width: number }) {
363
+ const indicator: FearGreedIndicator = {
364
+ definition: {
365
+ id: "index-history",
366
+ title: "Index History",
367
+ subtitle: "Fear & Greed score over the past year",
368
+ primaryKey: "fear_and_greed_historical",
369
+ primaryLabel: "Fear & Greed",
370
+ valueFormat: "score",
371
+ },
372
+ score: data.overall.score,
373
+ rating: data.overall.rating,
374
+ updatedAt: data.overall.updatedAt,
375
+ points: data.overall.history,
376
+ secondaryPoints: [],
377
+ latestValue: data.overall.score,
378
+ latestSecondaryValue: null,
379
+ };
380
+
381
+ return (
382
+ <SentimentChart
383
+ title={indicator.definition.title}
384
+ rating={indicator.rating}
385
+ score={indicator.score}
386
+ points={indicator.points}
387
+ width={width}
388
+ valueFormat={indicator.definition.valueFormat}
389
+ updatedAt={indicator.updatedAt}
390
+ primaryLabel={indicator.definition.primaryLabel}
391
+ />
392
+ );
393
+ }
394
+
395
+ function FearGreedPane({ paneId, focused, width, height }: PaneProps) {
396
+ const isDesktopWeb = useUiHost().kind === "desktop-web";
397
+ const [data, setData] = useState<FearGreedData | null>(sharedCache?.data ?? null);
398
+ const [loading, setLoading] = useState(!sharedCache);
399
+ const [error, setError] = useState<string | null>(null);
400
+ const [lastRefreshed, setLastRefreshed] = useState<number | null>(sharedCache?.fetchedAt ?? null);
401
+ const [now, setNow] = useState(Date.now());
402
+ const fetchGenRef = useRef(0);
403
+
404
+ const load = useCallback(async (force = false) => {
405
+ fetchGenRef.current += 1;
406
+ const gen = fetchGenRef.current;
407
+ setLoading(true);
408
+ setError(null);
409
+ try {
410
+ const nextData = await loadFearGreed(force);
411
+ if (fetchGenRef.current !== gen) return;
412
+ setData(nextData);
413
+ setLastRefreshed(Date.now());
414
+ } catch (err) {
415
+ if (fetchGenRef.current !== gen) return;
416
+ setError(err instanceof Error ? err.message : String(err));
417
+ } finally {
418
+ if (fetchGenRef.current === gen) setLoading(false);
419
+ }
420
+ }, []);
421
+
422
+ useEffect(() => {
423
+ if (!sharedCache) {
424
+ void load();
425
+ }
426
+ }, [load]);
427
+
428
+ useEffect(() => {
429
+ const interval = setInterval(() => setNow(Date.now()), 10_000);
430
+ return () => clearInterval(interval);
431
+ }, []);
432
+
433
+ const refresh = useCallback(() => {
434
+ void load(true);
435
+ }, [load]);
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
+
442
+ useShortcut((event) => {
443
+ if (!focused) return;
444
+ if (event.name === "r") {
445
+ event.preventDefault?.();
446
+ event.stopPropagation?.();
447
+ refresh();
448
+ }
449
+ });
450
+
451
+ const footerAge = lastRefreshed ? `refreshed ${formatAge(now - lastRefreshed)}` : loading ? "loading" : "";
452
+ usePaneFooter(paneId, () => ({
453
+ info: [
454
+ ...(data ? [{
455
+ id: "score",
456
+ parts: [
457
+ { text: `${formatScore(data.overall.score)} ${ratingLabel(data.overall.rating)}`, color: ratingColor(data.overall.rating), bold: true },
458
+ ],
459
+ }] : []),
460
+ ...(footerAge ? [{ id: "age", parts: [{ text: footerAge, tone: loading ? "muted" as const : "value" as const }] }] : []),
461
+ ...(error ? [{ id: "error", parts: [{ text: error, tone: "warning" as const }] }] : []),
462
+ ],
463
+ hints: [{ id: "refresh", key: "r", label: "efresh", onPress: refresh, disabled: loading }],
464
+ }), [data, error, footerAge, loading, paneId, refresh]);
465
+
466
+ if (loading && !data) {
467
+ return (
468
+ <Box flexDirection="column" width={width} height={height}>
469
+ <Box flexGrow={1} justifyContent="center" alignItems="center">
470
+ <Text fg={colors.textMuted}>Loading Fear & Greed...</Text>
471
+ </Box>
472
+ </Box>
473
+ );
474
+ }
475
+
476
+ if (!data) {
477
+ return (
478
+ <Box flexDirection="column" width={width} height={height}>
479
+ <Box flexGrow={1} justifyContent="center" alignItems="center" paddingX={1}>
480
+ <Text fg={colors.negative}>{error ?? "Fear & Greed data unavailable"}</Text>
481
+ </Box>
482
+ </Box>
483
+ );
484
+ }
485
+
486
+ return (
487
+ <Box flexDirection="column" width={width} height={height}>
488
+ <ScrollBox flexGrow={1} scrollY focusable={false}>
489
+ <Box flexDirection="column" paddingBottom={1}>
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
+ )}
522
+ {loading ? (
523
+ <Box height={1} paddingX={1} marginTop={1} justifyContent="center">
524
+ <Text fg={colors.textMuted}>refreshing...</Text>
525
+ </Box>
526
+ ) : null}
527
+ {error ? (
528
+ <Box paddingX={1} marginTop={1}>
529
+ <Text fg={colors.warning}>{error}</Text>
530
+ </Box>
531
+ ) : null}
532
+ <IndexHistoryChart data={data} width={width} />
533
+ <Box flexDirection="row" paddingX={1} marginTop={2} height={1}>
534
+ <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>7 FEAR & GREED INDICATORS</Text>
535
+ </Box>
536
+ {data.indicators.map((indicator) => (
537
+ <SentimentChart
538
+ key={indicator.definition.id}
539
+ title={indicator.definition.title}
540
+ rating={indicator.rating}
541
+ score={indicator.score}
542
+ points={indicator.points}
543
+ width={width}
544
+ valueFormat={indicator.definition.valueFormat}
545
+ updatedAt={indicator.updatedAt}
546
+ primaryLabel={indicator.definition.primaryLabel}
547
+ secondaryLabel={indicator.definition.secondaryLabel}
548
+ secondaryValue={indicator.latestSecondaryValue}
549
+ overlays={chartOverlay(indicator)}
550
+ />
551
+ ))}
552
+ </Box>
553
+ </ScrollBox>
554
+ </Box>
555
+ );
556
+ }
557
+
558
+ export const fearGreedPlugin: GloomPlugin = {
559
+ id: "fear-greed",
560
+ name: "Fear & Greed",
561
+ version: "1.0.0",
562
+ description: "CNN Fear & Greed sentiment gauge and market indicator charts.",
563
+ toggleable: true,
564
+
565
+ panes: [
566
+ {
567
+ id: "fear-greed",
568
+ name: "Fear & Greed",
569
+ icon: "G",
570
+ component: FearGreedPane,
571
+ defaultPosition: "right",
572
+ defaultMode: "floating",
573
+ defaultFloatingSize: { width: 110, height: 36 },
574
+ },
575
+ ],
576
+
577
+ paneTemplates: [
578
+ {
579
+ id: "fear-greed-pane",
580
+ paneId: "fear-greed",
581
+ label: "Fear & Greed",
582
+ description: "CNN Fear & Greed sentiment gauge with the seven indicator charts.",
583
+ keywords: ["fear", "greed", "sentiment", "cnn", "market", "indicators", "gauge"],
584
+ shortcut: { prefix: "FNG" },
585
+ },
586
+ ],
587
+ };
@@ -6,7 +6,7 @@ import { usePaneFooter } from "../../../components";
6
6
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
7
7
  import { colors, blendHex } from "../../../theme/colors";
8
8
  import { useAssetData } from "../../plugin-runtime";
9
- import { MAJOR_CURRENCIES, CURRENCY_FLAGS, formatRate, type MajorCurrency } from "./pairs";
9
+ import { MAJOR_CURRENCIES, formatRate, type MajorCurrency } from "./pairs";
10
10
 
11
11
  const REFRESH_INTERVAL_MS = 60_000;
12
12
 
@@ -18,7 +18,7 @@ function formatAge(ms: number): string {
18
18
  return `${Math.floor(mins / 60)}h ago`;
19
19
  }
20
20
 
21
- export function FxMatrixPane({ focused, width, height }: PaneProps) {
21
+ function FxMatrixPane({ focused, width, height }: PaneProps) {
22
22
  const dataProvider = useAssetData();
23
23
  const [rates, setRates] = useState<Map<MajorCurrency, number>>(new Map());
24
24
  const [loading, setLoading] = useState(true);
@@ -144,7 +144,7 @@ describe("HelpPane", () => {
144
144
  const calls: string[] = [];
145
145
  const runtime = createTestPluginRuntime({
146
146
  openCommandBar: (query?: string) => calls.push(`command:${query ?? ""}`),
147
- showWidget: (paneId: string) => calls.push(`widget:${paneId}`),
147
+ showPane: (paneId: string) => calls.push(`pane:${paneId}`),
148
148
  });
149
149
 
150
150
  await renderHelpPane(runtime);
@@ -161,6 +161,6 @@ describe("HelpPane", () => {
161
161
  await testSetup!.renderOnce();
162
162
  });
163
163
 
164
- expect(calls).toEqual(["widget:debug"]);
164
+ expect(calls).toEqual(["pane:debug"]);
165
165
  });
166
166
  });
@@ -211,7 +211,7 @@ function resolvePluginShortcuts(registry: ReturnType<typeof getSharedRegistry>):
211
211
 
212
212
  export function HelpPane({ width, height }: PaneProps) {
213
213
  const registry = getSharedRegistry();
214
- const { openCommandBar, showWidget } = usePluginAppActions();
214
+ const { openCommandBar, showPane } = usePluginAppActions();
215
215
  const [hoveredAction, setHoveredAction] = useState<string | null>(null);
216
216
  const commandShortcuts = resolveCommandShortcuts(registry);
217
217
  const pluginShortcuts = resolvePluginShortcuts(registry);
@@ -232,7 +232,7 @@ export function HelpPane({ width, height }: PaneProps) {
232
232
  : [platformShortcut("V")];
233
233
 
234
234
  const openDebugLog = () => {
235
- showWidget("debug");
235
+ showPane("debug");
236
236
  };
237
237
 
238
238
  const openLayoutActions = () => {
@@ -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
+ });