gloomberb 0.4.1 → 0.5.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 (212) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  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 +292 -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 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  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 +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  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 +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  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 +24 -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/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. package/src/version.ts +1 -1
@@ -9,8 +9,12 @@ import { colors } from "../../theme/colors";
9
9
  import { Spinner } from "../../components/spinner";
10
10
  import { DetailFeedView, type DetailFeedItem } from "../../components/detail-feed-view";
11
11
  import { isUsEquityTicker } from "../../utils/sec";
12
+ import { getSharedMarketDataCoordinator } from "../../market-data/coordinator";
13
+ import { parseForm4Xml, transactionTypeLabel } from "./insider/insider-data";
14
+ import { formatCompact, formatCurrency } from "../../utils/format";
12
15
 
13
16
  const SEC_FILING_LIMIT = 50;
17
+ const OWNERSHIP_FORMS = new Set(["3", "4", "5"]);
14
18
 
15
19
  function getDisplayFormLabel(form: string): string {
16
20
  const trimmed = form.trim();
@@ -111,6 +115,64 @@ function buildDetailBody(filing: SecFilingItem): string {
111
115
  : "No additional SEC filing description is available for this entry.";
112
116
  }
113
117
 
118
+ function buildForm4Preview(content: string | null): string | null {
119
+ if (!content) return null;
120
+ const tx = parseForm4Xml(content);
121
+ if (!tx) return null;
122
+ const type = transactionTypeLabel(tx.transactionType);
123
+ const shares = formatCompact(tx.shares);
124
+ const price = tx.pricePerShare != null ? ` @ ${formatCurrency(tx.pricePerShare)}` : "";
125
+ return `${tx.reportedName} — ${type} ${shares} shares${price}`;
126
+ }
127
+
128
+ function buildForm4Detail(content: string | null, filing: SecFilingItem): string {
129
+ if (!content) return buildDetailBody(filing);
130
+ const tx = parseForm4Xml(content);
131
+ if (!tx) return buildDetailBody(filing);
132
+
133
+ const lines: string[] = [];
134
+ lines.push(`Insider: ${tx.reportedName}`);
135
+ if (tx.title) lines.push(`Title: ${tx.title}`);
136
+ lines.push(`Transaction: ${transactionTypeLabel(tx.transactionType)}`);
137
+ lines.push(`Shares: ${formatCompact(tx.shares)}`);
138
+ if (tx.pricePerShare != null) lines.push(`Price/Share: ${formatCurrency(tx.pricePerShare)}`);
139
+ if (tx.totalValue != null) lines.push(`Total Value: ${formatCurrency(tx.totalValue)}`);
140
+ if (tx.sharesOwned != null) lines.push(`Shares Owned After: ${formatCompact(tx.sharesOwned)}`);
141
+ return lines.join("\n");
142
+ }
143
+
144
+ function getFormCategory(form: string): "annual" | "quarterly" | "current" | "ownership" | "other" {
145
+ const f = form.trim().toUpperCase();
146
+ if (f === "10-K" || f === "10-K/A" || f === "20-F") return "annual";
147
+ if (f === "10-Q" || f === "10-Q/A") return "quarterly";
148
+ if (f.startsWith("8-K")) return "current";
149
+ if (OWNERSHIP_FORMS.has(f) || f === "SC 13G" || f === "SC 13D" || f === "SC 13G/A" || f === "SC 13D/A") return "ownership";
150
+ return "other";
151
+ }
152
+
153
+ function getFormDescription(form: string): string {
154
+ const f = form.trim().toUpperCase();
155
+ switch (f) {
156
+ case "10-K": return "Annual Report";
157
+ case "10-K/A": return "Annual Report (Amended)";
158
+ case "10-Q": return "Quarterly Report";
159
+ case "10-Q/A": return "Quarterly Report (Amended)";
160
+ case "8-K": return "Current Report";
161
+ case "8-K/A": return "Current Report (Amended)";
162
+ case "4": return "Insider Transaction";
163
+ case "3": return "Initial Insider Ownership";
164
+ case "5": return "Annual Insider Ownership";
165
+ case "SC 13G": return "Beneficial Ownership (Passive)";
166
+ case "SC 13G/A": return "Beneficial Ownership (Amended)";
167
+ case "SC 13D": return "Beneficial Ownership (Active)";
168
+ case "SC 13D/A": return "Beneficial Ownership (Amended)";
169
+ case "DEF 14A": return "Proxy Statement";
170
+ case "S-1": return "Registration Statement";
171
+ case "20-F": return "Annual Report (Foreign)";
172
+ default: return "";
173
+ }
174
+ }
175
+
114
176
  function toFeedItems(
115
177
  filings: SecFilingItem[],
116
178
  selectedAccessionNumber: string | undefined,
@@ -119,33 +181,47 @@ function toFeedItems(
119
181
  ): DetailFeedItem[] {
120
182
  return filings.map((filing) => {
121
183
  const displayTitle = getFilingDisplayTitle(filing);
184
+ const formDesc = getFormDescription(filing.form);
122
185
  const hasFetchedContent = contentCache.has(filing.accessionNumber);
123
186
  const fetchedContent = contentCache.get(filing.accessionNumber);
187
+ const isOwnership = OWNERSHIP_FORMS.has(filing.form.trim());
124
188
  const fallbackBody = hasFetchedContent && !loadingContent && !fetchedContent
125
189
  ? `${buildDetailBody(filing)}\n\nReadable filing content was not available for this document.`
126
190
  : buildDetailBody(filing);
127
191
 
192
+ // For Form 4s, build structured preview and detail from parsed XML
193
+ const form4Preview = isOwnership && hasFetchedContent
194
+ ? buildForm4Preview(fetchedContent ?? null)
195
+ : null;
196
+ const form4Detail = isOwnership && hasFetchedContent
197
+ ? buildForm4Detail(fetchedContent ?? null, filing)
198
+ : null;
199
+
200
+ const enrichedTitle = formDesc
201
+ ? `${displayTitle} — ${formDesc}`
202
+ : displayTitle;
203
+
128
204
  return {
129
205
  id: filing.accessionNumber,
130
206
  eyebrow: filing.form,
131
- title: displayTitle,
207
+ title: form4Preview ? `${displayTitle} | ${form4Preview}` : enrichedTitle,
132
208
  timestamp: filing.filingDate,
133
- preview: filing.items ? `Items ${filing.items}` : filing.primaryDocument,
134
- detailTitle: displayTitle,
209
+ preview: form4Preview
210
+ ?? (filing.items ? `Items ${filing.items}` : (formDesc || filing.primaryDocument)),
211
+ detailTitle: enrichedTitle,
135
212
  detailMeta: [
136
213
  `Filed ${formatFiledAt(filing)}`,
137
214
  `Accession ${filing.accessionNumber}`,
138
215
  ...(filing.items ? [`Items ${filing.items}`] : []),
139
- ...(filing.primaryDocument ? [`Primary document ${filing.primaryDocument}`] : []),
140
216
  ],
141
217
  detailBody: filing.accessionNumber === selectedAccessionNumber
142
218
  ? (
143
219
  loadingContent
144
220
  ? "Loading filing content..."
145
- : fetchedContent ?? fallbackBody
221
+ : form4Detail ?? fetchedContent ?? fallbackBody
146
222
  )
147
- : fallbackBody,
148
- detailNote: [filing.primaryDocumentUrl, filing.filingUrl].filter(Boolean).join("\n"),
223
+ : form4Detail ?? fallbackBody,
224
+ detailNote: filing.filingUrl || undefined,
149
225
  };
150
226
  });
151
227
  }
@@ -200,6 +276,37 @@ function SecTab({ width, height, focused }: DetailTabProps) {
200
276
  }
201
277
  }, [filings.length, selectedIdx, setSelectedIdx]);
202
278
 
279
+ // Background prefetch Form 4 content for enriched previews
280
+ const prefetchStartedRef = useRef(new Set<string>());
281
+ useEffect(() => {
282
+ if (filings.length === 0) return;
283
+ const coordinator = getSharedMarketDataCoordinator();
284
+ if (!coordinator) return;
285
+ const gen = contentFetchRef.current;
286
+ const form4s = filings.filter((f) =>
287
+ OWNERSHIP_FORMS.has(f.form.trim())
288
+ && !contentCache.has(f.accessionNumber)
289
+ && !prefetchStartedRef.current.has(f.accessionNumber),
290
+ );
291
+ if (form4s.length === 0) return;
292
+
293
+ for (const filing of form4s) {
294
+ prefetchStartedRef.current.add(filing.accessionNumber);
295
+ }
296
+
297
+ (async () => {
298
+ for (const filing of form4s) {
299
+ if (contentFetchRef.current !== gen) return;
300
+ try {
301
+ const entry = await coordinator.loadSecFilingContent(filing);
302
+ if (contentFetchRef.current !== gen) return;
303
+ const content = entry?.data ?? null;
304
+ setContentCache((prev) => new Map(prev).set(filing.accessionNumber, content));
305
+ } catch { /* skip */ }
306
+ }
307
+ })();
308
+ }, [filings]);
309
+
203
310
  if (!ticker) return <text fg={colors.textDim}>Select a ticker to view SEC filings.</text>;
204
311
  if (!eligibleTicker) return renderNotice("SEC filings are only shown for US equities.", width);
205
312
  if (loading && filings.length === 0) return <Spinner label="Loading SEC filings..." />;
@@ -0,0 +1,359 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import 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 { colors, priceColor } from "../../../theme/colors";
7
+ import { formatCurrency, formatPercentRaw } from "../../../utils/format";
8
+ import { usePluginPaneState, usePluginTickerActions } from "../../plugin-runtime";
9
+ import { getSharedDataProvider } from "../../registry";
10
+ import { SECTORS, type SectorDef } from "./sector-data";
11
+
12
+ const REFRESH_INTERVAL_MS = 60_000;
13
+
14
+ interface SectorRow extends SectorDef {
15
+ price: number | null;
16
+ changePercent: number | null;
17
+ currency: string;
18
+ loading: boolean;
19
+ }
20
+
21
+ type SectorColumnId = "name" | "etf" | "price" | "changePercent" | "bar";
22
+ type SectorColumn = DataTableColumn & { id: SectorColumnId };
23
+ type SortDirection = "asc" | "desc";
24
+
25
+ interface SectorSortPreference {
26
+ columnId: SectorColumnId;
27
+ direction: SortDirection;
28
+ }
29
+
30
+ const DEFAULT_SORT_PREFERENCE: SectorSortPreference = {
31
+ columnId: "changePercent",
32
+ direction: "desc",
33
+ };
34
+
35
+ function buildBar(changePercent: number, barWidth: number): string {
36
+ if (barWidth <= 0) return "";
37
+ const filled = Math.round(Math.abs(changePercent) / 5 * barWidth);
38
+ const clamped = Math.min(filled, barWidth);
39
+ return "━".repeat(clamped);
40
+ }
41
+
42
+ function formatTime(date: Date): string {
43
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
44
+ }
45
+
46
+ function buildColumns(width: number): SectorColumn[] {
47
+ const etfWidth = 6;
48
+ const priceWidth = 11;
49
+ const changeWidth = 9;
50
+ const barWidth = Math.max(4, Math.min(24, Math.floor(width * 0.24)));
51
+ const columnCount = 5;
52
+ const fixedWidth = etfWidth + priceWidth + changeWidth + barWidth;
53
+ const nameWidth = Math.max(12, width - 2 - columnCount - fixedWidth);
54
+
55
+ return [
56
+ { id: "name", label: "SECTOR", width: nameWidth, align: "left" },
57
+ { id: "etf", label: "ETF", width: etfWidth, align: "left" },
58
+ { id: "price", label: "LAST", width: priceWidth, align: "right" },
59
+ { id: "changePercent", label: "CHG%", width: changeWidth, align: "right" },
60
+ { id: "bar", label: "MOVE", width: barWidth, align: "left" },
61
+ ];
62
+ }
63
+
64
+ function getSortValue(columnId: SectorColumnId, row: SectorRow): string | number | null {
65
+ switch (columnId) {
66
+ case "name":
67
+ return row.name;
68
+ case "etf":
69
+ return row.etf;
70
+ case "price":
71
+ return row.price;
72
+ case "changePercent":
73
+ case "bar":
74
+ return row.changePercent;
75
+ }
76
+ }
77
+
78
+ function compareSortValues(
79
+ left: string | number | null,
80
+ right: string | number | null,
81
+ direction: SortDirection,
82
+ ): number {
83
+ if (left == null && right == null) return 0;
84
+ if (left == null) return 1;
85
+ if (right == null) return -1;
86
+
87
+ const comparison = typeof left === "string" && typeof right === "string"
88
+ ? left.localeCompare(right)
89
+ : Number(left) - Number(right);
90
+ return direction === "asc" ? comparison : -comparison;
91
+ }
92
+
93
+ function sortRows(rows: SectorRow[], sortPreference: SectorSortPreference): SectorRow[] {
94
+ return [...rows].sort((left, right) => compareSortValues(
95
+ getSortValue(sortPreference.columnId, left),
96
+ getSortValue(sortPreference.columnId, right),
97
+ sortPreference.direction,
98
+ ));
99
+ }
100
+
101
+ function nextSortPreference(current: SectorSortPreference, columnId: string): SectorSortPreference {
102
+ const typedColumnId = columnId as SectorColumnId;
103
+ if (current.columnId !== typedColumnId) {
104
+ return {
105
+ columnId: typedColumnId,
106
+ direction: typedColumnId === "changePercent" || typedColumnId === "bar" ? "desc" : "asc",
107
+ };
108
+ }
109
+ if (current.direction === "desc") {
110
+ return { columnId: typedColumnId, direction: "asc" };
111
+ }
112
+ return DEFAULT_SORT_PREFERENCE;
113
+ }
114
+
115
+ export function SectorPerformancePane({ focused, width, height }: PaneProps) {
116
+ const { navigateTicker } = usePluginTickerActions();
117
+ const [rows, setRows] = useState<SectorRow[]>(
118
+ SECTORS.map((sector) => ({ ...sector, price: null, changePercent: null, currency: "USD", loading: true })),
119
+ );
120
+ const [selectedEtf, setSelectedEtf] = usePluginPaneState<string | null>("selectedEtf", null);
121
+ const [sortPreference, setSortPreference] = usePluginPaneState<SectorSortPreference>(
122
+ "sortPreference",
123
+ DEFAULT_SORT_PREFERENCE,
124
+ );
125
+ const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
126
+ const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
127
+
128
+ const fetchGenRef = useRef(0);
129
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
130
+ const headerScrollRef = useRef<ScrollBoxRenderable>(null);
131
+
132
+ const columns = useMemo(() => buildColumns(width), [width]);
133
+ const sortedRows = useMemo(() => sortRows(rows, sortPreference), [rows, sortPreference]);
134
+ const selectedIdx = selectedEtf
135
+ ? sortedRows.findIndex((row) => row.etf === selectedEtf)
136
+ : -1;
137
+ const activeIdx = selectedIdx >= 0 ? selectedIdx : (sortedRows.length > 0 ? 0 : -1);
138
+
139
+ const syncHeaderScroll = useCallback(() => {
140
+ const bodyScrollBox = scrollRef.current;
141
+ const headerScrollBox = headerScrollRef.current;
142
+ if (bodyScrollBox && headerScrollBox && headerScrollBox.scrollLeft !== bodyScrollBox.scrollLeft) {
143
+ headerScrollBox.scrollLeft = bodyScrollBox.scrollLeft;
144
+ }
145
+ }, []);
146
+
147
+ const handleBodyScrollActivity = useCallback(() => {
148
+ syncHeaderScroll();
149
+ }, [syncHeaderScroll]);
150
+
151
+ const fetchAll = useCallback(() => {
152
+ fetchGenRef.current += 1;
153
+ const gen = fetchGenRef.current;
154
+ const provider = getSharedDataProvider();
155
+ if (!provider) {
156
+ setRows((prev) => prev.map((row) => ({ ...row, loading: false })));
157
+ return;
158
+ }
159
+
160
+ setRows((prev) => prev.map((row) => ({ ...row, loading: true })));
161
+
162
+ const fetches = SECTORS.map(async (sector) => {
163
+ try {
164
+ const quote = await provider.getQuote(sector.etf, "");
165
+ if (fetchGenRef.current !== gen) return;
166
+ setRows((prev) =>
167
+ prev.map((row) =>
168
+ row.etf === sector.etf
169
+ ? {
170
+ ...row,
171
+ price: quote?.price ?? null,
172
+ changePercent: quote?.changePercent ?? null,
173
+ currency: quote?.currency ?? "USD",
174
+ loading: false,
175
+ }
176
+ : row,
177
+ ),
178
+ );
179
+ } catch {
180
+ if (fetchGenRef.current !== gen) return;
181
+ setRows((prev) =>
182
+ prev.map((row) =>
183
+ row.etf === sector.etf ? { ...row, loading: false } : row,
184
+ ),
185
+ );
186
+ }
187
+ });
188
+
189
+ Promise.allSettled(fetches).then(() => {
190
+ if (fetchGenRef.current === gen) {
191
+ setLastRefresh(new Date());
192
+ }
193
+ });
194
+ }, []);
195
+
196
+ useEffect(() => {
197
+ fetchAll();
198
+ const interval = setInterval(fetchAll, REFRESH_INTERVAL_MS);
199
+ return () => clearInterval(interval);
200
+ }, [fetchAll]);
201
+
202
+ useEffect(() => {
203
+ if (selectedEtf && sortedRows.some((row) => row.etf === selectedEtf)) return;
204
+ const firstRow = sortedRows[0];
205
+ if (firstRow && selectedEtf !== firstRow.etf) {
206
+ setSelectedEtf(firstRow.etf);
207
+ }
208
+ }, [selectedEtf, setSelectedEtf, sortedRows]);
209
+
210
+ useEffect(() => {
211
+ const scrollBox = scrollRef.current;
212
+ if (!scrollBox?.viewport || activeIdx < 0) return;
213
+ const viewportHeight = Math.max(scrollBox.viewport.height, 1);
214
+ if (activeIdx < scrollBox.scrollTop) {
215
+ scrollBox.scrollTo(activeIdx);
216
+ } else if (activeIdx >= scrollBox.scrollTop + viewportHeight) {
217
+ scrollBox.scrollTo(activeIdx - viewportHeight + 1);
218
+ }
219
+ }, [activeIdx, sortedRows.length]);
220
+
221
+ const openRow = useCallback((row: SectorRow) => {
222
+ navigateTicker(row.etf);
223
+ }, [navigateTicker]);
224
+
225
+ const handleHeaderClick = useCallback((columnId: string) => {
226
+ setSortPreference((current) => nextSortPreference(current, columnId));
227
+ }, [setSortPreference]);
228
+
229
+ const selectIndex = useCallback((index: number) => {
230
+ const row = sortedRows[index];
231
+ if (row) setSelectedEtf(row.etf);
232
+ }, [setSelectedEtf, sortedRows]);
233
+
234
+ const selectRow = useCallback((row: SectorRow) => {
235
+ if (row.etf === selectedEtf) {
236
+ openRow(row);
237
+ return;
238
+ }
239
+ setSelectedEtf(row.etf);
240
+ }, [openRow, selectedEtf, setSelectedEtf]);
241
+
242
+ useKeyboard((event) => {
243
+ if (!focused) return;
244
+
245
+ if (event.name === "j" || event.name === "down") {
246
+ selectIndex(Math.min(activeIdx + 1, sortedRows.length - 1));
247
+ } else if (event.name === "k" || event.name === "up") {
248
+ selectIndex(Math.max(activeIdx - 1, 0));
249
+ } else if (event.name === "return") {
250
+ const row = sortedRows[activeIdx];
251
+ if (row) openRow(row);
252
+ } else if (event.name === "r") {
253
+ fetchAll();
254
+ }
255
+ });
256
+
257
+ const renderCell = useCallback((
258
+ row: SectorRow,
259
+ column: SectorColumn,
260
+ _index: number,
261
+ rowState: { selected: boolean },
262
+ ): DataTableCell => {
263
+ const selectedColor = rowState.selected ? colors.selectedText : undefined;
264
+ switch (column.id) {
265
+ case "name":
266
+ return { text: row.name, color: selectedColor ?? colors.text };
267
+ case "etf":
268
+ return { text: row.etf, color: selectedColor ?? colors.textDim };
269
+ case "price":
270
+ if (row.loading && row.price === null) {
271
+ return { text: "…", color: selectedColor ?? colors.textDim };
272
+ }
273
+ return {
274
+ text: row.price !== null ? formatCurrency(row.price, row.currency) : "—",
275
+ color: selectedColor ?? (row.price !== null ? colors.text : colors.textDim),
276
+ };
277
+ case "changePercent":
278
+ return {
279
+ text: row.changePercent !== null ? formatPercentRaw(row.changePercent) : "—",
280
+ color: selectedColor ?? (row.changePercent !== null ? priceColor(row.changePercent) : colors.textDim),
281
+ };
282
+ case "bar": {
283
+ const bar = row.changePercent !== null ? buildBar(row.changePercent, column.width) : "";
284
+ const barColor = row.changePercent !== null && row.changePercent >= 0 ? colors.positive : colors.negative;
285
+ return {
286
+ text: bar,
287
+ color: selectedColor ?? (bar ? barColor : colors.textDim),
288
+ };
289
+ }
290
+ }
291
+ }, []);
292
+
293
+ return (
294
+ <box flexDirection="column" width={width} height={height}>
295
+ <box flexDirection="row" height={1} paddingX={1}>
296
+ {lastRefresh ? (
297
+ <text fg={colors.textMuted}>{formatTime(lastRefresh)}</text>
298
+ ) : (
299
+ <text fg={colors.textMuted}>loading…</text>
300
+ )}
301
+ </box>
302
+
303
+ <DataTable<SectorRow, SectorColumn>
304
+ columns={columns}
305
+ items={sortedRows}
306
+ sortColumnId={sortPreference.columnId}
307
+ sortDirection={sortPreference.direction}
308
+ onHeaderClick={handleHeaderClick}
309
+ headerScrollRef={headerScrollRef}
310
+ scrollRef={scrollRef}
311
+ syncHeaderScroll={syncHeaderScroll}
312
+ onBodyScrollActivity={handleBodyScrollActivity}
313
+ hoveredIdx={hoveredIdx}
314
+ setHoveredIdx={setHoveredIdx}
315
+ getItemKey={(row) => row.etf}
316
+ isSelected={(row) => row.etf === selectedEtf}
317
+ onSelect={selectRow}
318
+ renderCell={renderCell}
319
+ emptyStateTitle="No sector data available"
320
+ showHorizontalScrollbar={false}
321
+ />
322
+
323
+ <box height={1} paddingX={1}>
324
+ <text fg={colors.textMuted}>[r]efresh</text>
325
+ </box>
326
+ </box>
327
+ );
328
+ }
329
+
330
+ export const sectorsPlugin: GloomPlugin = {
331
+ id: "sectors",
332
+ name: "Sector Performance",
333
+ version: "1.0.0",
334
+ description: "S&P 500 sector performance via sector ETF proxies",
335
+ toggleable: true,
336
+
337
+ panes: [
338
+ {
339
+ id: "sectors",
340
+ name: "Sector Performance",
341
+ icon: "S",
342
+ component: SectorPerformancePane,
343
+ defaultPosition: "right",
344
+ defaultMode: "floating",
345
+ defaultFloatingSize: { width: 75, height: 16 },
346
+ },
347
+ ],
348
+
349
+ paneTemplates: [
350
+ {
351
+ id: "sectors-pane",
352
+ paneId: "sectors",
353
+ label: "Sector Performance",
354
+ description: "S&P 500 sector performance sorted by daily change.",
355
+ keywords: ["sector", "sectors", "etf", "xlk", "xlv", "xlf", "performance", "spdr"],
356
+ shortcut: { prefix: "BI" },
357
+ },
358
+ ],
359
+ };
@@ -0,0 +1,18 @@
1
+ export interface SectorDef {
2
+ name: string;
3
+ etf: string;
4
+ }
5
+
6
+ export const SECTORS: SectorDef[] = [
7
+ { name: "Technology", etf: "XLK" },
8
+ { name: "Healthcare", etf: "XLV" },
9
+ { name: "Financials", etf: "XLF" },
10
+ { name: "Consumer Disc.", etf: "XLY" },
11
+ { name: "Communication", etf: "XLC" },
12
+ { name: "Industrials", etf: "XLI" },
13
+ { name: "Consumer Staples", etf: "XLP" },
14
+ { name: "Energy", etf: "XLE" },
15
+ { name: "Utilities", etf: "XLU" },
16
+ { name: "Real Estate", etf: "XLRE" },
17
+ { name: "Materials", etf: "XLB" },
18
+ ];
@@ -1,8 +1,24 @@
1
1
  import { useTerminalDimensions } from "@opentui/react";
2
+ import { useCallback, useMemo } from "react";
3
+ import { saveConfig } from "../../../data/config-store";
4
+ import { ChartIndicatorSelector } from "../../../components/chart/chart-indicator-selector";
5
+ import {
6
+ buildIndicatorConfigFromSelection,
7
+ CHART_INDICATORS_PLUGIN_CONFIG_VERSION_KEY,
8
+ CURRENT_CHART_INDICATORS_CONFIG_VERSION,
9
+ CHART_INDICATORS_PLUGIN_CONFIG_KEY,
10
+ resolveChartIndicatorSelection,
11
+ normalizeChartIndicatorSelection,
12
+ type ChartIndicatorId,
13
+ } from "../../../components/chart/indicators/options";
2
14
  import type { ChartAxisMode } from "../../../components/chart/chart-types";
3
15
  import { ResolvedStockChart } from "../../../components/chart/stock-chart";
16
+ import { useAppState } from "../../../state/app-context";
4
17
  import type { TickerFinancials } from "../../../types/financials";
5
18
  import type { TickerRecord } from "../../../types/ticker";
19
+ import { getSharedRegistry } from "../../registry";
20
+
21
+ const TICKER_DETAIL_PLUGIN_ID = "ticker-detail";
6
22
 
7
23
  export function ChartTab({
8
24
  width,
@@ -26,29 +42,75 @@ export function ChartTab({
26
42
  financials: TickerFinancials | null;
27
43
  }) {
28
44
  const { width: termWidth, height: termHeight } = useTerminalDimensions();
45
+ const { state, dispatch } = useAppState();
29
46
 
30
47
  const chartWidth = Math.max((width || Math.floor(termWidth * 0.55)) - 2, 30);
31
48
  const chartHeight = Math.max((height || termHeight - 8) - 2, 10);
49
+ const rawIndicatorSelection = state.config.pluginConfig[TICKER_DETAIL_PLUGIN_ID]?.[CHART_INDICATORS_PLUGIN_CONFIG_KEY];
50
+ const indicatorSelectionVersion = state.config.pluginConfig[TICKER_DETAIL_PLUGIN_ID]?.[CHART_INDICATORS_PLUGIN_CONFIG_VERSION_KEY];
51
+ const hasStoredIndicatorSelection = Array.isArray(rawIndicatorSelection);
52
+ const selectedIndicatorIds = useMemo(
53
+ () => resolveChartIndicatorSelection(rawIndicatorSelection, indicatorSelectionVersion),
54
+ [indicatorSelectionVersion, rawIndicatorSelection],
55
+ );
56
+ const selectedIndicatorConfig = useMemo(
57
+ () => buildIndicatorConfigFromSelection(selectedIndicatorIds),
58
+ [selectedIndicatorIds],
59
+ );
60
+ const showVolume = selectedIndicatorIds.includes("volume");
61
+ const persistIndicatorSelection = useCallback((nextSelection: ChartIndicatorId[]) => {
62
+ const normalized = normalizeChartIndicatorSelection(nextSelection);
63
+ const nextConfig = {
64
+ ...state.config,
65
+ pluginConfig: {
66
+ ...state.config.pluginConfig,
67
+ [TICKER_DETAIL_PLUGIN_ID]: {
68
+ ...(state.config.pluginConfig[TICKER_DETAIL_PLUGIN_ID] ?? {}),
69
+ [CHART_INDICATORS_PLUGIN_CONFIG_KEY]: normalized,
70
+ [CHART_INDICATORS_PLUGIN_CONFIG_VERSION_KEY]: CURRENT_CHART_INDICATORS_CONFIG_VERSION,
71
+ },
72
+ },
73
+ };
74
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
75
+ saveConfig(nextConfig).catch(() => {});
76
+ getSharedRegistry()?.events.emit("config:changed", { config: nextConfig });
77
+ }, [dispatch, state.config]);
32
78
 
33
79
  return (
34
80
  <box
35
81
  flexDirection="column"
36
82
  paddingX={1}
37
83
  flexGrow={1}
38
- onMouseDown={() => {
39
- if (!interactive) onActivate?.();
40
- }}
41
84
  >
42
- <ResolvedStockChart
43
- width={chartWidth}
44
- height={chartHeight}
45
- focused={focused}
46
- interactive={interactive}
47
- axisMode={axisMode}
48
- symbol={symbol}
49
- ticker={ticker}
50
- financials={financials}
51
- />
85
+ <box
86
+ flexDirection="column"
87
+ flexGrow={1}
88
+ onMouseDown={() => {
89
+ if (!interactive) onActivate?.();
90
+ }}
91
+ >
92
+ <ResolvedStockChart
93
+ width={chartWidth}
94
+ height={chartHeight}
95
+ focused={focused}
96
+ interactive={interactive}
97
+ axisMode={axisMode}
98
+ symbol={symbol}
99
+ ticker={ticker}
100
+ financials={financials}
101
+ indicatorConfig={hasStoredIndicatorSelection ? selectedIndicatorConfig : undefined}
102
+ showVolume={showVolume}
103
+ footerControls={(
104
+ <ChartIndicatorSelector
105
+ width={chartWidth}
106
+ selectedIds={selectedIndicatorIds}
107
+ onChange={persistIndicatorSelection}
108
+ variant="hint"
109
+ shortcutActive={focused}
110
+ />
111
+ )}
112
+ />
113
+ </box>
52
114
  </box>
53
115
  );
54
116
  }