gloomberb 0.6.1 → 0.7.0

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 (139) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +9 -6
  4. package/src/cli/registry.test.ts +0 -1
  5. package/src/components/chart/chart-controller.ts +0 -1
  6. package/src/components/chart/chart-data.ts +1 -31
  7. package/src/components/chart/chart-renderer.ts +70 -0
  8. package/src/components/chart/chart-resolution.test.ts +0 -36
  9. package/src/components/chart/chart-resolution.ts +0 -27
  10. package/src/components/chart/chart.test.ts +17 -45
  11. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  12. package/src/components/chart/comparison-chart-data.ts +1 -4
  13. package/src/components/chart/comparison-stock-chart.tsx +48 -27
  14. package/src/components/chart/static-chart-surface.tsx +28 -4
  15. package/src/components/chart/stock-chart.test.ts +0 -16
  16. package/src/components/chart/stock-chart.tsx +38 -21
  17. package/src/components/chart/time-axis-label.tsx +43 -0
  18. package/src/components/command-bar/command-bar.test.tsx +71 -8
  19. package/src/components/command-bar/command-bar.tsx +388 -713
  20. package/src/components/command-bar/confirm-body.tsx +51 -0
  21. package/src/components/command-bar/list-model.ts +77 -0
  22. package/src/components/command-bar/view-model.ts +0 -19
  23. package/src/components/command-bar/workflow-ops.ts +4 -0
  24. package/src/components/data-table-stack-view.test.tsx +39 -4
  25. package/src/components/data-table-stack-view.tsx +11 -104
  26. package/src/components/data-table-view.tsx +43 -79
  27. package/src/components/feed-data-table-stack-view.tsx +10 -17
  28. package/src/components/feed-stack-controller.ts +39 -0
  29. package/src/components/index.ts +5 -1
  30. package/src/components/layout/floating-pane.tsx +30 -2
  31. package/src/components/layout/header.tsx +10 -7
  32. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  33. package/src/components/layout/pane-cycle-order.ts +32 -0
  34. package/src/components/layout/pane-footer.tsx +11 -4
  35. package/src/components/layout/pane-header.tsx +5 -5
  36. package/src/components/layout/shell.test.tsx +66 -22
  37. package/src/components/layout/shell.tsx +13 -6
  38. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  39. package/src/components/pane-settings-dialog.tsx +12 -4
  40. package/src/components/pane-template-wizard.test.tsx +3 -4
  41. package/src/components/speedometer-gauge.test.tsx +45 -0
  42. package/src/components/speedometer-gauge.tsx +558 -0
  43. package/src/components/table-view-shared.tsx +158 -0
  44. package/src/components/ticker-list-table-view.tsx +42 -72
  45. package/src/components/ui/confirm-dialog.tsx +66 -0
  46. package/src/components/ui/data-table.tsx +18 -10
  47. package/src/components/ui/index.ts +0 -1
  48. package/src/components/ui/page-stack-view.tsx +39 -10
  49. package/src/components/ui/table-layout.test.ts +16 -0
  50. package/src/components/ui/table-layout.ts +10 -3
  51. package/src/components/ui/ui.test.tsx +100 -20
  52. package/src/core/state/app-state.ts +6 -3
  53. package/src/market-data/coordinator.test.ts +1 -1
  54. package/src/market-data/coordinator.ts +0 -1
  55. package/src/news/aggregator.ts +0 -3
  56. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  57. package/src/plugins/builtin/ai/providers.ts +0 -17
  58. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  59. package/src/plugins/builtin/ai/screener-pane.tsx +0 -3
  60. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  61. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  62. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  63. package/src/plugins/builtin/chat-controller.test.ts +105 -0
  64. package/src/plugins/builtin/chat-controller.ts +75 -6
  65. package/src/plugins/builtin/chat.test.tsx +6 -6
  66. package/src/plugins/builtin/chat.tsx +29 -20
  67. package/src/plugins/builtin/cloud-tweets.tsx +0 -1
  68. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  69. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  70. package/src/plugins/builtin/correlation/index.tsx +1 -6
  71. package/src/plugins/builtin/debug.tsx +1 -3
  72. package/src/plugins/builtin/econ/index.tsx +1 -2
  73. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  74. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  75. package/src/plugins/builtin/fear-greed/index.tsx +478 -0
  76. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  77. package/src/plugins/builtin/help.test.tsx +2 -2
  78. package/src/plugins/builtin/help.tsx +2 -2
  79. package/src/plugins/builtin/holders/index.tsx +10 -15
  80. package/src/plugins/builtin/insider/index.tsx +22 -89
  81. package/src/plugins/builtin/market-movers/index.tsx +1 -1
  82. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  83. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  84. package/src/plugins/builtin/news.tsx +4 -16
  85. package/src/plugins/builtin/notes.tsx +18 -55
  86. package/src/plugins/builtin/options.test.tsx +58 -2
  87. package/src/plugins/builtin/options.tsx +108 -49
  88. package/src/plugins/builtin/plugin-groups.ts +2 -0
  89. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  90. package/src/plugins/builtin/research/index.tsx +5 -21
  91. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  92. package/src/plugins/builtin/sec.tsx +13 -66
  93. package/src/plugins/builtin/sectors/index.tsx +1 -1
  94. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  95. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  96. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  97. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  98. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  99. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  100. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  101. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  102. package/src/plugins/ibkr/gateway-service.ts +1 -1
  103. package/src/plugins/ibkr/index.test.tsx +0 -2
  104. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  105. package/src/plugins/pane-manager.ts +0 -13
  106. package/src/plugins/plugin-runtime.test.tsx +6 -6
  107. package/src/plugins/plugin-runtime.tsx +13 -8
  108. package/src/plugins/prediction-markets/cli.ts +0 -1
  109. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  110. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  111. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  112. package/src/plugins/registry.ts +10 -39
  113. package/src/react/input.ts +1 -0
  114. package/src/renderers/electrobun/view/data-table.tsx +28 -6
  115. package/src/renderers/electrobun/view/desktop-controls.tsx +34 -20
  116. package/src/renderers/electrobun/view/dialog-host.tsx +11 -1
  117. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  118. package/src/renderers/electrobun/view/key-event.ts +1 -1
  119. package/src/renderers/electrobun/view/styles.css +331 -0
  120. package/src/renderers/opentui/chart-surface.tsx +179 -0
  121. package/src/renderers/opentui/start.tsx +48 -30
  122. package/src/renderers/opentui/test-utils.tsx +17 -0
  123. package/src/renderers/opentui/ui-host.tsx +5 -2
  124. package/src/sources/provider-router.ts +0 -4
  125. package/src/state/app-context.test.ts +45 -0
  126. package/src/state/use-inline-tickers.test.tsx +73 -0
  127. package/src/state/use-inline-tickers.ts +7 -1
  128. package/src/test-support/plugin-runtime.ts +3 -2
  129. package/src/theme/colors.ts +12 -3
  130. package/src/theme/themes.test.ts +23 -1
  131. package/src/theme/themes.ts +115 -0
  132. package/src/types/broker.ts +1 -1
  133. package/src/types/data-provider.ts +0 -1
  134. package/src/types/plugin.ts +0 -2
  135. package/src/ui/host.tsx +1 -0
  136. package/src/utils/back-navigation.ts +24 -1
  137. package/src/version.ts +1 -1
  138. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  139. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -0,0 +1,478 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Box, ScrollBox, Text, TextAttributes } 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 FEAR_GREED_GAUGE_SEGMENTS: SpeedometerSegment[] = [
20
+ { from: 0, to: 24.999, label: "EXTREME FEAR", color: colors.negative },
21
+ { from: 25, to: 44.999, label: "FEAR", color: colors.warning },
22
+ { from: 45, to: 55, label: "NEUTRAL", color: colors.neutral },
23
+ { from: 55.001, to: 75, label: "GREED", color: colors.positive },
24
+ { from: 75.001, to: 100, label: "EXTREME GREED", color: colors.positive },
25
+ ];
26
+
27
+ let sharedCache: { data: FearGreedData; fetchedAt: number } | null = null;
28
+ let activeFetch: Promise<FearGreedData> | null = null;
29
+
30
+ async function loadFearGreed(force = false): Promise<FearGreedData> {
31
+ if (!force && sharedCache && Date.now() - sharedCache.fetchedAt < CACHE_TTL_MS) {
32
+ return sharedCache.data;
33
+ }
34
+ if (activeFetch) return activeFetch;
35
+
36
+ activeFetch = fetchFearGreedData()
37
+ .then((data) => {
38
+ sharedCache = { data, fetchedAt: Date.now() };
39
+ return data;
40
+ })
41
+ .finally(() => {
42
+ activeFetch = null;
43
+ });
44
+ return activeFetch;
45
+ }
46
+
47
+ function ratingLabel(rating: FearGreedRating): string {
48
+ return rating.toUpperCase();
49
+ }
50
+
51
+ function ratingColor(rating: FearGreedRating): string {
52
+ switch (rating) {
53
+ case "extreme fear":
54
+ return colors.negative;
55
+ case "fear":
56
+ return colors.warning;
57
+ case "neutral":
58
+ return colors.neutral;
59
+ case "greed":
60
+ case "extreme greed":
61
+ return colors.positive;
62
+ }
63
+ }
64
+
65
+ function ratingTrend(rating: FearGreedRating): "positive" | "negative" | "neutral" {
66
+ switch (rating) {
67
+ case "extreme fear":
68
+ case "fear":
69
+ return "negative";
70
+ case "neutral":
71
+ return "neutral";
72
+ case "greed":
73
+ case "extreme greed":
74
+ return "positive";
75
+ }
76
+ }
77
+
78
+ function formatScore(score: number | null | undefined): string {
79
+ if (score == null || Number.isNaN(score)) return "--";
80
+ return String(Math.round(score));
81
+ }
82
+
83
+ function formatIndicatorValue(value: number | null | undefined, format: FearGreedValueFormat): string {
84
+ if (value == null || Number.isNaN(value)) return "--";
85
+ switch (format) {
86
+ case "score":
87
+ return formatScore(value);
88
+ case "percent":
89
+ return `${formatNumber(value, 2)}%`;
90
+ case "ratio":
91
+ return value.toFixed(2);
92
+ case "number": {
93
+ const abs = Math.abs(value);
94
+ if (abs >= 1000) return formatNumber(value, 0);
95
+ if (abs >= 100) return formatNumber(value, 1);
96
+ return formatNumber(value, 2);
97
+ }
98
+ }
99
+ }
100
+
101
+ function formatAxisValue(format: FearGreedValueFormat): (value: number) => string {
102
+ return (value) => formatIndicatorValue(value, format);
103
+ }
104
+
105
+ function formatUpdatedAt(date: Date | null): string {
106
+ if (!date) return "Last updated --";
107
+ const parts = new Intl.DateTimeFormat("en-US", {
108
+ timeZone: "America/New_York",
109
+ month: "short",
110
+ day: "numeric",
111
+ hour: "numeric",
112
+ minute: "2-digit",
113
+ hour12: true,
114
+ }).formatToParts(date);
115
+ const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
116
+ return `Last updated ${part("month")} ${part("day")} at ${part("hour")}:${part("minute")} ${part("dayPeriod")} ET`;
117
+ }
118
+
119
+ function formatAge(ms: number): string {
120
+ const secs = Math.max(0, Math.floor(ms / 1000));
121
+ if (secs < 60) return `${secs}s ago`;
122
+ const mins = Math.floor(secs / 60);
123
+ if (mins < 60) return `${mins}m ago`;
124
+ const hours = Math.floor(mins / 60);
125
+ return `${hours}h ago`;
126
+ }
127
+
128
+ function SentimentBadge({ rating }: { rating: FearGreedRating }) {
129
+ const color = ratingColor(rating);
130
+ return (
131
+ <Box height={1} paddingX={1} backgroundColor={blendHex(colors.bg, color, 0.28)}>
132
+ <Text fg={color} attributes={TextAttributes.BOLD}>{ratingLabel(rating)}</Text>
133
+ </Box>
134
+ );
135
+ }
136
+
137
+ function PreviousScoreGrid({ data, width }: { data: FearGreedData; width: number }) {
138
+ const items = [
139
+ { label: "Previous close", value: data.overall.previousClose },
140
+ { label: "1 week ago", value: data.overall.previousWeek },
141
+ { label: "1 month ago", value: data.overall.previousMonth },
142
+ { label: "1 year ago", value: data.overall.previousYear },
143
+ ];
144
+ const columns = width >= 84 ? 4 : 2;
145
+ const columnWidth = Math.max(18, Math.floor((width - 2) / columns));
146
+ const rows = Array.from({ length: Math.ceil(items.length / columns) }, (_, rowIndex) => (
147
+ items.slice(rowIndex * columns, rowIndex * columns + columns)
148
+ ));
149
+
150
+ return (
151
+ <Box flexDirection="column" paddingX={1} marginTop={1}>
152
+ {rows.map((row, rowIndex) => (
153
+ <Box key={rowIndex} flexDirection="row" height={1}>
154
+ {row.map((item) => {
155
+ const value = item.value;
156
+ const color = value == null ? colors.textDim : ratingColor(value < 25 ? "extreme fear" : value < 45 ? "fear" : value <= 55 ? "neutral" : "greed");
157
+ return (
158
+ <Box key={item.label} width={columnWidth} flexShrink={0} flexDirection="row">
159
+ <Text fg={colors.textDim}>{item.label}: </Text>
160
+ <Text fg={color} attributes={TextAttributes.BOLD}>{formatScore(value)}</Text>
161
+ </Box>
162
+ );
163
+ })}
164
+ </Box>
165
+ ))}
166
+ </Box>
167
+ );
168
+ }
169
+
170
+ function chartOverlay(indicator: FearGreedIndicator): ChartIndicatorOverlays | null {
171
+ if (indicator.secondaryPoints.length === 0) return null;
172
+ return {
173
+ smaLines: [{
174
+ period: 0,
175
+ points: indicator.secondaryPoints,
176
+ color: colors.warning,
177
+ }],
178
+ emaLines: [],
179
+ bollinger: null,
180
+ rsi: null,
181
+ macd: null,
182
+ };
183
+ }
184
+
185
+ function SentimentChart({
186
+ title,
187
+ subtitle,
188
+ rating,
189
+ score,
190
+ points,
191
+ width,
192
+ valueFormat,
193
+ updatedAt,
194
+ primaryLabel,
195
+ secondaryLabel,
196
+ secondaryValue,
197
+ overlays,
198
+ }: {
199
+ title: string;
200
+ subtitle: string;
201
+ rating: FearGreedRating;
202
+ score: number | null;
203
+ points: FearGreedIndicator["points"];
204
+ width: number;
205
+ valueFormat: FearGreedValueFormat;
206
+ updatedAt: Date | null;
207
+ primaryLabel: string;
208
+ secondaryLabel?: string;
209
+ secondaryValue?: number | null;
210
+ overlays?: ChartIndicatorOverlays | null;
211
+ }) {
212
+ const chartWidth = Math.max(24, width - 2);
213
+ const chartHeight = width >= 96 ? 12 : 10;
214
+ const color = ratingColor(rating);
215
+ const basePalette = resolveChartPalette(colors, ratingTrend(rating));
216
+ const palette = {
217
+ ...basePalette,
218
+ lineColor: color,
219
+ fillColor: blendHex(colors.bg, color, 0.18),
220
+ gridColor: blendHex(colors.bg, colors.border, 0.55),
221
+ };
222
+ const latest = points.length > 0 ? points[points.length - 1]!.close : null;
223
+
224
+ return (
225
+ <Box flexDirection="column" marginTop={2} paddingX={1}>
226
+ <Box flexDirection="row" height={1}>
227
+ <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{title.toUpperCase()}</Text>
228
+ <Box flexGrow={1} />
229
+ <SentimentBadge rating={rating} />
230
+ </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>
255
+ {points.length >= 2 ? (
256
+ <Box marginTop={1}>
257
+ <StaticChartSurface
258
+ points={points}
259
+ width={chartWidth}
260
+ height={chartHeight}
261
+ mode="line"
262
+ colors={palette}
263
+ indicators={overlays}
264
+ showTimeAxis
265
+ timeAxisColor={colors.textDim}
266
+ yAxisColor={colors.textDim}
267
+ formatYAxisValue={formatAxisValue(valueFormat)}
268
+ />
269
+ </Box>
270
+ ) : (
271
+ <Box height={chartHeight} marginTop={1} justifyContent="center" alignItems="center">
272
+ <Text fg={colors.textMuted}>Not enough chart data</Text>
273
+ </Box>
274
+ )}
275
+ <Box height={1} marginTop={1}>
276
+ <Text fg={colors.textDim}>{formatUpdatedAt(updatedAt)}</Text>
277
+ </Box>
278
+ </Box>
279
+ );
280
+ }
281
+
282
+ function IndexHistoryChart({ data, width }: { data: FearGreedData; width: number }) {
283
+ const indicator: FearGreedIndicator = {
284
+ definition: {
285
+ id: "index-history",
286
+ title: "Index History",
287
+ subtitle: "Fear & Greed score over the past year",
288
+ primaryKey: "fear_and_greed_historical",
289
+ primaryLabel: "Fear & Greed",
290
+ valueFormat: "score",
291
+ },
292
+ score: data.overall.score,
293
+ rating: data.overall.rating,
294
+ updatedAt: data.overall.updatedAt,
295
+ points: data.overall.history,
296
+ secondaryPoints: [],
297
+ latestValue: data.overall.score,
298
+ latestSecondaryValue: null,
299
+ };
300
+
301
+ return (
302
+ <SentimentChart
303
+ title={indicator.definition.title}
304
+ subtitle={indicator.definition.subtitle}
305
+ rating={indicator.rating}
306
+ score={indicator.score}
307
+ points={indicator.points}
308
+ width={width}
309
+ valueFormat={indicator.definition.valueFormat}
310
+ updatedAt={indicator.updatedAt}
311
+ primaryLabel={indicator.definition.primaryLabel}
312
+ />
313
+ );
314
+ }
315
+
316
+ function FearGreedPane({ paneId, focused, width, height }: PaneProps) {
317
+ const [data, setData] = useState<FearGreedData | null>(sharedCache?.data ?? null);
318
+ const [loading, setLoading] = useState(!sharedCache);
319
+ const [error, setError] = useState<string | null>(null);
320
+ const [lastRefreshed, setLastRefreshed] = useState<number | null>(sharedCache?.fetchedAt ?? null);
321
+ const [now, setNow] = useState(Date.now());
322
+ const fetchGenRef = useRef(0);
323
+
324
+ const load = useCallback(async (force = false) => {
325
+ fetchGenRef.current += 1;
326
+ const gen = fetchGenRef.current;
327
+ setLoading(true);
328
+ setError(null);
329
+ try {
330
+ const nextData = await loadFearGreed(force);
331
+ if (fetchGenRef.current !== gen) return;
332
+ setData(nextData);
333
+ setLastRefreshed(Date.now());
334
+ } catch (err) {
335
+ if (fetchGenRef.current !== gen) return;
336
+ setError(err instanceof Error ? err.message : String(err));
337
+ } finally {
338
+ if (fetchGenRef.current === gen) setLoading(false);
339
+ }
340
+ }, []);
341
+
342
+ useEffect(() => {
343
+ if (!sharedCache) {
344
+ void load();
345
+ }
346
+ }, [load]);
347
+
348
+ useEffect(() => {
349
+ const interval = setInterval(() => setNow(Date.now()), 10_000);
350
+ return () => clearInterval(interval);
351
+ }, []);
352
+
353
+ const refresh = useCallback(() => {
354
+ void load(true);
355
+ }, [load]);
356
+
357
+ useShortcut((event) => {
358
+ if (!focused) return;
359
+ if (event.name === "r") {
360
+ event.preventDefault?.();
361
+ event.stopPropagation?.();
362
+ refresh();
363
+ }
364
+ });
365
+
366
+ const footerAge = lastRefreshed ? `refreshed ${formatAge(now - lastRefreshed)}` : loading ? "loading" : "";
367
+ usePaneFooter(paneId, () => ({
368
+ info: [
369
+ ...(data ? [{
370
+ id: "score",
371
+ parts: [
372
+ { text: `${formatScore(data.overall.score)} ${ratingLabel(data.overall.rating)}`, color: ratingColor(data.overall.rating), bold: true },
373
+ ],
374
+ }] : []),
375
+ ...(footerAge ? [{ id: "age", parts: [{ text: footerAge, tone: loading ? "muted" as const : "value" as const }] }] : []),
376
+ ...(error ? [{ id: "error", parts: [{ text: error, tone: "warning" as const }] }] : []),
377
+ ],
378
+ hints: [{ id: "refresh", key: "r", label: "efresh", onPress: refresh, disabled: loading }],
379
+ }), [data, error, footerAge, loading, paneId, refresh]);
380
+
381
+ if (loading && !data) {
382
+ return (
383
+ <Box flexDirection="column" width={width} height={height}>
384
+ <Box flexGrow={1} justifyContent="center" alignItems="center">
385
+ <Text fg={colors.textMuted}>Loading Fear & Greed...</Text>
386
+ </Box>
387
+ </Box>
388
+ );
389
+ }
390
+
391
+ if (!data) {
392
+ return (
393
+ <Box flexDirection="column" width={width} height={height}>
394
+ <Box flexGrow={1} justifyContent="center" alignItems="center" paddingX={1}>
395
+ <Text fg={colors.negative}>{error ?? "Fear & Greed data unavailable"}</Text>
396
+ </Box>
397
+ </Box>
398
+ );
399
+ }
400
+
401
+ return (
402
+ <Box flexDirection="column" width={width} height={height}>
403
+ <ScrollBox flexGrow={1} scrollY focusable={false}>
404
+ <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
+ />
411
+ {loading ? (
412
+ <Box height={1} paddingX={1} marginTop={1} justifyContent="center">
413
+ <Text fg={colors.textMuted}>refreshing...</Text>
414
+ </Box>
415
+ ) : null}
416
+ <PreviousScoreGrid data={data} width={width} />
417
+ {error ? (
418
+ <Box paddingX={1} marginTop={1}>
419
+ <Text fg={colors.warning}>{error}</Text>
420
+ </Box>
421
+ ) : null}
422
+ <IndexHistoryChart data={data} width={width} />
423
+ <Box flexDirection="row" paddingX={1} marginTop={2} height={1}>
424
+ <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>7 FEAR & GREED INDICATORS</Text>
425
+ </Box>
426
+ {data.indicators.map((indicator) => (
427
+ <SentimentChart
428
+ key={indicator.definition.id}
429
+ title={indicator.definition.title}
430
+ subtitle={indicator.definition.subtitle}
431
+ rating={indicator.rating}
432
+ score={indicator.score}
433
+ points={indicator.points}
434
+ width={width}
435
+ valueFormat={indicator.definition.valueFormat}
436
+ updatedAt={indicator.updatedAt}
437
+ primaryLabel={indicator.definition.primaryLabel}
438
+ secondaryLabel={indicator.definition.secondaryLabel}
439
+ secondaryValue={indicator.latestSecondaryValue}
440
+ overlays={chartOverlay(indicator)}
441
+ />
442
+ ))}
443
+ </Box>
444
+ </ScrollBox>
445
+ </Box>
446
+ );
447
+ }
448
+
449
+ export const fearGreedPlugin: GloomPlugin = {
450
+ id: "fear-greed",
451
+ name: "Fear & Greed",
452
+ version: "1.0.0",
453
+ description: "CNN Fear & Greed sentiment gauge and market indicator charts.",
454
+ toggleable: true,
455
+
456
+ panes: [
457
+ {
458
+ id: "fear-greed",
459
+ name: "Fear & Greed",
460
+ icon: "G",
461
+ component: FearGreedPane,
462
+ defaultPosition: "right",
463
+ defaultMode: "floating",
464
+ defaultFloatingSize: { width: 110, height: 36 },
465
+ },
466
+ ],
467
+
468
+ paneTemplates: [
469
+ {
470
+ id: "fear-greed-pane",
471
+ paneId: "fear-greed",
472
+ label: "Fear & Greed",
473
+ description: "CNN Fear & Greed sentiment gauge with the seven indicator charts.",
474
+ keywords: ["fear", "greed", "sentiment", "cnn", "market", "indicators", "gauge"],
475
+ shortcut: { prefix: "FNG" },
476
+ },
477
+ ],
478
+ };
@@ -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 = () => {
@@ -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;
@@ -452,7 +453,7 @@ function Tile({ tile, selected, currency, onSelect }: {
452
453
  width={renderWidth}
453
454
  height={renderHeight}
454
455
  backgroundColor={selected ? colors.selected : tileColor(tile.row)}
455
- onMouseDown={(event) => {
456
+ onMouseDown={(event: PreventableMouseEvent) => {
456
457
  event.preventDefault();
457
458
  onSelect();
458
459
  }}
@@ -538,7 +539,7 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
538
539
  <Box
539
540
  data-gloom-role="holders-desktop-tile"
540
541
  style={style}
541
- onMouseDown={(event) => {
542
+ onMouseDown={(event: PreventableMouseEvent) => {
542
543
  event.preventDefault();
543
544
  onSelect();
544
545
  }}
@@ -681,7 +682,8 @@ function HoldersTreemap({ rows, width, height, selectedId, onSelect, currency }:
681
682
  );
682
683
  }
683
684
 
684
- export function HoldersView({ focused, width, height }: Pick<PaneProps, "focused" | "width" | "height">) {
685
+ function HoldersView({ focused, width, height }: { focused: boolean; width: number; height: number }) {
686
+ const { nativePaneChrome } = useUiCapabilities();
685
687
  const { symbol, ticker } = usePaneTicker();
686
688
  const dataProvider = useAssetData();
687
689
  const [viewMode, setViewMode] = usePluginPaneState<ViewMode>("viewMode", "chart");
@@ -853,6 +855,7 @@ export function HoldersView({ focused, width, height }: Pick<PaneProps, "focused
853
855
  : loading
854
856
  ? "Loading holders..."
855
857
  : error ?? "No holders available";
858
+ const chartHeight = Math.max(1, height - 1 - (nativePaneChrome ? 1 : 0));
856
859
 
857
860
  return (
858
861
  <Box flexDirection="column" width={width} height={height}>
@@ -889,7 +892,7 @@ export function HoldersView({ focused, width, height }: Pick<PaneProps, "focused
889
892
  <HoldersTreemap
890
893
  rows={sortedRows}
891
894
  width={width}
892
- height={Math.max(1, height - 1)}
895
+ height={chartHeight}
893
896
  selectedId={selectedId}
894
897
  onSelect={(row) => setSelectedId(row.id)}
895
898
  currency={currency}
@@ -899,14 +902,6 @@ export function HoldersView({ focused, width, height }: Pick<PaneProps, "focused
899
902
  );
900
903
  }
901
904
 
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
905
  export const holdersPlugin: GloomPlugin = {
911
906
  id: "holders",
912
907
  name: "Holders",
@@ -919,7 +914,7 @@ export const holdersPlugin: GloomPlugin = {
919
914
  id: "holders",
920
915
  name: "Holders",
921
916
  order: 42,
922
- component: HoldersDetailTab,
917
+ component: HoldersView,
923
918
  isVisible: ({ ticker }) => !!ticker,
924
919
  });
925
920
  },
@@ -929,7 +924,7 @@ export const holdersPlugin: GloomPlugin = {
929
924
  id: "holders",
930
925
  name: "Holders",
931
926
  icon: "H",
932
- component: HoldersPane,
927
+ component: HoldersView,
933
928
  defaultPosition: "right",
934
929
  defaultMode: "floating",
935
930
  defaultFloatingSize: { width: 105, height: 34 },