gloomberb 0.4.1 → 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 (207) 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/header.tsx +2 -1
  48. package/src/components/layout/shell.test.tsx +186 -27
  49. package/src/components/layout/shell.tsx +5 -4
  50. package/src/components/layout/status-bar.test.tsx +16 -7
  51. package/src/components/layout/status-bar.tsx +4 -1
  52. package/src/components/markdown-editor.tsx +1 -0
  53. package/src/components/pane-settings-dialog.tsx +13 -152
  54. package/src/components/pane-template-wizard.tsx +1 -0
  55. package/src/components/price-selector-dialog.tsx +12 -7
  56. package/src/components/ticker-badge-text.test.tsx +50 -0
  57. package/src/components/ticker-badge-text.tsx +17 -2
  58. package/src/components/ticker-badge.tsx +2 -2
  59. package/src/components/ticker-list-table.tsx +54 -1
  60. package/src/components/toggle-list.tsx +15 -2
  61. package/src/components/ui/data-table.tsx +60 -3
  62. package/src/components/ui/external-link.tsx +48 -11
  63. package/src/components/ui/fields.tsx +2 -1
  64. package/src/components/ui/index.ts +8 -1
  65. package/src/components/ui/multi-select-chips.tsx +69 -0
  66. package/src/components/ui/multi-select-dialog.tsx +267 -0
  67. package/src/components/ui/multi-select.ts +78 -0
  68. package/src/components/ui/ui.test.tsx +343 -1
  69. package/src/data/plugin-state-store.ts +30 -19
  70. package/src/data/resource-store.ts +133 -103
  71. package/src/data/session-store.ts +19 -12
  72. package/src/data/sqlite-cache.test.ts +37 -2
  73. package/src/data/sqlite-database.ts +8 -2
  74. package/src/data/sqlite-retry.test.ts +63 -0
  75. package/src/data/sqlite-retry.ts +66 -0
  76. package/src/data/ticker-store.ts +29 -18
  77. package/src/index.tsx +6 -1
  78. package/src/market-data/coordinator.test.ts +114 -5
  79. package/src/market-data/coordinator.ts +70 -1
  80. package/src/market-data/hooks.test.tsx +73 -2
  81. package/src/market-data/hooks.tsx +16 -4
  82. package/src/notifications/app-notifier.test.ts +177 -0
  83. package/src/notifications/app-notifier.ts +241 -0
  84. package/src/pane-settings.test.ts +2 -0
  85. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  86. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  87. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  88. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  89. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  90. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  91. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  92. package/src/plugins/builtin/alerts/index.tsx +519 -0
  93. package/src/plugins/builtin/alerts/types.ts +14 -0
  94. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  95. package/src/plugins/builtin/analytics/index.tsx +775 -0
  96. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  97. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  98. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  99. package/src/plugins/builtin/chat-controller.test.ts +62 -18
  100. package/src/plugins/builtin/chat-controller.ts +23 -12
  101. package/src/plugins/builtin/chat.test.tsx +715 -4
  102. package/src/plugins/builtin/chat.tsx +415 -119
  103. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  104. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  105. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  106. package/src/plugins/builtin/correlation/compute.ts +115 -0
  107. package/src/plugins/builtin/correlation/index.tsx +385 -0
  108. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  109. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  110. package/src/plugins/builtin/correlation/settings.ts +95 -0
  111. package/src/plugins/builtin/debug.tsx +36 -50
  112. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  113. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  114. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  115. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  116. package/src/plugins/builtin/earnings/index.tsx +337 -0
  117. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  118. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  119. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  120. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  121. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  122. package/src/plugins/builtin/econ/index.tsx +999 -0
  123. package/src/plugins/builtin/econ/types.ts +13 -0
  124. package/src/plugins/builtin/fred-settings.ts +10 -0
  125. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  126. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  127. package/src/plugins/builtin/help.test.tsx +1 -5
  128. package/src/plugins/builtin/help.tsx +1 -16
  129. package/src/plugins/builtin/insider/index.tsx +417 -0
  130. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  131. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  132. package/src/plugins/builtin/layout-manager.tsx +24 -16
  133. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  134. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  135. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  136. package/src/plugins/builtin/options.tsx +4 -3
  137. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  138. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  139. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  140. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  141. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  142. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  143. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  144. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  146. package/src/plugins/builtin/sec.tsx +114 -7
  147. package/src/plugins/builtin/sectors/index.tsx +359 -0
  148. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  149. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  150. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  151. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  152. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  153. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  154. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  155. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  156. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  157. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  158. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  159. package/src/plugins/catalog.ts +22 -0
  160. package/src/plugins/gridlock-notification.ts +15 -0
  161. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  162. package/src/plugins/ibkr/index.test.tsx +1 -1
  163. package/src/plugins/ibkr/index.tsx +1 -1
  164. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  165. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  166. package/src/plugins/ibkr/trade-utils.ts +7 -6
  167. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  168. package/src/plugins/plugin-runtime.test.tsx +2 -0
  169. package/src/plugins/plugin-runtime.tsx +10 -0
  170. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  171. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  172. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  173. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  174. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  175. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  176. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  177. package/src/plugins/registry.ts +22 -13
  178. package/src/sources/provider-router.test.ts +3 -2
  179. package/src/sources/sec-edgar.ts +13 -1
  180. package/src/sources/yahoo-finance.ts +75 -1
  181. package/src/state/app-context-selector.test.tsx +17 -1
  182. package/src/state/app-context.test.tsx +24 -0
  183. package/src/state/app-context.tsx +29 -6
  184. package/src/theme/colors.ts +5 -0
  185. package/src/theme/themes.ts +25 -0
  186. package/src/types/config.test.ts +107 -0
  187. package/src/types/config.ts +30 -1
  188. package/src/types/data-provider.ts +13 -0
  189. package/src/types/plugin.ts +35 -1
  190. package/src/utils/format.test.ts +11 -1
  191. package/src/utils/format.ts +86 -8
  192. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  193. package/src/utils/inline-content-tokenizer.ts +76 -0
  194. package/src/utils/link-tokenizer.test.ts +27 -0
  195. package/src/utils/link-tokenizer.ts +93 -0
  196. package/src/utils/market-format.test.ts +109 -0
  197. package/src/utils/market-format.ts +299 -0
  198. package/src/utils/market-status.ts +3 -2
  199. package/src/utils/selection-clipboard.test.ts +43 -0
  200. package/src/utils/selection-clipboard.ts +88 -0
  201. package/src/utils/terminal-input-reset.test.ts +50 -0
  202. package/src/utils/terminal-input-reset.ts +24 -0
  203. package/src/utils/throttled-fetch.test.ts +145 -0
  204. package/src/utils/throttled-fetch.ts +212 -0
  205. package/src/utils/ticker-tokenizer.test.ts +31 -0
  206. package/src/utils/ticker-tokenizer.ts +7 -42
  207. package/src/version.ts +1 -1
@@ -0,0 +1,417 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import type { GloomPlugin, DetailTabProps } from "../../../types/plugin";
5
+ import type { SecFilingItem } from "../../../types/data-provider";
6
+ import {
7
+ useResolvedEntryValue,
8
+ useSecFilingContent,
9
+ useSecFilingsQuery,
10
+ } from "../../../market-data/hooks";
11
+ import { instrumentFromTicker } from "../../../market-data/request-types";
12
+ import { usePaneTicker } from "../../../state/app-context";
13
+ import { colors } from "../../../theme/colors";
14
+ import { Spinner } from "../../../components/spinner";
15
+ import { isUsEquityTicker } from "../../../utils/sec";
16
+ import { formatCompact, formatCurrency } from "../../../utils/format";
17
+ import { parseForm4Xml, transactionTypeLabel, type InsiderTransaction } from "./insider-data";
18
+
19
+ const FORM4_LIMIT = 20;
20
+ const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
21
+
22
+ const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
23
+
24
+ function formatDate(d: Date | string | number): string {
25
+ const date = d instanceof Date ? d : new Date(d);
26
+ if (isNaN(date.getTime())) return "—";
27
+ return `${MONTH_NAMES[date.getMonth()]} ${String(date.getDate()).padStart(2, " ")} ${date.getFullYear()}`;
28
+ }
29
+
30
+ function transactionColor(type: InsiderTransaction["transactionType"]): string {
31
+ if (type === "P") return colors.positive;
32
+ if (type === "S") return colors.negative;
33
+ return colors.textDim;
34
+ }
35
+
36
+ function wrapLines(text: string, width: number): string[] {
37
+ const words = text.trim().split(/\s+/);
38
+ const lines: string[] = [];
39
+ let current = "";
40
+ for (const word of words) {
41
+ if (!current) { current = word; continue; }
42
+ if (current.length + 1 + word.length <= width) {
43
+ current = `${current} ${word}`;
44
+ } else {
45
+ lines.push(current);
46
+ current = word;
47
+ }
48
+ }
49
+ if (current) lines.push(current);
50
+ return lines;
51
+ }
52
+
53
+ function renderNotice(message: string, width: number) {
54
+ const lines = wrapLines(message, width - 4);
55
+ return (
56
+ <box flexDirection="column" paddingX={1} paddingY={1}>
57
+ {lines.map((line, i) => (
58
+ <box key={i} height={1}>
59
+ <text fg={colors.textDim}>{line}</text>
60
+ </box>
61
+ ))}
62
+ </box>
63
+ );
64
+ }
65
+
66
+ interface ParsedFiling {
67
+ filing: SecFilingItem;
68
+ transaction: InsiderTransaction | null;
69
+ }
70
+
71
+ function buildSummary(parsed: ParsedFiling[]): string {
72
+ const cutoff = new Date(Date.now() - NINETY_DAYS_MS);
73
+ let buyShares = 0;
74
+ let sellShares = 0;
75
+ let buyValue = 0;
76
+ let sellValue = 0;
77
+
78
+ for (const { transaction } of parsed) {
79
+ if (!transaction) continue;
80
+ const txDate = transaction.filingDate instanceof Date ? transaction.filingDate : new Date(transaction.filingDate);
81
+ if (isNaN(txDate.getTime()) || txDate < cutoff) continue;
82
+ if (transaction.transactionType === "P") {
83
+ buyShares += transaction.shares;
84
+ buyValue += transaction.totalValue ?? 0;
85
+ } else if (transaction.transactionType === "S") {
86
+ sellShares += transaction.shares;
87
+ sellValue += transaction.totalValue ?? 0;
88
+ }
89
+ }
90
+
91
+ if (buyShares === 0 && sellShares === 0) return "No buy/sell activity in last 90 days.";
92
+
93
+ const parts: string[] = [];
94
+ if (buyShares > 0) parts.push(`Bought ${formatCompact(buyShares)} shares (${formatCurrency(buyValue)})`);
95
+ if (sellShares > 0) parts.push(`Sold ${formatCompact(sellShares)} shares (${formatCurrency(sellValue)})`);
96
+ return parts.join(" | ");
97
+ }
98
+
99
+ function InsiderTab({ width, height, focused }: DetailTabProps) {
100
+ const { ticker } = usePaneTicker();
101
+ const eligibleTicker = isUsEquityTicker(ticker);
102
+ const instrument = instrumentFromTicker(ticker, ticker?.metadata.ticker ?? null);
103
+
104
+ const [selectedIdx, setSelectedIdx] = useState(0);
105
+ const [nameFilter, setNameFilter] = useState<string | null>(null);
106
+ const [detailFiling, setDetailFiling] = useState<ParsedFiling | null>(null);
107
+
108
+ const filingsEntry = useSecFilingsQuery(
109
+ instrument && eligibleTicker ? { instrument, count: FORM4_LIMIT } : null,
110
+ );
111
+ const allFilings = useResolvedEntryValue(filingsEntry) ?? [];
112
+ const form4Filings = allFilings.filter((f) => f.form.trim() === "4");
113
+
114
+ const loading =
115
+ filingsEntry?.phase === "loading" ||
116
+ (filingsEntry?.phase === "refreshing" && allFilings.length === 0);
117
+ const error =
118
+ filingsEntry?.phase === "error"
119
+ ? (filingsEntry.error?.message ?? "Failed to load SEC filings")
120
+ : null;
121
+
122
+ // Fetch content for each Form 4 filing sequentially via an index pointer
123
+ const [contentMap, setContentMap] = useState<Map<string, string | null>>(new Map());
124
+ const fetchIndexRef = useRef(0);
125
+ const [fetchPointer, setFetchPointer] = useState(0);
126
+
127
+ // Reset when ticker changes
128
+ useEffect(() => {
129
+ fetchIndexRef.current += 1;
130
+ setContentMap(new Map());
131
+ setFetchPointer(0);
132
+ }, [ticker?.metadata.ticker]);
133
+
134
+ // Pick the next un-fetched filing to load
135
+ const nextToFetch = form4Filings.find((f) => !contentMap.has(f.accessionNumber)) ?? null;
136
+ const contentEntry = useSecFilingContent(nextToFetch);
137
+ const contentValue = useResolvedEntryValue(contentEntry);
138
+
139
+ useEffect(() => {
140
+ if (!nextToFetch) return;
141
+ if (contentEntry?.phase === "error") {
142
+ setContentMap((prev) => new Map(prev).set(nextToFetch.accessionNumber, null));
143
+ setFetchPointer((p) => p + 1);
144
+ return;
145
+ }
146
+ if (contentValue !== null && contentValue !== undefined) {
147
+ setContentMap((prev) => new Map(prev).set(nextToFetch.accessionNumber, contentValue));
148
+ setFetchPointer((p) => p + 1);
149
+ }
150
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
+ }, [contentValue, contentEntry?.phase, nextToFetch?.accessionNumber, fetchPointer]);
152
+
153
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
154
+
155
+ const allParsed: ParsedFiling[] = form4Filings.map((filing) => {
156
+ const xml = contentMap.get(filing.accessionNumber) ?? null;
157
+ return {
158
+ filing,
159
+ transaction: xml ? parseForm4Xml(xml) : null,
160
+ };
161
+ });
162
+
163
+ // Apply name filter
164
+ const parsed = nameFilter
165
+ ? allParsed.filter((p) => p.transaction?.reportedName === nameFilter)
166
+ : allParsed;
167
+
168
+ // Keyboard handling
169
+ useKeyboard((event) => {
170
+ if (!focused) return;
171
+
172
+ if (event.name === "escape") {
173
+ if (detailFiling) { setDetailFiling(null); return; }
174
+ if (nameFilter) { setNameFilter(null); setSelectedIdx(0); return; }
175
+ return;
176
+ }
177
+
178
+ if (detailFiling) return; // No navigation in detail view
179
+
180
+ if (event.name === "j" || event.name === "down") {
181
+ setSelectedIdx((prev) => Math.min(prev + 1, parsed.length - 1));
182
+ } else if (event.name === "k" || event.name === "up") {
183
+ setSelectedIdx((prev) => Math.max(prev - 1, 0));
184
+ } else if (event.name === "return") {
185
+ const item = parsed[selectedIdx];
186
+ if (item?.transaction) setDetailFiling(item);
187
+ } else if (event.name === "f") {
188
+ // Toggle name filter on selected row's insider
189
+ const item = parsed[selectedIdx];
190
+ if (item?.transaction) {
191
+ if (nameFilter === item.transaction.reportedName) {
192
+ setNameFilter(null);
193
+ } else {
194
+ setNameFilter(item.transaction.reportedName);
195
+ }
196
+ setSelectedIdx(0);
197
+ }
198
+ }
199
+ });
200
+
201
+ // Scroll to follow selection
202
+ useEffect(() => {
203
+ const sb = scrollRef.current;
204
+ if (!sb?.viewport || parsed.length === 0 || selectedIdx < 0) return;
205
+ const viewportHeight = Math.max(sb.viewport.height, 1);
206
+ if (selectedIdx < sb.scrollTop) sb.scrollTo(selectedIdx);
207
+ else if (selectedIdx >= sb.scrollTop + viewportHeight) sb.scrollTo(selectedIdx - viewportHeight + 1);
208
+ }, [selectedIdx, parsed.length]);
209
+
210
+ if (!ticker) return <text fg={colors.textDim}>Select a ticker to view insider activity.</text>;
211
+ if (!eligibleTicker) return renderNotice("Insider transactions are only shown for US equities.", width);
212
+ if (loading && allFilings.length === 0) return <Spinner label="Loading insider filings..." />;
213
+ if (error) return renderNotice(`Error: ${error}`, width);
214
+ if (!loading && form4Filings.length === 0) {
215
+ return renderNotice(`No Form 4 filings found for ${ticker.metadata.ticker}.`, width);
216
+ }
217
+
218
+ // Detail view
219
+ if (detailFiling?.transaction) {
220
+ const tx = detailFiling.transaction;
221
+ const filing = detailFiling.filing;
222
+ return (
223
+ <box flexDirection="column" width={width} height={height}>
224
+ <box height={1} paddingX={1}>
225
+ <text fg={colors.textBright} attributes={TextAttributes.BOLD}>◂ {tx.reportedName}</text>
226
+ {tx.title && <text fg={colors.textDim}> — {tx.title}</text>}
227
+ </box>
228
+ <scrollbox flexGrow={1} scrollY focusable={false}>
229
+ <box flexDirection="column" paddingX={1} paddingY={1} gap={1}>
230
+ <box flexDirection="column">
231
+ <box flexDirection="row" height={1}>
232
+ <box width={16}><text fg={colors.textDim}>Transaction</text></box>
233
+ <text fg={transactionColor(tx.transactionType)} attributes={TextAttributes.BOLD}>
234
+ {transactionTypeLabel(tx.transactionType)}
235
+ </text>
236
+ </box>
237
+ <box flexDirection="row" height={1}>
238
+ <box width={16}><text fg={colors.textDim}>Date</text></box>
239
+ <text fg={colors.text}>{formatDate(tx.filingDate)}</text>
240
+ </box>
241
+ <box flexDirection="row" height={1}>
242
+ <box width={16}><text fg={colors.textDim}>Shares</text></box>
243
+ <text fg={colors.text}>{formatCompact(tx.shares)}</text>
244
+ </box>
245
+ <box flexDirection="row" height={1}>
246
+ <box width={16}><text fg={colors.textDim}>Price/Share</text></box>
247
+ <text fg={colors.text}>
248
+ {tx.pricePerShare != null ? formatCurrency(tx.pricePerShare) : "—"}
249
+ </text>
250
+ </box>
251
+ <box flexDirection="row" height={1}>
252
+ <box width={16}><text fg={colors.textDim}>Total Value</text></box>
253
+ <text fg={transactionColor(tx.transactionType)}>
254
+ {tx.totalValue != null ? formatCurrency(tx.totalValue) : "—"}
255
+ </text>
256
+ </box>
257
+ <box flexDirection="row" height={1}>
258
+ <box width={16}><text fg={colors.textDim}>Shares Owned</text></box>
259
+ <text fg={colors.text}>
260
+ {tx.sharesOwned != null ? formatCompact(tx.sharesOwned) : "—"}
261
+ </text>
262
+ </box>
263
+ </box>
264
+ <box flexDirection="column">
265
+ <box height={1}>
266
+ <text fg={colors.textDim} attributes={TextAttributes.BOLD}>Filing</text>
267
+ </box>
268
+ <box flexDirection="row" height={1}>
269
+ <box width={16}><text fg={colors.textDim}>Accession</text></box>
270
+ <text fg={colors.text}>{filing.accessionNumber}</text>
271
+ </box>
272
+ <box flexDirection="row" height={1}>
273
+ <box width={16}><text fg={colors.textDim}>Filed</text></box>
274
+ <text fg={colors.text}>{formatDate(filing.filingDate)}</text>
275
+ </box>
276
+ <box flexDirection="row" height={1}>
277
+ <box width={16}><text fg={colors.textDim}>Form</text></box>
278
+ <text fg={colors.text}>{filing.form}</text>
279
+ </box>
280
+ </box>
281
+ </box>
282
+ </scrollbox>
283
+ </box>
284
+ );
285
+ }
286
+
287
+ const summary = buildSummary(allParsed);
288
+ const dateW = 12;
289
+ const typeW = 7;
290
+ const sharesW = 10;
291
+ const priceW = 10;
292
+ const valueW = 12;
293
+ const nameW = Math.max(10, width - dateW - typeW - sharesW - priceW - valueW - 2);
294
+ const pendingCount = form4Filings.filter((f) => !contentMap.has(f.accessionNumber)).length;
295
+
296
+ return (
297
+ <box flexDirection="column" width={width} height={height}>
298
+ {/* Summary bar */}
299
+ <box height={1} paddingX={1} flexDirection="row">
300
+ <text fg={colors.textDim}>{summary}</text>
301
+ {nameFilter && (
302
+ <box marginLeft={1}>
303
+ <text fg={colors.warning}>[filtered: {nameFilter}]</text>
304
+ </box>
305
+ )}
306
+ {pendingCount > 0 && (
307
+ <>
308
+ <box flexGrow={1} />
309
+ <text fg={colors.textMuted}>loading {pendingCount}…</text>
310
+ </>
311
+ )}
312
+ </box>
313
+
314
+ {/* Column headers */}
315
+ <box height={1} paddingX={1} flexDirection="row">
316
+ <box width={dateW}><text fg={colors.textDim}>DATE</text></box>
317
+ <box width={nameW}><text fg={colors.textDim}>INSIDER</text></box>
318
+ <box width={typeW}><text fg={colors.textDim}>TYPE</text></box>
319
+ <box width={sharesW} justifyContent="flex-end"><text fg={colors.textDim}>SHARES</text></box>
320
+ <box width={priceW} justifyContent="flex-end"><text fg={colors.textDim}>PRICE</text></box>
321
+ <box width={valueW} justifyContent="flex-end"><text fg={colors.textDim}>VALUE</text></box>
322
+ </box>
323
+
324
+ <scrollbox ref={scrollRef} flexGrow={1} scrollY focusable={false}>
325
+ <box flexDirection="column">
326
+ {parsed.map(({ filing, transaction }, i) => {
327
+ const isSelected = i === selectedIdx;
328
+ const bg = isSelected ? colors.selected : undefined;
329
+
330
+ if (!transaction) {
331
+ const isLoading = !contentMap.has(filing.accessionNumber);
332
+ return (
333
+ <box key={filing.accessionNumber} height={1} paddingX={1} flexDirection="row" backgroundColor={bg}>
334
+ <box width={dateW}>
335
+ <text fg={colors.textDim}>{formatDate(filing.filingDate)}</text>
336
+ </box>
337
+ <box flexGrow={1}>
338
+ <text fg={colors.textMuted}>{isLoading ? "loading…" : "—"}</text>
339
+ </box>
340
+ </box>
341
+ );
342
+ }
343
+
344
+ const typeLabel = transactionTypeLabel(transaction.transactionType);
345
+ const typeFg = isSelected ? colors.selectedText : transactionColor(transaction.transactionType);
346
+ const fg = isSelected ? colors.selectedText : colors.text;
347
+ const dimFg = isSelected ? colors.selectedText : colors.textDim;
348
+
349
+ return (
350
+ <box
351
+ key={filing.accessionNumber}
352
+ height={1}
353
+ paddingX={1}
354
+ flexDirection="row"
355
+ backgroundColor={bg}
356
+ onMouseDown={() => setSelectedIdx(i)}
357
+ >
358
+ <box width={dateW}>
359
+ <text fg={dimFg}>{formatDate(transaction.filingDate)}</text>
360
+ </box>
361
+ <box
362
+ width={nameW}
363
+ onMouseDown={(ev: any) => {
364
+ ev.preventDefault?.();
365
+ if (nameFilter === transaction.reportedName) {
366
+ setNameFilter(null);
367
+ } else {
368
+ setNameFilter(transaction.reportedName);
369
+ setSelectedIdx(0);
370
+ }
371
+ }}
372
+ >
373
+ <text fg={fg} attributes={TextAttributes.UNDERLINE}>
374
+ {transaction.reportedName.slice(0, nameW - 1)}
375
+ </text>
376
+ </box>
377
+ <box width={typeW}>
378
+ <text fg={typeFg} attributes={TextAttributes.BOLD}>{typeLabel}</text>
379
+ </box>
380
+ <box width={sharesW} justifyContent="flex-end">
381
+ <text fg={fg}>{formatCompact(transaction.shares)}</text>
382
+ </box>
383
+ <box width={priceW} justifyContent="flex-end">
384
+ <text fg={fg}>
385
+ {transaction.pricePerShare != null ? formatCurrency(transaction.pricePerShare) : "—"}
386
+ </text>
387
+ </box>
388
+ <box width={valueW} justifyContent="flex-end">
389
+ <text fg={typeFg}>
390
+ {transaction.totalValue != null ? formatCompact(transaction.totalValue) : "—"}
391
+ </text>
392
+ </box>
393
+ </box>
394
+ );
395
+ })}
396
+ </box>
397
+ </scrollbox>
398
+ </box>
399
+ );
400
+ }
401
+
402
+ export const insiderPlugin: GloomPlugin = {
403
+ id: "insider",
404
+ name: "Insider Trading",
405
+ version: "1.0.0",
406
+ description: "SEC Form 4 insider transaction activity",
407
+
408
+ setup(ctx) {
409
+ ctx.registerDetailTab({
410
+ id: "insider",
411
+ name: "Insider",
412
+ order: 47,
413
+ component: InsiderTab,
414
+ isVisible: ({ ticker }) => isUsEquityTicker(ticker),
415
+ });
416
+ },
417
+ };
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseForm4Xml, transactionTypeLabel } from "./insider-data";
3
+
4
+ const SAMPLE_FORM4 = `<?xml version="1.0"?>
5
+ <ownershipDocument>
6
+ <reportingOwner>
7
+ <reportingOwnerId><rptOwnerName>COOK TIMOTHY D</rptOwnerName></reportingOwnerId>
8
+ <reportingOwnerRelationship>
9
+ <isOfficer>1</isOfficer>
10
+ <officerTitle>Chief Executive Officer</officerTitle>
11
+ </reportingOwnerRelationship>
12
+ </reportingOwner>
13
+ <nonDerivativeTable>
14
+ <nonDerivativeTransaction>
15
+ <transactionDate><value>2024-04-01</value></transactionDate>
16
+ <transactionCoding><transactionCode>S</transactionCode></transactionCoding>
17
+ <transactionAmounts>
18
+ <transactionShares><value>50000</value></transactionShares>
19
+ <transactionPricePerShare><value>171.50</value></transactionPricePerShare>
20
+ </transactionAmounts>
21
+ <postTransactionAmounts>
22
+ <sharesOwnedFollowingTransaction><value>3500000</value></sharesOwnedFollowingTransaction>
23
+ </postTransactionAmounts>
24
+ </nonDerivativeTransaction>
25
+ </nonDerivativeTable>
26
+ </ownershipDocument>`;
27
+
28
+ describe("parseForm4Xml", () => {
29
+ test("parses insider sale from Form 4 XML", () => {
30
+ const result = parseForm4Xml(SAMPLE_FORM4);
31
+ expect(result).not.toBeNull();
32
+ expect(result!.reportedName).toBe("COOK TIMOTHY D");
33
+ expect(result!.title).toBe("Chief Executive Officer");
34
+ expect(result!.transactionType).toBe("S");
35
+ expect(result!.shares).toBe(50000);
36
+ expect(result!.pricePerShare).toBe(171.50);
37
+ expect(result!.totalValue).toBe(50000 * 171.50);
38
+ expect(result!.sharesOwned).toBe(3500000);
39
+ });
40
+
41
+ test("returns null for non-Form-4 XML", () => {
42
+ expect(parseForm4Xml("<html>not a form</html>")).toBeNull();
43
+ expect(parseForm4Xml("")).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe("transactionTypeLabel", () => {
48
+ test("maps codes to labels", () => {
49
+ expect(transactionTypeLabel("P")).toBe("BUY");
50
+ expect(transactionTypeLabel("S")).toBe("SELL");
51
+ expect(transactionTypeLabel("A")).toBe("AWARD");
52
+ expect(transactionTypeLabel("D")).toBe("DISPOSE");
53
+ expect(transactionTypeLabel("")).toBe("—");
54
+ });
55
+ });
@@ -0,0 +1,49 @@
1
+ export interface InsiderTransaction {
2
+ filingDate: Date;
3
+ reportedName: string;
4
+ title: string;
5
+ transactionType: "P" | "S" | "A" | "D" | "";
6
+ shares: number;
7
+ pricePerShare: number | null;
8
+ totalValue: number | null;
9
+ sharesOwned: number | null;
10
+ form: string;
11
+ }
12
+
13
+ export function parseForm4Xml(xml: string): InsiderTransaction | null {
14
+ const nameMatch = xml.match(/<rptOwnerName>([^<]+)<\/rptOwnerName>/);
15
+ if (!nameMatch) return null;
16
+
17
+ const titleMatch = xml.match(/<officerTitle>([^<]+)<\/officerTitle>/);
18
+ const codeMatch = xml.match(/<transactionCode>([^<]+)<\/transactionCode>/);
19
+ const sharesMatch = xml.match(/<transactionShares>[\s\S]*?<value>([^<]+)<\/value>/);
20
+ const priceMatch = xml.match(/<transactionPricePerShare>[\s\S]*?<value>([^<]+)<\/value>/);
21
+ const ownedMatch = xml.match(/<sharesOwnedFollowingTransaction>[\s\S]*?<value>([^<]+)<\/value>/);
22
+ const dateMatch = xml.match(/<transactionDate>[\s\S]*?<value>([^<]+)<\/value>/);
23
+
24
+ const shares = sharesMatch ? parseFloat(sharesMatch[1]!) : 0;
25
+ const price = priceMatch ? parseFloat(priceMatch[1]!) : null;
26
+ const code = codeMatch?.[1] ?? "";
27
+
28
+ return {
29
+ filingDate: dateMatch ? new Date(dateMatch[1]!) : new Date(),
30
+ reportedName: nameMatch[1]!.trim(),
31
+ title: titleMatch?.[1]?.trim() ?? "",
32
+ transactionType: (code === "P" || code === "S" || code === "A" || code === "D") ? code : "",
33
+ shares: isNaN(shares) ? 0 : shares,
34
+ pricePerShare: price !== null && !isNaN(price) ? price : null,
35
+ totalValue: price !== null && !isNaN(price) && !isNaN(shares) ? shares * price : null,
36
+ sharesOwned: ownedMatch ? parseFloat(ownedMatch[1]!) : null,
37
+ form: "4",
38
+ };
39
+ }
40
+
41
+ export function transactionTypeLabel(type: InsiderTransaction["transactionType"]): string {
42
+ switch (type) {
43
+ case "P": return "BUY";
44
+ case "S": return "SELL";
45
+ case "A": return "AWARD";
46
+ case "D": return "DISPOSE";
47
+ default: return "—";
48
+ }
49
+ }
@@ -1,8 +1,9 @@
1
1
  import { saveConfig } from "../../data/config-store";
2
2
  import { findPaneInstance, type LayoutConfig } from "../../types/config";
3
- import type { GloomPlugin, GloomPluginContext } from "../../types/plugin";
3
+ import type { AppNotificationRequest, GloomPlugin, GloomPluginContext } from "../../types/plugin";
4
4
  import type { AppAction } from "../../state/app-context";
5
5
  import { getSharedRegistry } from "../registry";
6
+ import { notifyGridlockComplete } from "../gridlock-notification";
6
7
  import {
7
8
  dockPane,
8
9
  floatPane,
@@ -54,6 +55,10 @@ export const layoutManagerPlugin: GloomPlugin = {
54
55
  description: "Pane layout management commands",
55
56
 
56
57
  setup(ctx) {
58
+ const notify = (body: string, options?: Omit<AppNotificationRequest, "body">) => {
59
+ ctx.notify({ body, ...options });
60
+ };
61
+
57
62
  ctx.registerCommand({
58
63
  id: "float-pane",
59
64
  label: "Float Pane",
@@ -68,7 +73,7 @@ export const layoutManagerPlugin: GloomPlugin = {
68
73
  const { layout, termWidth, termHeight, focusedPaneId } = getStateRef();
69
74
  const focusedPane = getFocusedPane(layout, focusedPaneId);
70
75
  if (!focusedPane || !isPaneDocked(layout, focusedPane.instanceId)) {
71
- ctx.showToast("Focus a docked pane to float it", { type: "info" });
76
+ notify("Focus a docked pane to float it", { type: "info" });
72
77
  return;
73
78
  }
74
79
 
@@ -93,7 +98,7 @@ export const layoutManagerPlugin: GloomPlugin = {
93
98
  const { layout, focusedPaneId } = getStateRef();
94
99
  const focusedPane = getFocusedPane(layout, focusedPaneId);
95
100
  if (!focusedPane || !layout.floating.some((entry) => entry.instanceId === focusedPane.instanceId)) {
96
- ctx.showToast("Focus a floating pane to dock it", { type: "info" });
101
+ notify("Focus a floating pane to dock it", { type: "info" });
97
102
  return;
98
103
  }
99
104
 
@@ -112,7 +117,7 @@ export const layoutManagerPlugin: GloomPlugin = {
112
117
  hidden: () => true,
113
118
  execute: async () => {
114
119
  ctx.openCommandBar("NP ");
115
- ctx.showToast("Choose a pane template to create", { type: "info" });
120
+ notify("Choose a pane template to create", { type: "info" });
116
121
  },
117
122
  });
118
123
 
@@ -121,12 +126,15 @@ export const layoutManagerPlugin: GloomPlugin = {
121
126
  label: "Gridlock All Windows",
122
127
  description: "Arrange all visible panes into a tiled grid",
123
128
  keywords: ["grid", "gridlock", "tile", "arrange", "windows", "layout"],
129
+ shortcut: "GL",
124
130
  category: "config",
125
131
  execute: async () => {
126
132
  if (!getStateRef) return;
127
133
  const { layout, termWidth, termHeight } = getStateRef();
128
134
  persistLayout(gridlockAllPanes(layout, { x: 0, y: 0, width: termWidth, height: termHeight }));
129
- ctx.showToast("Retiled all panes", { type: "success" });
135
+ notifyGridlockComplete(ctx.notify, () => {
136
+ dispatchRef?.({ type: "UNDO_LAYOUT" });
137
+ });
130
138
  },
131
139
  });
132
140
 
@@ -141,7 +149,7 @@ export const layoutManagerPlugin: GloomPlugin = {
141
149
  const { layout, focusedPaneId } = getStateRef();
142
150
  const focusedPane = getFocusedPane(layout, focusedPaneId);
143
151
  if (!focusedPane || !isPaneInLayout(layout, focusedPane.instanceId)) {
144
- ctx.showToast("Focus a pane to remove it", { type: "info" });
152
+ notify("Focus a pane to remove it", { type: "info" });
145
153
  return;
146
154
  }
147
155
  persistLayout(removePane(layout, focusedPane.instanceId));
@@ -158,11 +166,11 @@ export const layoutManagerPlugin: GloomPlugin = {
158
166
  execute: async (values) => {
159
167
  const name = values?.name?.trim();
160
168
  if (!name) {
161
- ctx.showToast("Layout name is required", { type: "error" });
169
+ notify("Layout name is required", { type: "error" });
162
170
  return;
163
171
  }
164
172
  dispatchRef?.({ type: "NEW_LAYOUT", name });
165
- ctx.showToast(`Layout "${name}" created`, { type: "success" });
173
+ notify(`Layout "${name}" created`, { type: "success" });
166
174
  },
167
175
  });
168
176
 
@@ -191,13 +199,13 @@ export const layoutManagerPlugin: GloomPlugin = {
191
199
  if (!registry) return;
192
200
  const config = registry.getConfigFn();
193
201
  if (config.layouts.length <= 1) {
194
- ctx.showToast("Can't delete the only layout", { type: "error" });
202
+ notify("Can't delete the only layout", { type: "error" });
195
203
  return;
196
204
  }
197
205
  const index = config.activeLayoutIndex;
198
206
  const name = config.layouts[index]!.name;
199
207
  dispatchRef?.({ type: "DELETE_LAYOUT", index });
200
- ctx.showToast(`Layout "${name}" deleted`, { type: "success" });
208
+ notify(`Layout "${name}" deleted`, { type: "success" });
201
209
  },
202
210
  });
203
211
 
@@ -211,13 +219,13 @@ export const layoutManagerPlugin: GloomPlugin = {
211
219
  execute: async (values) => {
212
220
  const name = values?.name?.trim();
213
221
  if (!name) {
214
- ctx.showToast("Name is required", { type: "error" });
222
+ notify("Name is required", { type: "error" });
215
223
  return;
216
224
  }
217
225
  const registry = getSharedRegistry();
218
226
  if (!registry) return;
219
227
  dispatchRef?.({ type: "RENAME_LAYOUT", index: registry.getConfigFn().activeLayoutIndex, name });
220
- ctx.showToast(`Layout renamed to "${name}"`, { type: "success" });
228
+ notify(`Layout renamed to "${name}"`, { type: "success" });
221
229
  },
222
230
  });
223
231
 
@@ -231,7 +239,7 @@ export const layoutManagerPlugin: GloomPlugin = {
231
239
  const registry = getSharedRegistry();
232
240
  if (!registry) return;
233
241
  dispatchRef?.({ type: "DUPLICATE_LAYOUT", index: registry.getConfigFn().activeLayoutIndex });
234
- ctx.showToast("Layout duplicated", { type: "success" });
242
+ notify("Layout duplicated", { type: "success" });
235
243
  },
236
244
  });
237
245
 
@@ -247,11 +255,11 @@ export const layoutManagerPlugin: GloomPlugin = {
247
255
  const focusedPane = getFocusedPane(layout, focusedPaneId);
248
256
  const dockedPaneIds = getDockedPaneIds(layout);
249
257
  if (dockedPaneIds.length < 2) {
250
- ctx.showToast("Need at least 2 docked panes to swap", { type: "info" });
258
+ notify("Need at least 2 docked panes to swap", { type: "info" });
251
259
  return;
252
260
  }
253
261
  if (!focusedPane || !isPaneDocked(layout, focusedPane.instanceId)) {
254
- ctx.showToast("Focus a docked pane to swap it", { type: "info" });
262
+ notify("Focus a docked pane to swap it", { type: "info" });
255
263
  return;
256
264
  }
257
265
 
@@ -262,7 +270,7 @@ export const layoutManagerPlugin: GloomPlugin = {
262
270
  }
263
271
 
264
272
  ctx.openCommandBar("LAY ");
265
- ctx.showToast("Choose a swap target from layout mode", { type: "info" });
273
+ notify("Choose a swap target from layout mode", { type: "info" });
266
274
  },
267
275
  });
268
276
  },