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
@@ -0,0 +1,519 @@
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, GloomPluginContext, PaneProps } from "../../../types/plugin";
6
+ import type { AlertCondition, AlertRule } from "./types";
7
+ import { colors } from "../../../theme/colors";
8
+ import { getSharedRegistry } from "../../registry";
9
+ import { usePluginConfigState } from "../../plugin-runtime";
10
+ import {
11
+ createAlert,
12
+ evaluateAlert,
13
+ formatAlertDescription,
14
+ serializeAlerts,
15
+ deserializeAlerts,
16
+ } from "./alert-engine";
17
+
18
+ const ALERTS_KEY = "alerts";
19
+ const POLL_INTERVAL_MS = 30_000;
20
+
21
+ function relativeTime(ts: number): string {
22
+ const diff = Date.now() - ts;
23
+ const seconds = Math.floor(diff / 1000);
24
+ if (seconds < 60) return `${seconds}s ago`;
25
+ const minutes = Math.floor(seconds / 60);
26
+ if (minutes < 60) return `${minutes}m ago`;
27
+ const hours = Math.floor(minutes / 60);
28
+ if (hours < 24) return `${hours}h ago`;
29
+ const days = Math.floor(hours / 24);
30
+ return `${days}d ago`;
31
+ }
32
+
33
+ interface AlertCommandInput {
34
+ symbol: string;
35
+ condition: AlertCondition;
36
+ price: number;
37
+ }
38
+
39
+ function parseAlertCondition(value: string | undefined): AlertCondition | null {
40
+ const normalized = value?.trim().toLowerCase();
41
+ switch (normalized) {
42
+ case ">":
43
+ case "above":
44
+ case "over":
45
+ case "gt":
46
+ return "above";
47
+ case "<":
48
+ case "below":
49
+ case "under":
50
+ case "lt":
51
+ return "below";
52
+ case "x":
53
+ case "cross":
54
+ case "crosses":
55
+ return "crosses";
56
+ default:
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function parseAlertShortcutValues(input: string): Record<string, string> {
62
+ const parts = input.trim().split(/\s+/).filter(Boolean);
63
+ if (parts.length === 0) return {};
64
+ if (parts.length > 3) {
65
+ throw new Error("Use SA SYMBOL above|below|crosses PRICE.");
66
+ }
67
+
68
+ const values: Record<string, string> = {
69
+ symbol: parts[0]!.toUpperCase(),
70
+ };
71
+
72
+ if (parts[1]) {
73
+ const condition = parseAlertCondition(parts[1]);
74
+ if (!condition) {
75
+ throw new Error("Use above, below, crosses, >, <, or x.");
76
+ }
77
+ values.condition = condition;
78
+ }
79
+
80
+ if (parts[2]) {
81
+ const price = Number.parseFloat(parts[2]!.replace(/^\$/, ""));
82
+ if (!Number.isFinite(price)) {
83
+ throw new Error("Use a numeric target price.");
84
+ }
85
+ values.price = String(price);
86
+ }
87
+
88
+ return values;
89
+ }
90
+
91
+ function parseAlertCommandValues(values?: Record<string, string>): AlertCommandInput | null {
92
+ const shortcut = values?.shortcut?.trim();
93
+ if (shortcut) {
94
+ values = {
95
+ ...values,
96
+ ...parseAlertShortcutValues(shortcut),
97
+ };
98
+ }
99
+
100
+ const symbol = values?.symbol?.trim().toUpperCase();
101
+ const condition = parseAlertCondition(values?.condition);
102
+ const priceStr = values?.price?.trim();
103
+ if (!symbol || !condition || !priceStr) return null;
104
+ const price = Number.parseFloat(priceStr);
105
+ if (!Number.isFinite(price)) return null;
106
+ return { symbol, condition, price };
107
+ }
108
+
109
+ type AlertColumnId =
110
+ | "status"
111
+ | "symbol"
112
+ | "condition"
113
+ | "target"
114
+ | "last"
115
+ | "triggered"
116
+ | "rearm";
117
+
118
+ type AlertColumn = DataTableColumn & { id: AlertColumnId };
119
+
120
+ const ALERT_COLUMNS: AlertColumn[] = [
121
+ { id: "status", label: "Status", width: 6 },
122
+ { id: "symbol", label: "Symbol", width: 8 },
123
+ { id: "condition", label: "Condition", width: 9 },
124
+ { id: "target", label: "Target", width: 8, align: "right" },
125
+ { id: "last", label: "Last", width: 8, align: "right" },
126
+ { id: "triggered", label: "Triggered", width: 9 },
127
+ { id: "rearm", label: "", width: 6 },
128
+ ];
129
+
130
+ const ALERT_TABLE_CONTENT_WIDTH = ALERT_COLUMNS.reduce(
131
+ (sum, column) => sum + column.width + 1,
132
+ 2,
133
+ );
134
+
135
+ export function AlertsPane({ focused, width, height, close }: PaneProps) {
136
+ const [alertsJson, setAlertsJson] = usePluginConfigState<string>(ALERTS_KEY, "[]");
137
+ const [selectedIdx, setSelectedIdx] = useState(0);
138
+ const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
139
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
140
+ const headerScrollRef = useRef<ScrollBoxRenderable>(null);
141
+ const showHorizontalScrollbar = ALERT_TABLE_CONTENT_WIDTH > width;
142
+
143
+ const { alerts, rows, activeCount, triggeredCount } = useMemo(() => {
144
+ const parsed = deserializeAlerts(alertsJson);
145
+ const activeAlerts = parsed.filter((a) => a.status === "active");
146
+ const triggeredAlerts = parsed
147
+ .filter((a) => a.status === "triggered")
148
+ .sort((a, b) => (b.triggeredAt ?? 0) - (a.triggeredAt ?? 0));
149
+
150
+ return {
151
+ alerts: parsed,
152
+ rows: [...activeAlerts, ...triggeredAlerts],
153
+ activeCount: activeAlerts.length,
154
+ triggeredCount: triggeredAlerts.length,
155
+ };
156
+ }, [alertsJson]);
157
+
158
+ const saveAlerts = useCallback((next: AlertRule[]) => {
159
+ setAlertsJson(serializeAlerts(next));
160
+ }, [setAlertsJson]);
161
+
162
+ const deleteAlert = useCallback((id: string) => {
163
+ saveAlerts(alerts.filter((a) => a.id !== id));
164
+ setSelectedIdx((prev) => Math.max(0, Math.min(prev, rows.length - 2)));
165
+ }, [alerts, rows.length, saveAlerts]);
166
+
167
+ const rearmAlert = useCallback((id: string) => {
168
+ saveAlerts(
169
+ alerts.map((a) =>
170
+ a.id === id ? { ...a, status: "active" as const, triggeredAt: undefined } : a,
171
+ ),
172
+ );
173
+ }, [alerts, saveAlerts]);
174
+
175
+ const openSetAlertCommand = useCallback(() => {
176
+ getSharedRegistry()?.openPluginCommandWorkflow("set-alert");
177
+ }, []);
178
+
179
+ const deleteSelectedAlert = useCallback(() => {
180
+ const selected = rows[selectedIdx];
181
+ if (selected) deleteAlert(selected.id);
182
+ }, [deleteAlert, rows, selectedIdx]);
183
+
184
+ const syncHeaderScroll = useCallback(() => {
185
+ const body = scrollRef.current;
186
+ const header = headerScrollRef.current;
187
+ if (body && header && header.scrollLeft !== body.scrollLeft) {
188
+ header.scrollLeft = body.scrollLeft;
189
+ }
190
+ }, []);
191
+
192
+ const handleBodyScrollActivity = useCallback(() => {
193
+ syncHeaderScroll();
194
+ }, [syncHeaderScroll]);
195
+
196
+ useEffect(() => {
197
+ setSelectedIdx((prev) => (rows.length === 0 ? 0 : Math.min(prev, rows.length - 1)));
198
+ }, [rows.length]);
199
+
200
+ useKeyboard((event) => {
201
+ if (!focused) return;
202
+
203
+ if (event.name === "j" || event.name === "down") {
204
+ event.preventDefault?.();
205
+ if (rows.length > 0) {
206
+ setSelectedIdx((prev) => Math.min(prev + 1, rows.length - 1));
207
+ }
208
+ } else if (event.name === "k" || event.name === "up") {
209
+ event.preventDefault?.();
210
+ if (rows.length > 0) {
211
+ setSelectedIdx((prev) => Math.max(prev - 1, 0));
212
+ }
213
+ } else if (event.name === "d") {
214
+ event.preventDefault?.();
215
+ deleteSelectedAlert();
216
+ } else if (event.name === "return") {
217
+ event.preventDefault?.();
218
+ const selected = rows[selectedIdx];
219
+ if (selected?.status === "triggered") rearmAlert(selected.id);
220
+ } else if (event.name === "a" || event.name === "n") {
221
+ event.preventDefault?.();
222
+ openSetAlertCommand();
223
+ } else if (event.name === "escape") {
224
+ event.preventDefault?.();
225
+ close?.();
226
+ }
227
+ });
228
+
229
+ // Keep selection in scroll view
230
+ useEffect(() => {
231
+ const sb = scrollRef.current;
232
+ if (!sb?.viewport || rows.length === 0 || selectedIdx < 0) return;
233
+ const viewportHeight = Math.max(sb.viewport.height, 1);
234
+ if (selectedIdx < sb.scrollTop) {
235
+ sb.scrollTo(selectedIdx);
236
+ } else if (selectedIdx >= sb.scrollTop + viewportHeight) {
237
+ sb.scrollTo(selectedIdx - viewportHeight + 1);
238
+ }
239
+ }, [selectedIdx, rows.length]);
240
+
241
+ const renderCell = useCallback((
242
+ alert: AlertRule,
243
+ column: AlertColumn,
244
+ _index: number,
245
+ rowState: { selected: boolean; hovered: boolean },
246
+ ): DataTableCell => {
247
+ const selectedColor = rowState.selected ? colors.selectedText : undefined;
248
+ const actionMouseDown = (handler: () => void) => (event: any) => {
249
+ event.preventDefault?.();
250
+ event.stopPropagation?.();
251
+ handler();
252
+ };
253
+
254
+ switch (column.id) {
255
+ case "status":
256
+ return {
257
+ text: alert.status === "triggered" ? "Trig" : "Active",
258
+ color: selectedColor ?? (alert.status === "triggered" ? colors.positive : colors.textDim),
259
+ attributes: alert.status === "triggered" ? TextAttributes.BOLD : TextAttributes.NONE,
260
+ };
261
+ case "symbol":
262
+ return {
263
+ text: alert.symbol,
264
+ color: selectedColor ?? colors.textBright,
265
+ attributes: TextAttributes.BOLD,
266
+ };
267
+ case "condition":
268
+ return {
269
+ text: alert.condition,
270
+ color: selectedColor,
271
+ };
272
+ case "target":
273
+ return { text: String(alert.targetPrice), color: selectedColor };
274
+ case "last":
275
+ return {
276
+ text: alert.lastCheckedPrice == null ? "-" : String(alert.lastCheckedPrice),
277
+ color: selectedColor ?? colors.textDim,
278
+ };
279
+ case "triggered":
280
+ return {
281
+ text: alert.triggeredAt ? relativeTime(alert.triggeredAt) : "-",
282
+ color: selectedColor ?? colors.textDim,
283
+ };
284
+ case "rearm":
285
+ return alert.status === "triggered"
286
+ ? {
287
+ text: "Re-arm",
288
+ color: selectedColor ?? colors.textBright,
289
+ onMouseDown: actionMouseDown(() => rearmAlert(alert.id)),
290
+ }
291
+ : { text: "-", color: selectedColor ?? colors.textDim };
292
+ }
293
+ }, [rearmAlert]);
294
+
295
+ return (
296
+ <box
297
+ flexDirection="column"
298
+ width={width}
299
+ height={height}
300
+ backgroundColor={colors.bg}
301
+ >
302
+ <box
303
+ flexDirection="row"
304
+ height={1}
305
+ paddingX={1}
306
+ backgroundColor={colors.bg}
307
+ >
308
+ <text fg={colors.textDim}>{activeCount} active</text>
309
+ {triggeredCount > 0 && (
310
+ <box marginLeft={1}>
311
+ <text fg={colors.textMuted}>{triggeredCount} triggered</text>
312
+ </box>
313
+ )}
314
+ </box>
315
+
316
+ <DataTable<AlertRule, AlertColumn>
317
+ columns={ALERT_COLUMNS}
318
+ items={rows}
319
+ sortColumnId={null}
320
+ sortDirection="asc"
321
+ onHeaderClick={() => {}}
322
+ headerScrollRef={headerScrollRef}
323
+ scrollRef={scrollRef}
324
+ syncHeaderScroll={syncHeaderScroll}
325
+ onBodyScrollActivity={handleBodyScrollActivity}
326
+ hoveredIdx={hoveredIdx}
327
+ setHoveredIdx={setHoveredIdx}
328
+ getItemKey={(alert) => alert.id}
329
+ isSelected={(_alert, index) => index === selectedIdx}
330
+ onSelect={(_alert, index) => setSelectedIdx(index)}
331
+ onActivate={(alert) => {
332
+ if (alert.status === "triggered") rearmAlert(alert.id);
333
+ }}
334
+ renderCell={renderCell}
335
+ emptyStateTitle="No alerts"
336
+ emptyStateHint="Use the action bar to create one."
337
+ showHorizontalScrollbar={showHorizontalScrollbar}
338
+ />
339
+
340
+ <box
341
+ flexDirection="row"
342
+ height={1}
343
+ paddingX={1}
344
+ backgroundColor={colors.panel}
345
+ >
346
+ <box
347
+ onMouseDown={(event: any) => {
348
+ event.preventDefault?.();
349
+ event.stopPropagation?.();
350
+ openSetAlertCommand();
351
+ }}
352
+ >
353
+ <text fg={colors.textDim}>[a]dd alert</text>
354
+ </box>
355
+ <box width={2} />
356
+ <box
357
+ onMouseDown={(event: any) => {
358
+ event.preventDefault?.();
359
+ event.stopPropagation?.();
360
+ deleteSelectedAlert();
361
+ }}
362
+ >
363
+ <text fg={rows.length > 0 ? colors.textDim : colors.textMuted}>[d]elete</text>
364
+ </box>
365
+ </box>
366
+ </box>
367
+ );
368
+ }
369
+
370
+ let pollInterval: ReturnType<typeof setInterval> | null = null;
371
+
372
+ function loadAlerts(ctx: GloomPluginContext): AlertRule[] {
373
+ const json = ctx.configState.get<string>(ALERTS_KEY);
374
+ if (!json) return [];
375
+ return deserializeAlerts(json);
376
+ }
377
+
378
+ function saveAlerts(ctx: GloomPluginContext, alerts: AlertRule[]): void {
379
+ ctx.configState.set(ALERTS_KEY, serializeAlerts(alerts));
380
+ }
381
+
382
+ export const alertsPlugin: GloomPlugin = {
383
+ id: "alerts",
384
+ name: "Alerts",
385
+ version: "1.0.0",
386
+ description: "Price trigger alerts with desktop notifications",
387
+ toggleable: true,
388
+
389
+ setup(ctx) {
390
+ ctx.registerCommand({
391
+ id: "set-alert",
392
+ label: "Add Alert",
393
+ description: "Create a price alert from a symbol, condition, and target price",
394
+ keywords: ["add", "set", "alert", "price", "trigger", "notify", "alarm", "watch"],
395
+ category: "data",
396
+ shortcut: "SA",
397
+ shortcutArg: {
398
+ placeholder: "symbol condition price",
399
+ kind: "text",
400
+ parse: parseAlertShortcutValues,
401
+ },
402
+ wizardLayout: "form",
403
+ wizard: [
404
+ {
405
+ key: "symbol",
406
+ label: "Symbol",
407
+ placeholder: "AAPL",
408
+ type: "text",
409
+ },
410
+ {
411
+ key: "condition",
412
+ label: "Condition",
413
+ type: "select",
414
+ options: [
415
+ { label: "Above", value: "above" },
416
+ { label: "Below", value: "below" },
417
+ { label: "Crosses", value: "crosses" },
418
+ ],
419
+ },
420
+ {
421
+ key: "price",
422
+ label: "Target Price",
423
+ placeholder: "200.00",
424
+ type: "number",
425
+ },
426
+ ],
427
+ async execute(values) {
428
+ const input = parseAlertCommandValues(values);
429
+ if (!input) return;
430
+
431
+ const alert = createAlert(input.symbol, input.condition, input.price);
432
+ const existing = loadAlerts(ctx);
433
+ existing.push(alert);
434
+ saveAlerts(ctx, existing);
435
+
436
+ ctx.notify({
437
+ body: `Alert set: ${formatAlertDescription(alert)}`,
438
+ type: "info",
439
+ });
440
+ },
441
+ });
442
+
443
+ // Polling engine
444
+ const poll = async () => {
445
+ const alerts = loadAlerts(ctx);
446
+ if (alerts.length === 0) return;
447
+
448
+ const activeAlerts = alerts.filter((a) => a.status === "active");
449
+ if (activeAlerts.length === 0) return;
450
+
451
+ ctx.log.info("poll", { total: alerts.length, active: activeAlerts.length });
452
+
453
+ let changed = false;
454
+ for (const alert of alerts) {
455
+ if (alert.status !== "active") continue;
456
+ try {
457
+ const quote = await ctx.dataProvider.getQuote(alert.symbol, "");
458
+ if (!quote || typeof quote.price !== "number") {
459
+ ctx.log.warn("poll: no quote", { symbol: alert.symbol });
460
+ continue;
461
+ }
462
+
463
+ const triggered = evaluateAlert(alert, quote.price);
464
+
465
+ if (triggered) {
466
+ alert.status = "triggered";
467
+ alert.triggeredAt = Date.now();
468
+ ctx.log.info("poll: TRIGGERED", { symbol: alert.symbol, price: quote.price });
469
+ ctx.notify({
470
+ body: `${formatAlertDescription(alert)} triggered at ${quote.price}`,
471
+ type: "success",
472
+ desktop: "always",
473
+ persistent: true,
474
+ sound: "Glass",
475
+ });
476
+ changed = true;
477
+ }
478
+ alert.lastCheckedPrice = quote.price;
479
+ changed = true;
480
+ } catch (err) {
481
+ ctx.log.error("poll: error", { symbol: alert.symbol, error: String(err) });
482
+ }
483
+ }
484
+
485
+ if (changed) {
486
+ saveAlerts(ctx, alerts);
487
+ }
488
+ };
489
+
490
+ poll();
491
+ pollInterval = setInterval(poll, POLL_INTERVAL_MS);
492
+
493
+ ctx.registerPane({
494
+ id: "alerts",
495
+ name: "Alerts",
496
+ icon: "A",
497
+ component: AlertsPane,
498
+ defaultPosition: "right",
499
+ defaultMode: "floating",
500
+ defaultFloatingSize: { width: 65, height: 20 },
501
+ });
502
+
503
+ ctx.registerPaneTemplate({
504
+ id: "alerts-pane",
505
+ paneId: "alerts",
506
+ label: "Alerts",
507
+ description: "Price trigger alerts with notifications",
508
+ keywords: ["alerts", "price", "trigger", "alarm", "watch", "notify"],
509
+ shortcut: { prefix: "ALRT" },
510
+ });
511
+ },
512
+
513
+ dispose() {
514
+ if (pollInterval) {
515
+ clearInterval(pollInterval);
516
+ pollInterval = null;
517
+ }
518
+ },
519
+ };
@@ -0,0 +1,14 @@
1
+ export type AlertCondition = "above" | "below" | "crosses";
2
+ export type AlertStatus = "active" | "triggered" | "expired";
3
+
4
+ export interface AlertRule {
5
+ id: string;
6
+ symbol: string;
7
+ condition: AlertCondition;
8
+ targetPrice: number;
9
+ createdAt: number;
10
+ status: AlertStatus;
11
+ triggeredAt?: number;
12
+ lastCheckedPrice?: number;
13
+ message?: string;
14
+ }