gloomberb 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +184 -69
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +296 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -1
  48. package/src/components/layout/header.tsx +5 -4
  49. package/src/components/layout/pane-sizing.ts +6 -0
  50. package/src/components/layout/pane.tsx +5 -1
  51. package/src/components/layout/shell.test.tsx +263 -32
  52. package/src/components/layout/shell.tsx +10 -6
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +62 -2
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +82 -5
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +92 -18
  103. package/src/plugins/builtin/chat-controller.ts +40 -11
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +420 -119
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +33 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/gateway-service.test.ts +22 -0
  166. package/src/plugins/ibkr/gateway-service.ts +6 -0
  167. package/src/plugins/ibkr/index.test.tsx +1 -1
  168. package/src/plugins/ibkr/index.tsx +8 -2
  169. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  170. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  171. package/src/plugins/ibkr/trade-utils.ts +7 -6
  172. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  173. package/src/plugins/plugin-runtime.test.tsx +2 -0
  174. package/src/plugins/plugin-runtime.tsx +10 -0
  175. package/src/plugins/prediction-markets/controller-data.ts +129 -14
  176. package/src/plugins/prediction-markets/controller.ts +1 -1
  177. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  178. package/src/plugins/prediction-markets/index.tsx +5 -1
  179. package/src/plugins/prediction-markets/pane.test.tsx +78 -0
  180. package/src/plugins/prediction-markets/pane.tsx +7 -3
  181. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  182. package/src/plugins/prediction-markets/services/fetch.ts +17 -7
  183. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  184. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  185. package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
  186. package/src/plugins/registry.ts +45 -13
  187. package/src/sources/provider-router.test.ts +3 -2
  188. package/src/sources/sec-edgar.ts +13 -1
  189. package/src/sources/yahoo-finance.ts +75 -1
  190. package/src/state/app-context-selector.test.tsx +17 -1
  191. package/src/state/app-context.test.tsx +24 -0
  192. package/src/state/app-context.tsx +29 -6
  193. package/src/theme/colors.ts +5 -0
  194. package/src/theme/themes.ts +25 -0
  195. package/src/types/config.test.ts +107 -0
  196. package/src/types/config.ts +30 -1
  197. package/src/types/data-provider.ts +13 -0
  198. package/src/types/plugin.ts +35 -1
  199. package/src/updater.test.ts +2 -0
  200. package/src/utils/api-client.ts +12 -0
  201. package/src/utils/format.test.ts +11 -1
  202. package/src/utils/format.ts +86 -8
  203. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  204. package/src/utils/inline-content-tokenizer.ts +76 -0
  205. package/src/utils/link-tokenizer.test.ts +27 -0
  206. package/src/utils/link-tokenizer.ts +93 -0
  207. package/src/utils/market-format.test.ts +109 -0
  208. package/src/utils/market-format.ts +299 -0
  209. package/src/utils/market-status.ts +3 -2
  210. package/src/utils/selection-clipboard.test.ts +43 -0
  211. package/src/utils/selection-clipboard.ts +88 -0
  212. package/src/utils/terminal-input-reset.test.ts +50 -0
  213. package/src/utils/terminal-input-reset.ts +24 -0
  214. package/src/utils/throttled-fetch.test.ts +145 -0
  215. package/src/utils/throttled-fetch.ts +212 -0
  216. package/src/utils/ticker-tokenizer.test.ts +31 -0
  217. package/src/utils/ticker-tokenizer.ts +7 -42
  218. package/src/version.ts +1 -1
@@ -10,6 +10,8 @@ import {
10
10
  import { useAppState, usePaneInstanceId, type PaneRuntimeState } from "../state/app-context";
11
11
 
12
12
  export interface PluginRuntimeAccess {
13
+ pinTicker(symbol: string, options?: { floating?: boolean; paneType?: string }): void;
14
+ navigateTicker(symbol: string): void;
13
15
  subscribeResumeState(pluginId: string, key: string, listener: () => void): () => void;
14
16
  getResumeState<T = unknown>(pluginId: string, key: string, schemaVersion?: number): T | null;
15
17
  setResumeState(pluginId: string, key: string, value: unknown, schemaVersion?: number): void;
@@ -51,6 +53,14 @@ function usePluginRenderContext(): PluginRenderContextValue {
51
53
  return context;
52
54
  }
53
55
 
56
+ export function usePluginTickerActions() {
57
+ const { runtime } = usePluginRenderContext();
58
+ return {
59
+ pinTicker: runtime.pinTicker,
60
+ navigateTicker: runtime.navigateTicker,
61
+ };
62
+ }
63
+
54
64
  export function getPluginPaneStateValue<T>(
55
65
  paneState: PaneRuntimeState | undefined,
56
66
  pluginId: string,
@@ -26,6 +26,7 @@ import { subscribePolymarketMarket } from "./services/polymarket-ws";
26
26
  import type {
27
27
  PredictionBrowseTab,
28
28
  PredictionCategoryId,
29
+ PredictionVenue,
29
30
  PredictionListRow,
30
31
  PredictionMarketDetail,
31
32
  PredictionMarketSummary,
@@ -36,6 +37,56 @@ import type {
36
37
 
37
38
  const KEYBOARD_DETAIL_LOAD_DELAY_MS = 140;
38
39
 
40
+ function formatPredictionVenueLabel(venue: PredictionVenue): string {
41
+ return venue === "polymarket" ? "Polymarket" : "Kalshi";
42
+ }
43
+
44
+ function joinPredictionVenueLabels(venues: PredictionVenue[]): string {
45
+ const labels = [...new Set(venues.map(formatPredictionVenueLabel))];
46
+ if (labels.length <= 1) return labels[0] ?? "";
47
+ return `${labels.slice(0, -1).join(" and ")} and ${labels.at(-1)}`;
48
+ }
49
+
50
+ function formatPredictionLoadError(
51
+ venue: PredictionVenue,
52
+ subject: "markets" | "market detail",
53
+ error: unknown,
54
+ ): string {
55
+ const venueLabel = formatPredictionVenueLabel(venue);
56
+ const fallback = `Could not load ${venueLabel} ${subject}.`;
57
+ if (!(error instanceof Error)) {
58
+ return fallback;
59
+ }
60
+
61
+ const message = error.message.trim();
62
+ if (message.length === 0) {
63
+ return fallback;
64
+ }
65
+
66
+ const requestFailureMatch = message.match(/Request failed \((\d+)\)/i);
67
+ if (requestFailureMatch) {
68
+ return `${venueLabel} ${subject} request failed (${requestFailureMatch[1]}).`;
69
+ }
70
+
71
+ if (
72
+ /typo in the url or port|access the url|unable to connect|could not resolve host|connection refused/i.test(
73
+ message,
74
+ )
75
+ ) {
76
+ return `${venueLabel} is unavailable right now.`;
77
+ }
78
+
79
+ if (
80
+ /socket connection|socket hang up|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed|network connection|connection closed/i.test(
81
+ message,
82
+ )
83
+ ) {
84
+ return `${venueLabel} is unavailable right now.`;
85
+ }
86
+
87
+ return `${fallback} ${message}`;
88
+ }
89
+
39
90
  export function usePredictionMarketsDataState({
40
91
  browseTab,
41
92
  categoryId,
@@ -152,14 +203,84 @@ export function usePredictionMarketsDataState({
152
203
  ].filter((value): value is string => value != null),
153
204
  [includeKalshi, includePolymarket, kalshiCatalogKey, polymarketCatalogKey],
154
205
  );
206
+ const activeCatalogSources = useMemo(
207
+ () =>
208
+ [
209
+ includePolymarket
210
+ ? {
211
+ venue: "polymarket" as const,
212
+ cacheKey: polymarketCatalogKey,
213
+ error: catalogErrors[polymarketCatalogKey] ?? null,
214
+ markets: polymarketCatalog,
215
+ }
216
+ : null,
217
+ includeKalshi
218
+ ? {
219
+ venue: "kalshi" as const,
220
+ cacheKey: kalshiCatalogKey,
221
+ error: catalogErrors[kalshiCatalogKey] ?? null,
222
+ markets: kalshiCatalog,
223
+ }
224
+ : null,
225
+ ].filter(
226
+ (
227
+ value,
228
+ ): value is {
229
+ venue: PredictionVenue;
230
+ cacheKey: string;
231
+ error: string | null;
232
+ markets: PredictionMarketSummary[];
233
+ } => value != null,
234
+ ),
235
+ [
236
+ catalogErrors,
237
+ includeKalshi,
238
+ includePolymarket,
239
+ kalshiCatalog,
240
+ kalshiCatalogKey,
241
+ polymarketCatalog,
242
+ polymarketCatalogKey,
243
+ ],
244
+ );
155
245
  const catalogLoadCount = activeCatalogKeys.reduce(
156
246
  (count, cacheKey) => count + (catalogPending[cacheKey] ?? 0),
157
247
  0,
158
248
  );
159
- const catalogError = activeCatalogKeys
160
- .map((cacheKey) => catalogErrors[cacheKey])
161
- .filter((value): value is string => !!value)
162
- .join(" ") || null;
249
+ const catalogStatus = useMemo(() => {
250
+ const failingSources = activeCatalogSources.filter((source) => !!source.error);
251
+ if (failingSources.length === 0) {
252
+ return null;
253
+ }
254
+
255
+ const loadedSources = activeCatalogSources.filter(
256
+ (source) => !source.error && source.markets.length > 0,
257
+ );
258
+ if (failingSources.length < activeCatalogSources.length) {
259
+ const unavailableVenues = joinPredictionVenueLabels(
260
+ failingSources.map((source) => source.venue),
261
+ );
262
+ if (loadedSources.length > 0) {
263
+ return {
264
+ tone: "warning" as const,
265
+ message: `${unavailableVenues} unavailable right now; showing ${joinPredictionVenueLabels(
266
+ loadedSources.map((source) => source.venue),
267
+ )} markets.`,
268
+ };
269
+ }
270
+ return {
271
+ tone: "warning" as const,
272
+ message: `${unavailableVenues} unavailable right now.`,
273
+ };
274
+ }
275
+
276
+ return {
277
+ tone: "danger" as const,
278
+ message: failingSources
279
+ .map((source) => source.error)
280
+ .filter((value): value is string => !!value)
281
+ .join(" "),
282
+ };
283
+ }, [activeCatalogSources]);
163
284
 
164
285
  const allMarkets = useMemo(() => {
165
286
  const merged: PredictionMarketSummary[] = [];
@@ -276,9 +397,7 @@ export function usePredictionMarketsDataState({
276
397
  updatePredictionErrorState(
277
398
  current,
278
399
  cacheKey,
279
- error instanceof Error
280
- ? error.message
281
- : "Could not load Polymarket markets.",
400
+ formatPredictionLoadError("polymarket", "markets", error),
282
401
  ),
283
402
  );
284
403
  } finally {
@@ -310,9 +429,7 @@ export function usePredictionMarketsDataState({
310
429
  updatePredictionErrorState(
311
430
  current,
312
431
  cacheKey,
313
- error instanceof Error
314
- ? error.message
315
- : "Could not load Kalshi markets.",
432
+ formatPredictionLoadError("kalshi", "markets", error),
316
433
  ),
317
434
  );
318
435
  } finally {
@@ -360,9 +477,7 @@ export function usePredictionMarketsDataState({
360
477
  updatePredictionErrorState(
361
478
  current,
362
479
  cacheKey,
363
- error instanceof Error
364
- ? error.message
365
- : "Could not load market detail.",
480
+ formatPredictionLoadError(summary.venue, "market detail", error),
366
481
  ),
367
482
  );
368
483
  if (currentDetailCacheKeyRef.current === cacheKey) {
@@ -605,7 +720,7 @@ export function usePredictionMarketsDataState({
605
720
  }, []);
606
721
 
607
722
  return {
608
- catalogError,
723
+ catalogStatus,
609
724
  catalogLoadCount,
610
725
  debouncedSearchQuery,
611
726
  detail,
@@ -692,7 +692,7 @@ export function usePredictionMarketsController({
692
692
  paneSettings,
693
693
  browseTab,
694
694
  categoryId,
695
- catalogError: data.catalogError,
695
+ catalogStatus: data.catalogStatus,
696
696
  catalogLoadCount: data.catalogLoadCount,
697
697
  detail: data.detail,
698
698
  detailError: data.detailError,
@@ -1,6 +1,5 @@
1
- import { TextAttributes } from "@opentui/core";
2
1
  import { colors } from "../../../theme/colors";
3
- import { openUrl } from "../../../components/ui/external-link";
2
+ import { ExternalLinkText } from "../../../components/ui/external-link";
4
3
 
5
4
  export function truncatePredictionText(
6
5
  value: string,
@@ -19,10 +18,12 @@ export function SummaryLink({
19
18
  maxLength: number;
20
19
  }) {
21
20
  return (
22
- <box height={1} onMouseDown={() => openUrl(url)}>
23
- <text fg={colors.textBright} attributes={TextAttributes.UNDERLINE}>
24
- {truncatePredictionText(url, maxLength)}
25
- </text>
21
+ <box height={1}>
22
+ <ExternalLinkText
23
+ url={url}
24
+ label={truncatePredictionText(url, maxLength)}
25
+ color={colors.textBright}
26
+ />
26
27
  </box>
27
28
  );
28
29
  }
@@ -1,7 +1,7 @@
1
1
  import type { GloomPlugin, GloomPluginContext } from "../../types/plugin";
2
2
  import { parsePredictionSearchShortcut } from "./navigation";
3
3
  import { PredictionMarketsPane } from "./pane";
4
- import { attachPredictionMarketsPersistence } from "./services/fetch";
4
+ import { attachPredictionMarketsPersistence, resetPredictionMarketsPersistence } from "./services/fetch";
5
5
  import { predictionMarketsCliCommand } from "./cli";
6
6
  import {
7
7
  buildPredictionMarketsPaneSettingsDef,
@@ -101,4 +101,8 @@ export const predictionMarketsPlugin: GloomPlugin = {
101
101
  },
102
102
  });
103
103
  },
104
+
105
+ dispose() {
106
+ resetPredictionMarketsPersistence();
107
+ },
104
108
  };
@@ -83,6 +83,57 @@ describe("prediction markets pane interactions", () => {
83
83
  expect(frame).toContain("Will the Fed cut rates?");
84
84
  });
85
85
 
86
+ test("shows a venue warning instead of a raw fetch error when one catalog fails", async () => {
87
+ attachPredictionMarketsPersistence(new MemoryPersistence());
88
+
89
+ globalThis.fetch = (async (input: Request | string | URL) => {
90
+ const url = String(input);
91
+ if (url.includes("gamma-api.polymarket.com")) {
92
+ throw new Error("Unable to connect. Was there a typo in the url or port?");
93
+ }
94
+ if (url.includes("/trade-api/v2/events?")) {
95
+ return new Response(
96
+ JSON.stringify({
97
+ events: [
98
+ {
99
+ title: "Fed series",
100
+ category: "Economics",
101
+ event_ticker: "FED-1",
102
+ series_ticker: "FED",
103
+ markets: [
104
+ {
105
+ ticker: "KAL-1",
106
+ title: "Will the Fed cut rates?",
107
+ yes_sub_title: "Yes",
108
+ event_ticker: "FED-1",
109
+ status: "open",
110
+ market_type: "binary",
111
+ last_price_dollars: "0.48",
112
+ volume_24h_fp: "15000",
113
+ volume_fp: "90000",
114
+ open_interest_fp: "45000",
115
+ },
116
+ ],
117
+ },
118
+ ],
119
+ }),
120
+ { status: 200 },
121
+ );
122
+ }
123
+ return new Response(JSON.stringify({}), { status: 200 });
124
+ }) as unknown as typeof fetch;
125
+
126
+ testSetup = await testRender(<Harness />, { width: 120, height: 34 });
127
+ await flushFrames(testSetup);
128
+
129
+ const frame = testSetup.captureCharFrame();
130
+ expect(frame).toContain("Will the Fed cut rates?");
131
+ expect(frame).toContain(
132
+ "Polymarket unavailable right now; showing Kalshi markets.",
133
+ );
134
+ expect(frame).not.toContain("Was there a typo in the url or port?");
135
+ });
136
+
86
137
  test("selects a market on single click and opens detail on double click", async () => {
87
138
  installPredictionMarketMocks();
88
139
 
@@ -127,6 +178,33 @@ describe("prediction markets pane interactions", () => {
127
178
  expect(frame).toContain("Kalshi primary rule");
128
179
  });
129
180
 
181
+ test("focuses the pane when a market row is clicked", async () => {
182
+ installPredictionMarketMocks();
183
+
184
+ testSetup = await testRender(
185
+ <Harness initialFocusedPaneId="portfolio-list:main" />,
186
+ { width: 120, height: 34 },
187
+ );
188
+ await flushFrames(testSetup);
189
+
190
+ expect(harnessStateRef.current?.focusedPaneId).toBe("portfolio-list:main");
191
+
192
+ const frame = testSetup.captureCharFrame();
193
+ const lines = frame.split("\n");
194
+ const kalshiRow = lines.findIndex((line) =>
195
+ line.includes("Will the Fed cut rates?"),
196
+ );
197
+ const kalshiCol = lines[kalshiRow]?.indexOf("Will the Fed cut rates?") ?? -1;
198
+
199
+ await act(async () => {
200
+ await testSetup!.mockMouse.click(kalshiCol + 1, kalshiRow);
201
+ await testSetup!.renderOnce();
202
+ });
203
+ await flushFrames(testSetup);
204
+
205
+ expect(harnessStateRef.current?.focusedPaneId).toBe(TEST_PANE_ID);
206
+ });
207
+
130
208
  test("loads selected detail once instead of refetching in a render loop", async () => {
131
209
  const { fetchUrls } = installPredictionMarketMocks();
132
210
 
@@ -11,6 +11,10 @@ import type { PredictionBrowseTab, PredictionCategoryId } from "./types";
11
11
 
12
12
  export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
13
13
  const controller = usePredictionMarketsController({ focused });
14
+ const catalogStatusColor =
15
+ controller.catalogStatus?.tone === "danger"
16
+ ? colors.negative
17
+ : colors.borderFocused;
14
18
 
15
19
  const browseContent = (
16
20
  <box
@@ -113,9 +117,9 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
113
117
  </scrollbox>
114
118
  ) : null}
115
119
 
116
- {controller.catalogError ? (
117
- <box height={1} paddingX={1}>
118
- <text fg={colors.negative}>{controller.catalogError}</text>
120
+ {controller.catalogStatus ? (
121
+ <box height={1} paddingX={1} width={width} backgroundColor={colors.panel}>
122
+ <text fg={catalogStatusColor}>{controller.catalogStatus.message}</text>
119
123
  </box>
120
124
  ) : null}
121
125
 
@@ -363,6 +363,53 @@ describe("prediction markets plugin registration and services", () => {
363
363
  expect(markets[0]?.marketLabel).toBe("April 30");
364
364
  });
365
365
 
366
+ test("keeps Polymarket catalog results when one page connection resets", async () => {
367
+ let resetFetchCount = 0;
368
+
369
+ globalThis.fetch = (async (input: Request | string | URL) => {
370
+ const url = String(input);
371
+ if (url.includes("gamma-api.polymarket.com/events?")) {
372
+ if (url.includes("offset=200")) {
373
+ resetFetchCount += 1;
374
+ throw Object.assign(
375
+ new Error("The socket connection was closed unexpectedly."),
376
+ { code: "ECONNRESET" },
377
+ );
378
+ }
379
+ return new Response(
380
+ JSON.stringify([
381
+ {
382
+ id: "event-stable",
383
+ title: "Stable catalog page",
384
+ tags: [{ label: "Macro", slug: "economy" }],
385
+ markets: [
386
+ {
387
+ id: "pm-stable",
388
+ question: "Will the stable page load?",
389
+ conditionId: "cond-stable",
390
+ outcomes: '["Yes","No"]',
391
+ outcomePrices: '["0.57","0.43"]',
392
+ clobTokenIds: '["yes-stable","no-stable"]',
393
+ volume24hr: 125000,
394
+ active: true,
395
+ closed: false,
396
+ },
397
+ ],
398
+ },
399
+ ]),
400
+ { status: 200 },
401
+ );
402
+ }
403
+ return new Response(JSON.stringify({}), { status: 200 });
404
+ }) as unknown as typeof fetch;
405
+
406
+ const markets = await loadPolymarketCatalog("", "all");
407
+
408
+ expect(markets).toHaveLength(1);
409
+ expect(markets[0]?.marketId).toBe("pm-stable");
410
+ expect(resetFetchCount).toBe(3);
411
+ });
412
+
366
413
  test("uses remote catalog endpoints for search and category changes", async () => {
367
414
  const { fetchUrls } = installPredictionMarketMocks();
368
415
 
@@ -420,7 +467,7 @@ describe("prediction markets plugin registration and services", () => {
420
467
  }
421
468
  if (url.includes("gamma-api.polymarket.com/events/event-1")) {
422
469
  eventFetchCount += 1;
423
- if (eventFetchCount === 1) {
470
+ if (eventFetchCount <= 3) {
424
471
  return new Response("{}", { status: 500 });
425
472
  }
426
473
  return new Response(
@@ -1,6 +1,18 @@
1
1
  import type { PluginPersistence } from "../../../types/plugin";
2
+ import { createThrottledFetch } from "../../../utils/throttled-fetch";
2
3
 
3
4
  const DEFAULT_SOURCE_KEY = "remote";
5
+ const PREDICTION_FETCH = createThrottledFetch({
6
+ requestsPerMinute: 120,
7
+ maxRetries: 2,
8
+ timeoutMs: 10_000,
9
+ backoffBaseMs: 250,
10
+ dedupeGetRequests: false,
11
+ defaultHeaders: {
12
+ Accept: "application/json",
13
+ "User-Agent": "gloomberb-prediction-markets",
14
+ },
15
+ });
4
16
 
5
17
  export const PREDICTION_CACHE_POLICIES = {
6
18
  catalog: { staleMs: 30_000, expireMs: 10 * 60_000 },
@@ -19,18 +31,16 @@ export function attachPredictionMarketsPersistence(
19
31
  predictionMarketsPersistence = persistence;
20
32
  }
21
33
 
34
+ export function resetPredictionMarketsPersistence(): void {
35
+ predictionMarketsPersistence = null;
36
+ }
37
+
22
38
  export function getPredictionMarketsPersistence(): PluginPersistence | null {
23
39
  return predictionMarketsPersistence;
24
40
  }
25
41
 
26
42
  export async function fetchJson<T>(url: string): Promise<T> {
27
- const response = await fetch(url, {
28
- signal: AbortSignal.timeout(10_000),
29
- headers: {
30
- Accept: "application/json",
31
- "User-Agent": "gloomberb-prediction-markets",
32
- },
33
- });
43
+ const response = await PREDICTION_FETCH.fetch(url);
34
44
  if (!response.ok) {
35
45
  throw new Error(`Request failed (${response.status}) for ${url}`);
36
46
  }
@@ -219,6 +219,27 @@ function buildPolymarketSearchUrl(query: string): string {
219
219
  return url.toString();
220
220
  }
221
221
 
222
+ async function loadPolymarketCatalogPages(
223
+ offsets: number[],
224
+ tagSlug?: string,
225
+ ): Promise<PolymarketEventRecord[]> {
226
+ const results = await Promise.allSettled(
227
+ offsets.map((offset) =>
228
+ fetchJson<PolymarketEventRecord[]>(
229
+ buildPolymarketCatalogUrl(offset, tagSlug),
230
+ ),
231
+ ),
232
+ );
233
+ const pages = results.flatMap((result) =>
234
+ result.status === "fulfilled" ? result.value : [],
235
+ );
236
+ if (pages.length > 0) return pages;
237
+
238
+ const rejected = results.find((result) => result.status === "rejected");
239
+ if (rejected?.status === "rejected") throw rejected.reason;
240
+ return [];
241
+ }
242
+
222
243
  export function normalizePolymarketMarket(
223
244
  record: PolymarketMarketRecord,
224
245
  options?: {
@@ -360,12 +381,11 @@ export async function loadPolymarketCatalog(
360
381
  if (categoryId !== "all") {
361
382
  const tagSlugs = getPolymarketCategoryTagSlugs(categoryId);
362
383
  const categoryPages = await Promise.all(
363
- tagSlugs.flatMap((tagSlug) =>
364
- POLYMARKET_CATEGORY_OFFSETS.map((offset) =>
365
- fetchJson<PolymarketEventRecord[]>(
366
- buildPolymarketCatalogUrl(offset, tagSlug),
367
- ).catch(() => []),
368
- ),
384
+ tagSlugs.map((tagSlug) =>
385
+ loadPolymarketCatalogPages(
386
+ POLYMARKET_CATEGORY_OFFSETS,
387
+ tagSlug,
388
+ ).catch(() => []),
369
389
  ),
370
390
  );
371
391
  const categorized = sortPolymarketMarkets(
@@ -374,13 +394,11 @@ export async function loadPolymarketCatalog(
374
394
  if (categorized.length > 0) return categorized;
375
395
  }
376
396
 
377
- const pages = await Promise.all(
378
- POLYMARKET_CATALOG_OFFSETS.map((offset) =>
379
- fetchJson<PolymarketEventRecord[]>(buildPolymarketCatalogUrl(offset)),
380
- ),
397
+ const pages = await loadPolymarketCatalogPages(
398
+ POLYMARKET_CATALOG_OFFSETS,
381
399
  );
382
400
  return sortPolymarketMarkets(
383
- flattenPolymarketEvents(pages.flat(), "", categoryId),
401
+ flattenPolymarketEvents(pages, "", categoryId),
384
402
  );
385
403
  },
386
404
  PREDICTION_CACHE_POLICIES.catalog,
@@ -1,6 +1,8 @@
1
1
  import type { PredictionBookLevel, PredictionTrade } from "../types";
2
2
  import { parseFloatSafe } from "./fetch";
3
3
 
4
+ const POLYMARKET_HEARTBEAT_MS = 10_000;
5
+
4
6
  interface PolymarketLiveCallbacks {
5
7
  onBestBidAsk?: (
6
8
  assetId: string,
@@ -38,8 +40,23 @@ export function subscribePolymarketMarket(
38
40
  let closed = false;
39
41
  let socket: WebSocket | null = null;
40
42
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
43
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
44
+
45
+ const clearHeartbeat = () => {
46
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
47
+ heartbeatTimer = null;
48
+ };
49
+
50
+ const sendHeartbeat = () => {
51
+ try {
52
+ socket?.send("PING");
53
+ } catch {
54
+ // The close handler will schedule reconnects for broken sockets.
55
+ }
56
+ };
41
57
 
42
58
  const connect = () => {
59
+ clearHeartbeat();
43
60
  socket = new WebSocket(
44
61
  "wss://ws-subscriptions-clob.polymarket.com/ws/market",
45
62
  );
@@ -51,6 +68,7 @@ export function subscribePolymarketMarket(
51
68
  custom_feature_enabled: true,
52
69
  }),
53
70
  );
71
+ heartbeatTimer = setInterval(sendHeartbeat, POLYMARKET_HEARTBEAT_MS);
54
72
  });
55
73
  socket.addEventListener("message", (event) => {
56
74
  try {
@@ -89,6 +107,7 @@ export function subscribePolymarketMarket(
89
107
  }
90
108
  });
91
109
  socket.addEventListener("close", () => {
110
+ clearHeartbeat();
92
111
  socket = null;
93
112
  if (closed) return;
94
113
  reconnectTimer = setTimeout(connect, 1_500);
@@ -103,6 +122,7 @@ export function subscribePolymarketMarket(
103
122
  return () => {
104
123
  closed = true;
105
124
  if (reconnectTimer) clearTimeout(reconnectTimer);
125
+ clearHeartbeat();
106
126
  socket?.close();
107
127
  };
108
128
  }
@@ -99,11 +99,22 @@ export class MemoryPersistence implements PluginPersistence {
99
99
 
100
100
  attachPredictionMarketsPersistence(new MemoryPersistence());
101
101
 
102
- export function createConfig(): AppConfig {
102
+ export function createConfig(options?: {
103
+ initialFocusedPaneId?: string;
104
+ }): AppConfig {
103
105
  const config = createDefaultConfig("/tmp/gloomberb-prediction-markets");
106
+ const initialFocusedPaneId = options?.initialFocusedPaneId ?? TEST_PANE_ID;
107
+ const extraInstances = initialFocusedPaneId !== TEST_PANE_ID
108
+ ? [{
109
+ instanceId: initialFocusedPaneId,
110
+ paneId: initialFocusedPaneId.split(":")[0] ?? "portfolio-list",
111
+ binding: { kind: "none" as const },
112
+ }]
113
+ : [];
104
114
  const layout = {
105
115
  dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
106
116
  instances: [
117
+ ...extraInstances,
107
118
  {
108
119
  instanceId: TEST_PANE_ID,
109
120
  paneId: "prediction-markets",
@@ -126,6 +137,8 @@ export function createRuntime(): PluginRuntimeAccess {
126
137
  const listeners = new Map<string, Set<() => void>>();
127
138
 
128
139
  return {
140
+ pinTicker() {},
141
+ navigateTicker() {},
129
142
  subscribeResumeState(pluginId, key, listener) {
130
143
  const listenerKey = `${pluginId}:${key}`;
131
144
  if (!listeners.has(listenerKey)) listeners.set(listenerKey, new Set());
@@ -424,13 +437,17 @@ export function installPredictionMarketMocks() {
424
437
  return { fetchUrls };
425
438
  }
426
439
 
427
- export function Harness() {
440
+ export function Harness({
441
+ initialFocusedPaneId = TEST_PANE_ID,
442
+ }: {
443
+ initialFocusedPaneId?: string;
444
+ } = {}) {
428
445
  const runtime = useMemo(() => createRuntime(), []);
429
446
  const [state, dispatch] = useReducer(
430
447
  appReducer,
431
448
  (() => {
432
- const initial = createInitialState(createConfig());
433
- initial.focusedPaneId = TEST_PANE_ID;
449
+ const initial = createInitialState(createConfig({ initialFocusedPaneId }));
450
+ initial.focusedPaneId = initialFocusedPaneId;
434
451
  return initial;
435
452
  })(),
436
453
  );
@@ -442,7 +459,7 @@ export function Harness() {
442
459
  <PredictionMarketsPane
443
460
  paneId={TEST_PANE_ID}
444
461
  paneType="prediction-markets"
445
- focused
462
+ focused={state.focusedPaneId === TEST_PANE_ID}
446
463
  width={120}
447
464
  height={34}
448
465
  />