gloomberb 0.4.0 → 0.5.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 (218) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +184 -69
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +296 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -1
  48. package/src/components/layout/header.tsx +5 -4
  49. package/src/components/layout/pane-sizing.ts +6 -0
  50. package/src/components/layout/pane.tsx +5 -1
  51. package/src/components/layout/shell.test.tsx +263 -32
  52. package/src/components/layout/shell.tsx +10 -6
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +62 -2
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +82 -5
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +92 -18
  103. package/src/plugins/builtin/chat-controller.ts +40 -11
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +420 -119
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +33 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/gateway-service.test.ts +22 -0
  166. package/src/plugins/ibkr/gateway-service.ts +6 -0
  167. package/src/plugins/ibkr/index.test.tsx +1 -1
  168. package/src/plugins/ibkr/index.tsx +8 -2
  169. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  170. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  171. package/src/plugins/ibkr/trade-utils.ts +7 -6
  172. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  173. package/src/plugins/plugin-runtime.test.tsx +2 -0
  174. package/src/plugins/plugin-runtime.tsx +10 -0
  175. package/src/plugins/prediction-markets/controller-data.ts +129 -14
  176. package/src/plugins/prediction-markets/controller.ts +1 -1
  177. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  178. package/src/plugins/prediction-markets/index.tsx +5 -1
  179. package/src/plugins/prediction-markets/pane.test.tsx +78 -0
  180. package/src/plugins/prediction-markets/pane.tsx +7 -3
  181. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  182. package/src/plugins/prediction-markets/services/fetch.ts +17 -7
  183. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  184. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  185. package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
  186. package/src/plugins/registry.ts +45 -13
  187. package/src/sources/provider-router.test.ts +3 -2
  188. package/src/sources/sec-edgar.ts +13 -1
  189. package/src/sources/yahoo-finance.ts +75 -1
  190. package/src/state/app-context-selector.test.tsx +17 -1
  191. package/src/state/app-context.test.tsx +24 -0
  192. package/src/state/app-context.tsx +29 -6
  193. package/src/theme/colors.ts +5 -0
  194. package/src/theme/themes.ts +25 -0
  195. package/src/types/config.test.ts +107 -0
  196. package/src/types/config.ts +30 -1
  197. package/src/types/data-provider.ts +13 -0
  198. package/src/types/plugin.ts +35 -1
  199. package/src/updater.test.ts +2 -0
  200. package/src/utils/api-client.ts +12 -0
  201. package/src/utils/format.test.ts +11 -1
  202. package/src/utils/format.ts +86 -8
  203. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  204. package/src/utils/inline-content-tokenizer.ts +76 -0
  205. package/src/utils/link-tokenizer.test.ts +27 -0
  206. package/src/utils/link-tokenizer.ts +93 -0
  207. package/src/utils/market-format.test.ts +109 -0
  208. package/src/utils/market-format.ts +299 -0
  209. package/src/utils/market-status.ts +3 -2
  210. package/src/utils/selection-clipboard.test.ts +43 -0
  211. package/src/utils/selection-clipboard.ts +88 -0
  212. package/src/utils/terminal-input-reset.test.ts +50 -0
  213. package/src/utils/terminal-input-reset.ts +24 -0
  214. package/src/utils/throttled-fetch.test.ts +145 -0
  215. package/src/utils/throttled-fetch.ts +212 -0
  216. package/src/utils/ticker-tokenizer.test.ts +31 -0
  217. package/src/utils/ticker-tokenizer.ts +7 -42
  218. package/src/version.ts +1 -1
@@ -0,0 +1,410 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import { DataTable, type DataTableCell, type DataTableColumn } from "../../../components";
5
+ import type { GloomPlugin, PaneProps } from "../../../types/plugin";
6
+ import type { Quote } from "../../../types/financials";
7
+ import type { MarketState } from "../../../types/financials";
8
+ import { colors, priceColor } from "../../../theme/colors";
9
+ import { formatCurrency, formatPercentRaw } from "../../../utils/format";
10
+ import { usePluginTickerActions } from "../../plugin-runtime";
11
+ import { getSharedDataProvider } from "../../registry";
12
+ import { WORLD_INDICES, REGION_LABELS, REGION_ORDER, getIndicesByRegion, type IndexEntry } from "./indices";
13
+
14
+ const REFRESH_INTERVAL_MS = 60_000;
15
+
16
+ interface IndexQuoteState {
17
+ quote: Quote | null;
18
+ loading: boolean;
19
+ error: string | null;
20
+ }
21
+
22
+ type QuoteMap = Map<string, IndexQuoteState>;
23
+ type WorldIndexTableRow =
24
+ | { type: "header"; region: IndexEntry["region"] }
25
+ | { type: "row"; entry: IndexEntry };
26
+ type WorldIndexColumnId = "status" | "symbol" | "name" | "price" | "changePercent";
27
+ type WorldIndexColumn = DataTableColumn & { id: WorldIndexColumnId };
28
+ type SortDirection = "asc" | "desc";
29
+
30
+ interface WorldIndexSortPreference {
31
+ columnId: WorldIndexColumnId | null;
32
+ direction: SortDirection;
33
+ }
34
+
35
+ const DEFAULT_SORT_PREFERENCE: WorldIndexSortPreference = {
36
+ columnId: null,
37
+ direction: "asc",
38
+ };
39
+
40
+ const MARKET_STATE_SORT_ORDER: Partial<Record<MarketState, number>> = {
41
+ REGULAR: 0,
42
+ PREPRE: 1,
43
+ PRE: 1,
44
+ POST: 1,
45
+ POSTPOST: 1,
46
+ CLOSED: 2,
47
+ };
48
+
49
+ function marketStatusDot(state: MarketState | undefined): { char: string; color: string } {
50
+ switch (state) {
51
+ case "REGULAR":
52
+ return { char: "●", color: colors.positive };
53
+ case "PRE":
54
+ case "POST":
55
+ case "PREPRE":
56
+ case "POSTPOST":
57
+ return { char: "●", color: colors.warning };
58
+ case "CLOSED":
59
+ default:
60
+ return { char: "●", color: colors.negative };
61
+ }
62
+ }
63
+
64
+ function getSortValue(
65
+ columnId: WorldIndexColumnId,
66
+ entry: IndexEntry,
67
+ quotes: QuoteMap,
68
+ ): string | number | null {
69
+ const quote = quotes.get(entry.symbol)?.quote;
70
+
71
+ switch (columnId) {
72
+ case "status":
73
+ return quote?.marketState ? (MARKET_STATE_SORT_ORDER[quote.marketState] ?? 3) : 3;
74
+ case "symbol":
75
+ return entry.shortName;
76
+ case "name":
77
+ return entry.name;
78
+ case "price":
79
+ return quote?.price ?? null;
80
+ case "changePercent":
81
+ return quote?.changePercent ?? null;
82
+ }
83
+ }
84
+
85
+ function compareSortValues(
86
+ left: string | number | null,
87
+ right: string | number | null,
88
+ direction: SortDirection,
89
+ ): number {
90
+ if (left == null && right == null) return 0;
91
+ if (left == null) return 1;
92
+ if (right == null) return -1;
93
+
94
+ const comparison = typeof left === "string" && typeof right === "string"
95
+ ? left.localeCompare(right)
96
+ : Number(left) - Number(right);
97
+ return direction === "asc" ? comparison : -comparison;
98
+ }
99
+
100
+ function sortEntries(
101
+ entries: IndexEntry[],
102
+ sortPreference: WorldIndexSortPreference,
103
+ quotes: QuoteMap,
104
+ ): IndexEntry[] {
105
+ const sortColumnId = sortPreference.columnId;
106
+ if (!sortColumnId) return entries;
107
+ return [...entries].sort((left, right) => compareSortValues(
108
+ getSortValue(sortColumnId, left, quotes),
109
+ getSortValue(sortColumnId, right, quotes),
110
+ sortPreference.direction,
111
+ ));
112
+ }
113
+
114
+ function buildFlatRows(
115
+ indicesByRegion: Map<IndexEntry["region"], IndexEntry[]>,
116
+ sortPreference: WorldIndexSortPreference,
117
+ quotes: QuoteMap,
118
+ ): WorldIndexTableRow[] {
119
+ const rows: WorldIndexTableRow[] = [];
120
+ for (const region of REGION_ORDER) {
121
+ const entries = sortEntries(indicesByRegion.get(region) ?? [], sortPreference, quotes);
122
+ if (entries.length === 0) continue;
123
+ rows.push({ type: "header", region });
124
+ for (const entry of entries) {
125
+ rows.push({ type: "row", entry });
126
+ }
127
+ }
128
+ return rows;
129
+ }
130
+
131
+ // Returns only the row-type indices (for navigation purposes)
132
+ function rowIndicesOf(flatRows: ReturnType<typeof buildFlatRows>): number[] {
133
+ return flatRows.reduce<number[]>((acc, row, i) => {
134
+ if (row.type === "row") acc.push(i);
135
+ return acc;
136
+ }, []);
137
+ }
138
+
139
+ function nextSortPreference(
140
+ current: WorldIndexSortPreference,
141
+ columnId: string,
142
+ ): WorldIndexSortPreference {
143
+ const typedColumnId = columnId as WorldIndexColumnId;
144
+ if (current.columnId !== typedColumnId) {
145
+ return { columnId: typedColumnId, direction: "asc" };
146
+ }
147
+ if (current.direction === "asc") {
148
+ return { columnId: typedColumnId, direction: "desc" };
149
+ }
150
+ return DEFAULT_SORT_PREFERENCE;
151
+ }
152
+
153
+ export function WorldIndicesPane({ focused, width, height }: PaneProps) {
154
+ const { pinTicker } = usePluginTickerActions();
155
+ const [quotes, setQuotes] = useState<QuoteMap>(new Map());
156
+ const [selectedSymbol, setSelectedSymbol] = useState<string | null>(null);
157
+ const [hoveredFlatIdx, setHoveredFlatIdx] = useState<number | null>(null);
158
+ const [sortPreference, setSortPreference] = useState<WorldIndexSortPreference>(DEFAULT_SORT_PREFERENCE);
159
+ const fetchGenRef = useRef(0);
160
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
161
+ const headerScrollRef = useRef<ScrollBoxRenderable>(null);
162
+
163
+ const indicesByRegion = useMemo(() => getIndicesByRegion(), []);
164
+ const flatRows = useMemo(
165
+ () => buildFlatRows(indicesByRegion, sortPreference, quotes),
166
+ [indicesByRegion, quotes, sortPreference],
167
+ );
168
+ const navigableIndices = useMemo(() => rowIndicesOf(flatRows), [flatRows]);
169
+ const selectedFlatIdx = selectedSymbol
170
+ ? flatRows.findIndex((row) => row.type === "row" && row.entry.symbol === selectedSymbol)
171
+ : -1;
172
+ const activeFlatIdx = selectedFlatIdx >= 0 ? selectedFlatIdx : (navigableIndices[0] ?? -1);
173
+
174
+ useEffect(() => {
175
+ if (selectedSymbol && selectedFlatIdx >= 0) return;
176
+ const firstRow = flatRows.find((row) => row.type === "row");
177
+ if (firstRow?.type === "row") {
178
+ setSelectedSymbol(firstRow.entry.symbol);
179
+ } else if (selectedSymbol !== null) {
180
+ setSelectedSymbol(null);
181
+ }
182
+ }, [flatRows, selectedFlatIdx, selectedSymbol]);
183
+
184
+ const fetchAll = () => {
185
+ const provider = getSharedDataProvider();
186
+ if (!provider) return;
187
+
188
+ fetchGenRef.current += 1;
189
+ const gen = fetchGenRef.current;
190
+
191
+ for (const entry of WORLD_INDICES) {
192
+ setQuotes((prev) => {
193
+ const next = new Map(prev);
194
+ const existing = next.get(entry.symbol);
195
+ next.set(entry.symbol, { quote: existing?.quote ?? null, loading: true, error: null });
196
+ return next;
197
+ });
198
+
199
+ provider.getQuote(entry.symbol, "").then((quote) => {
200
+ if (fetchGenRef.current !== gen) return;
201
+ setQuotes((prev) => {
202
+ const next = new Map(prev);
203
+ next.set(entry.symbol, { quote, loading: false, error: null });
204
+ return next;
205
+ });
206
+ }).catch((err: unknown) => {
207
+ if (fetchGenRef.current !== gen) return;
208
+ const msg = err instanceof Error ? err.message : String(err);
209
+ setQuotes((prev) => {
210
+ const next = new Map(prev);
211
+ next.set(entry.symbol, { quote: null, loading: false, error: msg });
212
+ return next;
213
+ });
214
+ });
215
+ }
216
+ };
217
+
218
+ useEffect(() => {
219
+ fetchAll();
220
+ const interval = setInterval(fetchAll, REFRESH_INTERVAL_MS);
221
+ return () => clearInterval(interval);
222
+ }, []);
223
+
224
+ const syncHeaderScroll = useCallback(() => {
225
+ const bodyScrollBox = scrollRef.current;
226
+ const headerScrollBox = headerScrollRef.current;
227
+ if (bodyScrollBox && headerScrollBox && headerScrollBox.scrollLeft !== bodyScrollBox.scrollLeft) {
228
+ headerScrollBox.scrollLeft = bodyScrollBox.scrollLeft;
229
+ }
230
+ }, []);
231
+
232
+ const handleBodyScrollActivity = useCallback(() => {
233
+ syncHeaderScroll();
234
+ }, [syncHeaderScroll]);
235
+
236
+ const openSelected = useCallback((flatIdx: number) => {
237
+ const row = flatRows[flatIdx];
238
+ if (!row || row.type !== "row") return;
239
+ pinTicker(row.entry.symbol, { floating: true, paneType: "ticker-detail" });
240
+ }, [flatRows, pinTicker]);
241
+
242
+ const selectFlatIndex = useCallback((flatIdx: number) => {
243
+ const row = flatRows[flatIdx];
244
+ if (!row || row.type !== "row") return;
245
+ setSelectedSymbol(row.entry.symbol);
246
+ }, [flatRows]);
247
+
248
+ const handleHeaderClick = useCallback((columnId: string) => {
249
+ setSortPreference((current) => nextSortPreference(current, columnId));
250
+ }, []);
251
+
252
+ useKeyboard((event) => {
253
+ if (!focused) return;
254
+
255
+ const currentPos = navigableIndices.indexOf(activeFlatIdx);
256
+ const key = event.name;
257
+ const isEnter = key === "enter" || key === "return";
258
+
259
+ if (key === "j" || key === "down") {
260
+ event.preventDefault?.();
261
+ const next = navigableIndices[currentPos >= 0 ? currentPos + 1 : 0];
262
+ if (next !== undefined) selectFlatIndex(next);
263
+ } else if (key === "k" || key === "up") {
264
+ event.preventDefault?.();
265
+ const next = navigableIndices[currentPos > 0 ? currentPos - 1 : 0];
266
+ if (next !== undefined) selectFlatIndex(next);
267
+ } else if (isEnter) {
268
+ event.preventDefault?.();
269
+ openSelected(activeFlatIdx);
270
+ }
271
+ });
272
+
273
+ useEffect(() => {
274
+ const scrollBox = scrollRef.current;
275
+ if (!scrollBox?.viewport || activeFlatIdx < 0) return;
276
+ const viewportHeight = Math.max(scrollBox.viewport.height, 1);
277
+ if (activeFlatIdx < scrollBox.scrollTop) {
278
+ scrollBox.scrollTo(activeFlatIdx);
279
+ } else if (activeFlatIdx >= scrollBox.scrollTop + viewportHeight) {
280
+ scrollBox.scrollTo(activeFlatIdx - viewportHeight + 1);
281
+ }
282
+ }, [activeFlatIdx]);
283
+
284
+ const columns = useMemo<WorldIndexColumn[]>(() => {
285
+ const statusWidth = 1;
286
+ const symbolWidth = 8;
287
+ const priceWidth = 15;
288
+ const changeWidth = 9;
289
+ const columnCount = 5;
290
+ const fixedWidth = statusWidth + symbolWidth + priceWidth + changeWidth;
291
+ const nameWidth = Math.max(10, width - 2 - columnCount - fixedWidth);
292
+
293
+ return [
294
+ { id: "status", label: "", width: statusWidth, align: "left" },
295
+ { id: "symbol", label: "INDEX", width: symbolWidth, align: "left" },
296
+ { id: "name", label: "NAME", width: nameWidth, align: "left" },
297
+ { id: "price", label: "LAST", width: priceWidth, align: "right" },
298
+ { id: "changePercent", label: "CHG%", width: changeWidth, align: "right" },
299
+ ];
300
+ }, [width]);
301
+
302
+ const renderCell = useCallback((
303
+ row: WorldIndexTableRow,
304
+ column: WorldIndexColumn,
305
+ _index: number,
306
+ rowState: { selected: boolean },
307
+ ): DataTableCell => {
308
+ if (row.type === "header") return { text: "" };
309
+
310
+ const { entry } = row;
311
+ const state = quotes.get(entry.symbol);
312
+ const quote = state?.quote;
313
+ const selectedColor = rowState.selected ? colors.selectedText : undefined;
314
+
315
+ switch (column.id) {
316
+ case "status": {
317
+ const dot = marketStatusDot(quote?.marketState);
318
+ return { text: dot.char, color: dot.color };
319
+ }
320
+ case "symbol":
321
+ return {
322
+ text: entry.shortName,
323
+ color: selectedColor ?? colors.textBright,
324
+ attributes: TextAttributes.BOLD,
325
+ };
326
+ case "name":
327
+ return {
328
+ text: entry.name,
329
+ color: selectedColor,
330
+ };
331
+ case "price":
332
+ if (state?.loading && !quote) {
333
+ return { text: "…", color: rowState.selected ? colors.selectedText : colors.textDim };
334
+ }
335
+ if (state?.error || quote?.price === undefined) {
336
+ return { text: "—", color: rowState.selected ? colors.selectedText : colors.textDim };
337
+ }
338
+ return {
339
+ text: formatCurrency(quote.price, quote.currency ?? "USD"),
340
+ color: selectedColor,
341
+ };
342
+ case "changePercent":
343
+ if (!quote || quote.changePercent === undefined) {
344
+ return { text: "—", color: rowState.selected ? colors.selectedText : colors.textDim };
345
+ }
346
+ return {
347
+ text: formatPercentRaw(quote.changePercent),
348
+ color: selectedColor ?? priceColor(quote.changePercent),
349
+ };
350
+ }
351
+ }, [quotes]);
352
+
353
+ return (
354
+ <box flexDirection="column" width={width} height={height}>
355
+ <DataTable<WorldIndexTableRow, WorldIndexColumn>
356
+ columns={columns}
357
+ items={flatRows}
358
+ sortColumnId={sortPreference.columnId}
359
+ sortDirection={sortPreference.direction}
360
+ onHeaderClick={handleHeaderClick}
361
+ headerScrollRef={headerScrollRef}
362
+ scrollRef={scrollRef}
363
+ syncHeaderScroll={syncHeaderScroll}
364
+ onBodyScrollActivity={handleBodyScrollActivity}
365
+ hoveredIdx={hoveredFlatIdx}
366
+ setHoveredIdx={setHoveredFlatIdx}
367
+ getItemKey={(row) => row.type === "header" ? `header-${row.region}` : row.entry.symbol}
368
+ isSelected={(row) => row.type === "row" && row.entry.symbol === selectedSymbol}
369
+ onSelect={(_row, index) => selectFlatIndex(index)}
370
+ onActivate={(_row, index) => openSelected(index)}
371
+ renderSectionHeader={(row) => row.type === "header"
372
+ ? { text: REGION_LABELS[row.region] }
373
+ : null}
374
+ renderCell={renderCell}
375
+ emptyStateTitle="No indices configured."
376
+ />
377
+ </box>
378
+ );
379
+ }
380
+
381
+ export const worldIndicesPlugin: GloomPlugin = {
382
+ id: "world-indices",
383
+ name: "World Equity Indices",
384
+ version: "1.0.0",
385
+ description: "Global equity index monitor grouped by region",
386
+ toggleable: true,
387
+
388
+ panes: [
389
+ {
390
+ id: "world-indices",
391
+ name: "World Indices",
392
+ icon: "W",
393
+ component: WorldIndicesPane,
394
+ defaultPosition: "right",
395
+ defaultMode: "floating",
396
+ defaultFloatingSize: { width: 72, height: 32 },
397
+ },
398
+ ],
399
+
400
+ paneTemplates: [
401
+ {
402
+ id: "world-indices-pane",
403
+ paneId: "world-indices",
404
+ label: "World Equity Indices",
405
+ description: "Monitor global equity indices grouped by region.",
406
+ keywords: ["world", "indices", "global", "equity", "markets", "international"],
407
+ shortcut: { prefix: "WEI" },
408
+ },
409
+ ],
410
+ };
@@ -0,0 +1,52 @@
1
+ export interface IndexEntry {
2
+ symbol: string;
3
+ name: string;
4
+ shortName: string;
5
+ region: "americas" | "europe" | "asia-pacific" | "other";
6
+ }
7
+
8
+ export const WORLD_INDICES: IndexEntry[] = [
9
+ // Americas
10
+ { symbol: "^GSPC", name: "S&P 500", shortName: "SPX", region: "americas" },
11
+ { symbol: "^DJI", name: "Dow Jones Industrial Average", shortName: "DJIA", region: "americas" },
12
+ { symbol: "^IXIC", name: "Nasdaq Composite", shortName: "COMP", region: "americas" },
13
+ { symbol: "^RUT", name: "Russell 2000", shortName: "RUT", region: "americas" },
14
+ { symbol: "^GSPTSE", name: "S&P/TSX Composite", shortName: "TSX", region: "americas" },
15
+ { symbol: "^BVSP", name: "Bovespa", shortName: "BVSP", region: "americas" },
16
+
17
+ // Europe
18
+ { symbol: "^FTSE", name: "FTSE 100", shortName: "FTSE", region: "europe" },
19
+ { symbol: "^GDAXI", name: "DAX", shortName: "DAX", region: "europe" },
20
+ { symbol: "^FCHI", name: "CAC 40", shortName: "CAC", region: "europe" },
21
+ { symbol: "^STOXX50E", name: "Euro Stoxx 50", shortName: "SX5E", region: "europe" },
22
+ { symbol: "^SSMI", name: "Swiss Market Index", shortName: "SMI", region: "europe" },
23
+
24
+ // Asia-Pacific
25
+ { symbol: "^N225", name: "Nikkei 225", shortName: "NKY", region: "asia-pacific" },
26
+ { symbol: "^HSI", name: "Hang Seng Index", shortName: "HSI", region: "asia-pacific" },
27
+ { symbol: "000001.SS", name: "Shanghai Composite", shortName: "SHCOMP", region: "asia-pacific" },
28
+ { symbol: "^KS11", name: "KOSPI", shortName: "KOSPI", region: "asia-pacific" },
29
+ { symbol: "^AXJO", name: "ASX 200", shortName: "ASX", region: "asia-pacific" },
30
+ { symbol: "^BSESN", name: "BSE Sensex", shortName: "SENSEX", region: "asia-pacific" },
31
+
32
+ // Other
33
+ { symbol: "^VIX", name: "CBOE Volatility Index", shortName: "VIX", region: "other" },
34
+ { symbol: "DX-Y.NYB", name: "US Dollar Index", shortName: "DXY", region: "other" },
35
+ ];
36
+
37
+ export const REGION_LABELS: Record<IndexEntry["region"], string> = {
38
+ americas: "Americas",
39
+ europe: "Europe",
40
+ "asia-pacific": "Asia-Pacific",
41
+ other: "Other",
42
+ };
43
+
44
+ export const REGION_ORDER: IndexEntry["region"][] = ["americas", "europe", "asia-pacific", "other"];
45
+
46
+ export function getIndicesByRegion(): Map<IndexEntry["region"], IndexEntry[]> {
47
+ const map = new Map<IndexEntry["region"], IndexEntry[]>();
48
+ for (const region of REGION_ORDER) {
49
+ map.set(region, WORLD_INDICES.filter((entry) => entry.region === region));
50
+ }
51
+ return map;
52
+ }
@@ -0,0 +1,242 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { TextAttributes } from "@opentui/core";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import type { GloomPlugin, PaneProps } from "../../../types/plugin";
5
+ import { colors } from "../../../theme/colors";
6
+ import { useAppSelector } from "../../../state/app-context";
7
+ import { renderChart, resolveChartPalette } from "../../../components/chart/chart-renderer";
8
+ import type { ProjectedChartPoint } from "../../../components/chart/chart-data";
9
+ import {
10
+ attachYieldCurvePersistence,
11
+ loadYieldCurve,
12
+ parseYieldPoints,
13
+ isInverted,
14
+ resetYieldCurvePersistence,
15
+ TREASURY_MATURITIES,
16
+ type YieldPoint,
17
+ } from "./treasury-data";
18
+ import { FRED_API_KEY_COMMAND_LABEL, getSharedFredApiKey } from "../fred-settings";
19
+
20
+ function formatYield(y: number | null): string {
21
+ if (y == null) return "—";
22
+ return y.toFixed(2);
23
+ }
24
+
25
+ function spreadBp(points: YieldPoint[]): number | null {
26
+ const y2 = points.find((p) => p.maturity === "2Y")?.yield;
27
+ const y10 = points.find((p) => p.maturity === "10Y")?.yield;
28
+ if (y2 == null || y10 == null) return null;
29
+ return Math.round((y10 - y2) * 100);
30
+ }
31
+
32
+ export function YieldCurvePane({ focused, width, height }: PaneProps) {
33
+ const config = useAppSelector((s) => s.config);
34
+ const apiKey = getSharedFredApiKey(config);
35
+
36
+ const [points, setPoints] = useState<YieldPoint[]>([]);
37
+ const [loading, setLoading] = useState(false);
38
+ const [error, setError] = useState<string | null>(null);
39
+
40
+ const fetchGenRef = useRef(0);
41
+
42
+ const load = async (force = false) => {
43
+ if (!apiKey) return;
44
+
45
+ fetchGenRef.current += 1;
46
+ const gen = fetchGenRef.current;
47
+ setLoading(true);
48
+ setError(null);
49
+
50
+ try {
51
+ const data = await loadYieldCurve(apiKey, { force });
52
+ if (fetchGenRef.current !== gen) return;
53
+ setPoints(data);
54
+ } catch (err) {
55
+ if (fetchGenRef.current !== gen) return;
56
+ setError(err instanceof Error ? err.message : String(err));
57
+ } finally {
58
+ if (fetchGenRef.current === gen) setLoading(false);
59
+ }
60
+ };
61
+
62
+ useEffect(() => {
63
+ load();
64
+ }, [apiKey]);
65
+
66
+ useKeyboard((ev) => {
67
+ if (!focused) return;
68
+ if (ev.name === "r") {
69
+ load(true);
70
+ }
71
+ });
72
+
73
+ if (!apiKey) {
74
+ return (
75
+ <box flexDirection="column" width={width} height={height}>
76
+ <box flexGrow={1} justifyContent="center" alignItems="center">
77
+ <text fg={colors.textMuted}>{`Configure FRED API key: type '${FRED_API_KEY_COMMAND_LABEL}' in command bar`}</text>
78
+ </box>
79
+ <box height={1} paddingX={1}>
80
+ <text fg={colors.textMuted}>[r]efresh</text>
81
+ </box>
82
+ </box>
83
+ );
84
+ }
85
+
86
+ if (loading && points.length === 0) {
87
+ return (
88
+ <box flexDirection="column" width={width} height={height}>
89
+ <box flexGrow={1} justifyContent="center" alignItems="center">
90
+ <text fg={colors.textMuted}>Loading...</text>
91
+ </box>
92
+ <box height={1} paddingX={1}>
93
+ <text fg={colors.textMuted}>[r]efresh</text>
94
+ </box>
95
+ </box>
96
+ );
97
+ }
98
+
99
+ if (error && points.length === 0) {
100
+ return (
101
+ <box flexDirection="column" width={width} height={height}>
102
+ <box flexGrow={1} justifyContent="center" alignItems="center">
103
+ <text fg={colors.negative}>{error}</text>
104
+ </box>
105
+ <box height={1} paddingX={1}>
106
+ <text fg={colors.textMuted}>[r]efresh</text>
107
+ </box>
108
+ </box>
109
+ );
110
+ }
111
+
112
+ const validPoints = parseYieldPoints(points);
113
+ const inverted = isInverted(points);
114
+ const bp = spreadBp(points);
115
+ const spreadStr = bp != null ? ` 2Y-10Y: ${bp >= 0 ? "+" : ""}${bp}bp` : "";
116
+
117
+ const chartWidth = Math.max(10, width - 2);
118
+ const chartHeight = Math.min(12, Math.max(6, Math.floor(height * 0.3)));
119
+
120
+ const palette = resolveChartPalette(colors, "positive");
121
+
122
+ // Map yield points to chart points: use epoch + maturityYears*365*86400000 to space them on the x-axis
123
+ const chartPoints: ProjectedChartPoint[] = validPoints.map((p) => ({
124
+ date: new Date(p.maturityYears * 365 * 86400000),
125
+ open: p.yield!,
126
+ high: p.yield!,
127
+ low: p.yield!,
128
+ close: p.yield!,
129
+ volume: 0,
130
+ }));
131
+
132
+ let chartResult: ReturnType<typeof renderChart> | null = null;
133
+ if (chartPoints.length >= 2) {
134
+ chartResult = renderChart(chartPoints, {
135
+ width: chartWidth,
136
+ height: chartHeight,
137
+ showVolume: false,
138
+ volumeHeight: 0,
139
+ cursorX: null,
140
+ cursorY: null,
141
+ mode: "line",
142
+ colors: palette,
143
+ timeAxisDates: chartPoints.map((p) => p.date),
144
+ });
145
+ }
146
+
147
+ // Build maturity label row and yield value row for the table
148
+ const colWidth = Math.max(6, Math.floor((width - 2) / TREASURY_MATURITIES.length));
149
+ const maturityLabels = TREASURY_MATURITIES.map((m) => m.maturity.padEnd(colWidth)).join("").trimEnd();
150
+ const yieldValues = TREASURY_MATURITIES.map((m) => {
151
+ const pt = points.find((p) => p.maturity === m.maturity);
152
+ return formatYield(pt?.yield ?? null).padEnd(colWidth);
153
+ }).join("").trimEnd();
154
+
155
+ return (
156
+ <box flexDirection="column" width={width} height={height}>
157
+ {/* Header */}
158
+ <box height={1} paddingX={1} flexDirection="row">
159
+ {inverted ? (
160
+ <text fg={colors.warning} attributes={TextAttributes.BOLD}>⚠ INVERTED</text>
161
+ ) : null}
162
+ {spreadStr ? (
163
+ <text fg={colors.textDim}>{inverted ? spreadStr : spreadStr.trimStart()}</text>
164
+ ) : null}
165
+ <box flexGrow={1} />
166
+ {loading ? <text fg={colors.textDim}>updating…</text> : null}
167
+ </box>
168
+
169
+ {/* Scrollable chart + table */}
170
+ <scrollbox flexGrow={1} scrollY focusable={false}>
171
+ <box flexDirection="column">
172
+ {/* Chart */}
173
+ {chartResult && chartResult.lines.length > 0 ? (
174
+ <box flexDirection="column" paddingX={1} marginTop={1}>
175
+ <box flexDirection="column" height={chartHeight} backgroundColor={palette.bgColor}>
176
+ {chartResult.lines.map((line, i) => (
177
+ <text key={i} content={line} />
178
+ ))}
179
+ </box>
180
+ </box>
181
+ ) : (
182
+ <box paddingX={1} marginTop={1}>
183
+ <text fg={colors.textMuted}>Not enough data for chart</text>
184
+ </box>
185
+ )}
186
+
187
+ {/* Maturity table */}
188
+ <box paddingX={1} marginTop={1} height={1}>
189
+ <text fg={colors.textDim}>{maturityLabels}</text>
190
+ </box>
191
+ <box paddingX={1} height={1}>
192
+ <text fg={colors.text}>{yieldValues}</text>
193
+ </box>
194
+ </box>
195
+ </scrollbox>
196
+
197
+ {/* Footer */}
198
+ <box height={1} paddingX={1}>
199
+ <text fg={colors.textMuted}>[r]efresh</text>
200
+ </box>
201
+ </box>
202
+ );
203
+ }
204
+
205
+ export const yieldCurvePlugin: GloomPlugin = {
206
+ id: "yield-curve",
207
+ name: "Yield Curve",
208
+ version: "1.0.0",
209
+ description: "US Treasury yield curve from FRED",
210
+ toggleable: true,
211
+
212
+ setup(ctx) {
213
+ attachYieldCurvePersistence(ctx.persistence);
214
+ },
215
+
216
+ dispose() {
217
+ resetYieldCurvePersistence();
218
+ },
219
+
220
+ panes: [
221
+ {
222
+ id: "yield-curve",
223
+ name: "US Treasury Yield Curve",
224
+ icon: "Y",
225
+ component: YieldCurvePane,
226
+ defaultPosition: "right",
227
+ defaultMode: "floating",
228
+ defaultFloatingSize: { width: 80, height: 20 },
229
+ },
230
+ ],
231
+
232
+ paneTemplates: [
233
+ {
234
+ id: "yield-curve-pane",
235
+ paneId: "yield-curve",
236
+ label: "Yield Curve",
237
+ description: "US Treasury yield curve charted from FRED data.",
238
+ keywords: ["yield", "curve", "treasury", "bonds", "rates", "gc", "interest"],
239
+ shortcut: { prefix: "GC" },
240
+ },
241
+ ],
242
+ };