gloomberb 0.3.7 → 0.4.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 (143) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +45 -69
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +363 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +4 -2
  37. package/src/components/layout/header.tsx +3 -3
  38. package/src/components/layout/pane-header.tsx +26 -2
  39. package/src/components/layout/pane-sizing.ts +6 -0
  40. package/src/components/layout/pane.tsx +5 -1
  41. package/src/components/layout/shell.test.tsx +77 -5
  42. package/src/components/layout/shell.tsx +67 -52
  43. package/src/components/layout/status-bar.test.tsx +66 -0
  44. package/src/components/layout/status-bar.tsx +26 -5
  45. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  46. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  47. package/src/components/ticker-list-table.test.tsx +83 -0
  48. package/src/components/ticker-list-table.tsx +181 -97
  49. package/src/components/ui/data-table.tsx +50 -5
  50. package/src/components/ui/index.ts +1 -0
  51. package/src/components/ui/page-stack-view.tsx +60 -0
  52. package/src/components/use-double-click-activation.ts +31 -0
  53. package/src/index.tsx +10 -10
  54. package/src/market-data/coordinator.test.ts +92 -4
  55. package/src/market-data/coordinator.ts +32 -5
  56. package/src/market-data/hooks.test.tsx +120 -0
  57. package/src/market-data/hooks.tsx +53 -16
  58. package/src/market-data/request-types.ts +4 -2
  59. package/src/market-data/selectors.ts +24 -58
  60. package/src/plugins/builtin/chat-controller.test.ts +400 -3
  61. package/src/plugins/builtin/chat-controller.ts +328 -35
  62. package/src/plugins/builtin/chat.test.tsx +118 -6
  63. package/src/plugins/builtin/chat.tsx +180 -116
  64. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  65. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  66. package/src/plugins/builtin/layout-manager.tsx +9 -0
  67. package/src/plugins/builtin/news.tsx +1 -11
  68. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  69. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  70. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  71. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  72. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  73. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  74. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  75. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  76. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  77. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  78. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  79. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  80. package/src/plugins/builtin/sec.tsx +1 -11
  81. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  82. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  83. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  84. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  85. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  86. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  87. package/src/plugins/catalog.ts +65 -0
  88. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  89. package/src/plugins/ibkr/gateway-service.test.ts +67 -1
  90. package/src/plugins/ibkr/gateway-service.ts +147 -7
  91. package/src/plugins/ibkr/index.tsx +7 -1
  92. package/src/plugins/pane-manager.test.ts +1 -1
  93. package/src/plugins/plugin-runtime.tsx +27 -7
  94. package/src/plugins/prediction-markets/cli.ts +28 -0
  95. package/src/plugins/prediction-markets/controller-data.ts +132 -17
  96. package/src/plugins/prediction-markets/controller.ts +142 -122
  97. package/src/plugins/prediction-markets/index.tsx +7 -1
  98. package/src/plugins/prediction-markets/launch.ts +51 -16
  99. package/src/plugins/prediction-markets/metrics.ts +9 -5
  100. package/src/plugins/prediction-markets/pane.test.tsx +197 -57
  101. package/src/plugins/prediction-markets/pane.tsx +80 -155
  102. package/src/plugins/prediction-markets/services/fetch.ts +4 -0
  103. package/src/plugins/prediction-markets/table.tsx +6 -3
  104. package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/plugins/registry.ts +23 -0
  107. package/src/sources/gloomberb-cloud.test.ts +29 -0
  108. package/src/sources/gloomberb-cloud.ts +89 -1
  109. package/src/sources/provider-router.test.ts +265 -3
  110. package/src/sources/provider-router.ts +239 -11
  111. package/src/sources/yahoo-finance.test.ts +26 -0
  112. package/src/sources/yahoo-finance.ts +54 -3
  113. package/src/state/app-context-selector.test.tsx +99 -0
  114. package/src/state/app-context.test.ts +18 -0
  115. package/src/state/app-context.tsx +170 -65
  116. package/src/state/selectors.ts +25 -0
  117. package/src/theme/colors.ts +8 -8
  118. package/src/theme/themes.test.ts +2 -0
  119. package/src/theme/themes.ts +22 -10
  120. package/src/types/broker.ts +21 -0
  121. package/src/types/config.ts +17 -1
  122. package/src/types/data-provider.ts +18 -0
  123. package/src/types/financials.ts +31 -1
  124. package/src/types/plugin.ts +70 -0
  125. package/src/updater.test.ts +2 -0
  126. package/src/utils/api-client.test.ts +57 -0
  127. package/src/utils/api-client.ts +47 -12
  128. package/src/utils/back-navigation.ts +27 -0
  129. package/src/utils/format.test.ts +14 -0
  130. package/src/utils/format.ts +3 -3
  131. package/src/utils/market-status.ts +46 -1
  132. package/src/utils/quote-freshness.ts +127 -0
  133. package/src/utils/quote-resolution.test.ts +277 -0
  134. package/src/utils/quote-resolution.ts +641 -0
  135. package/src/utils/slugify.ts +3 -0
  136. package/src/utils/timestamp.ts +25 -0
  137. package/src/version.ts +1 -1
  138. package/src/cli/commands/portfolio.ts +0 -210
  139. package/src/cli/commands/watchlist.ts +0 -210
  140. package/src/plugins/builtin/data-providers.ts +0 -16
  141. package/src/sources/exchange-rates.ts +0 -18
  142. package/src/utils/ascii-chart.ts +0 -172
  143. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -25,6 +25,11 @@ import { tmpdir } from "os";
25
25
  import { join } from "path";
26
26
  import { firstValueFrom, filter, take, timeout } from "rxjs";
27
27
  import type { TimeRange } from "../../components/chart/chart-types";
28
+ import {
29
+ normalizeChartResolutionSupport,
30
+ type ChartResolutionSupport,
31
+ type ManualChartResolution,
32
+ } from "../../components/chart/chart-resolution";
28
33
  import type { BrokerConnectionStatus, BrokerPosition } from "../../types/broker";
29
34
  import type { QuoteSubscriptionTarget } from "../../types/data-provider";
30
35
  import type { Fundamentals, FinancialStatement, Quote, PricePoint, TickerFinancials } from "../../types/financials";
@@ -290,6 +295,7 @@ const IBKR_DATA_TIMEOUT = 8_000;
290
295
  const IBKR_QUOTE_STREAM_TICKS = "165,221,233";
291
296
 
292
297
  const HISTORY_PARAMS: Record<TimeRange, { duration: string; size: BarSizeSetting }> = {
298
+ "1D": { duration: "1 D", size: BarSizeSetting.MINUTES_FIVE },
293
299
  "1W": { duration: "7 D", size: BarSizeSetting.HOURS_ONE },
294
300
  "1M": { duration: "1 M", size: BarSizeSetting.HOURS_ONE },
295
301
  "3M": { duration: "3 M", size: BarSizeSetting.DAYS_ONE },
@@ -299,6 +305,17 @@ const HISTORY_PARAMS: Record<TimeRange, { duration: string; size: BarSizeSetting
299
305
  "ALL": { duration: "10 Y", size: BarSizeSetting.MONTHS_ONE },
300
306
  };
301
307
 
308
+ const IBKR_RESOLUTION_SUPPORT = normalizeChartResolutionSupport([
309
+ { resolution: "1m", maxRange: "1W" },
310
+ { resolution: "5m", maxRange: "1M" },
311
+ { resolution: "15m", maxRange: "3M" },
312
+ { resolution: "30m", maxRange: "6M" },
313
+ { resolution: "1h", maxRange: "1Y" },
314
+ { resolution: "1d", maxRange: "ALL" },
315
+ { resolution: "1wk", maxRange: "ALL" },
316
+ { resolution: "1mo", maxRange: "ALL" },
317
+ ]);
318
+
302
319
  /** Map generic bar size labels to IBKR BarSizeSetting values. */
303
320
  const GENERIC_BAR_SIZE_MAP: Record<string, BarSizeSetting> = {
304
321
  "1m": BarSizeSetting.MINUTES_ONE,
@@ -307,7 +324,8 @@ const GENERIC_BAR_SIZE_MAP: Record<string, BarSizeSetting> = {
307
324
  "30m": BarSizeSetting.MINUTES_THIRTY,
308
325
  "1h": BarSizeSetting.HOURS_ONE,
309
326
  "1d": BarSizeSetting.DAYS_ONE,
310
- "1w": BarSizeSetting.WEEKS_ONE,
327
+ "1wk": BarSizeSetting.WEEKS_ONE,
328
+ "1mo": BarSizeSetting.MONTHS_ONE,
311
329
  };
312
330
 
313
331
  export function parseIbkrHistoricalBarTime(value: string | number): Date {
@@ -383,6 +401,65 @@ function hasDelayedMarketData(
383
401
  ].some((tickType) => marketData.has(tickType));
384
402
  }
385
403
 
404
+ function firstNonSmartExchange(value?: string): string | undefined {
405
+ return value
406
+ ?.split(",")
407
+ .map((entry) => entry.trim())
408
+ .find((entry) => entry.length > 0 && entry !== "SMART");
409
+ }
410
+
411
+ function resolveIbkrListingExchange(
412
+ current: Quote | undefined,
413
+ contract: Contract,
414
+ details: ContractDetails,
415
+ ): string | undefined {
416
+ return current?.listingExchangeName
417
+ ?? contract.primaryExch
418
+ ?? firstNonSmartExchange(details.validExchanges)
419
+ ?? (current?.exchangeName !== "SMART" ? current?.exchangeName : undefined)
420
+ ?? (contract.exchange !== "SMART" ? contract.exchange : undefined);
421
+ }
422
+
423
+ function resolveIbkrRoutingExchange(current: Quote | undefined, contract: Contract): string | undefined {
424
+ return current?.routingExchangeName
425
+ ?? contract.exchange
426
+ ?? "SMART";
427
+ }
428
+
429
+ function ibkrVenueFields(
430
+ current: Quote | undefined,
431
+ contract: Contract,
432
+ details: ContractDetails,
433
+ ): Pick<
434
+ Quote,
435
+ | "exchangeName"
436
+ | "fullExchangeName"
437
+ | "listingExchangeName"
438
+ | "listingExchangeFullName"
439
+ | "routingExchangeName"
440
+ | "routingExchangeFullName"
441
+ | "marketState"
442
+ | "sessionConfidence"
443
+ > {
444
+ const listingExchangeName = resolveIbkrListingExchange(current, contract, details);
445
+ const listingExchangeFullName = current?.listingExchangeFullName
446
+ ?? current?.fullExchangeName
447
+ ?? listingExchangeName;
448
+ const routingExchangeName = resolveIbkrRoutingExchange(current, contract);
449
+ const routingExchangeFullName = current?.routingExchangeFullName ?? routingExchangeName;
450
+
451
+ return {
452
+ exchangeName: listingExchangeName,
453
+ fullExchangeName: listingExchangeFullName,
454
+ listingExchangeName,
455
+ listingExchangeFullName,
456
+ routingExchangeName,
457
+ routingExchangeFullName,
458
+ marketState: current?.marketState,
459
+ sessionConfidence: current?.sessionConfidence ?? "unknown",
460
+ };
461
+ }
462
+
386
463
  interface TickByTickBidAskUpdate {
387
464
  time: number;
388
465
  bidPrice: number;
@@ -417,9 +494,7 @@ export function applyTickByTickAllLastToQuote(
417
494
  volume: current?.volume,
418
495
  name: current?.name ?? details.longName ?? details.marketName ?? contract.symbol,
419
496
  lastUpdated: tick.time ? tick.time * 1000 : Date.now(),
420
- exchangeName: current?.exchangeName ?? details.validExchanges?.split(",")[0],
421
- fullExchangeName: current?.fullExchangeName ?? details.validExchanges?.split(",")[0],
422
- marketState: current?.marketState ?? "REGULAR",
497
+ ...ibkrVenueFields(current, contract, details),
423
498
  preMarketPrice: current?.preMarketPrice,
424
499
  preMarketChange: current?.preMarketChange,
425
500
  preMarketChangePercent: current?.preMarketChangePercent,
@@ -1052,6 +1127,67 @@ export class IbkrGatewayService {
1052
1127
  });
1053
1128
  }
1054
1129
 
1130
+ getChartResolutionSupport(
1131
+ _ticker: string,
1132
+ _config: IbkrGatewayConfig,
1133
+ _exchange?: string,
1134
+ _instrument?: BrokerContractRef | null,
1135
+ ): ChartResolutionSupport[] {
1136
+ return IBKR_RESOLUTION_SUPPORT;
1137
+ }
1138
+
1139
+ getChartResolutionCapabilities(
1140
+ _ticker: string,
1141
+ _config: IbkrGatewayConfig,
1142
+ _exchange?: string,
1143
+ _instrument?: BrokerContractRef | null,
1144
+ ): ManualChartResolution[] {
1145
+ return IBKR_RESOLUTION_SUPPORT.map((entry) => entry.resolution);
1146
+ }
1147
+
1148
+ async getPriceHistoryForResolution(
1149
+ ticker: string,
1150
+ config: IbkrGatewayConfig,
1151
+ exchange: string,
1152
+ bufferRange: TimeRange,
1153
+ resolution: ManualChartResolution,
1154
+ instrument?: BrokerContractRef | null,
1155
+ ): Promise<PricePoint[]> {
1156
+ const ibkrBarSize = GENERIC_BAR_SIZE_MAP[resolution];
1157
+ if (!ibkrBarSize) return [];
1158
+
1159
+ return this.dedup(`history:${ticker}:${exchange}:${bufferRange}:${resolution}`, async () => {
1160
+ await this.connect(config);
1161
+ const contract = await withTimeout(this.resolveContract(ticker, exchange, instrument ?? null), IBKR_DATA_TIMEOUT, "resolveContract");
1162
+ const params = HISTORY_PARAMS[bufferRange];
1163
+ const detailsPromise = withTimeout(this.getPrimaryContractDetails(contract), IBKR_DATA_TIMEOUT, "getContractDetails")
1164
+ .catch(() => undefined);
1165
+ const bars = await this.withMarketDataFallback(
1166
+ config,
1167
+ () => withTimeout(this.api!.getHistoricalData(
1168
+ contract,
1169
+ "",
1170
+ params.duration,
1171
+ ibkrBarSize,
1172
+ WhatToShow.TRADES,
1173
+ 1,
1174
+ 1,
1175
+ ), IBKR_DATA_TIMEOUT, "getHistoricalData"),
1176
+ );
1177
+ const details = await detailsPromise;
1178
+ const priceDivisor = getIbkrPriceDivisor(contract, details);
1179
+
1180
+ return bars.map((bar) => ({
1181
+ date: parseIbkrHistoricalBarTime(bar.time),
1182
+ open: normalizeIbkrPriceValue(bar.open, priceDivisor),
1183
+ high: normalizeIbkrPriceValue(bar.high, priceDivisor),
1184
+ low: normalizeIbkrPriceValue(bar.low, priceDivisor),
1185
+ close: normalizeIbkrPriceValue(bar.close ?? bar.open ?? bar.high ?? bar.low ?? 0, priceDivisor) ?? 0,
1186
+ volume: bar.volume,
1187
+ }));
1188
+ });
1189
+ }
1190
+
1055
1191
  async getDetailedPriceHistory(
1056
1192
  ticker: string,
1057
1193
  config: IbkrGatewayConfig,
@@ -2150,9 +2286,7 @@ export class IbkrGatewayService {
2150
2286
  volume: marketData.get(IBApiTickType.VOLUME)?.value ?? marketData.get(IBApiTickType.DELAYED_VOLUME)?.value,
2151
2287
  name: details.longName || details.marketName || contract.symbol,
2152
2288
  lastUpdated: ingressTm,
2153
- exchangeName: details.validExchanges?.split(",")[0],
2154
- fullExchangeName: details.validExchanges?.split(",")[0],
2155
- marketState: "REGULAR",
2289
+ ...ibkrVenueFields(undefined, contract, details),
2156
2290
  bid: normalizeIbkrPriceValue(
2157
2291
  marketData.get(IBApiTickType.BID)?.value ?? marketData.get(IBApiTickType.DELAYED_BID)?.value,
2158
2292
  priceDivisor,
@@ -2259,6 +2393,12 @@ export class IbkrGatewayServiceManager {
2259
2393
  await service.disconnect();
2260
2394
  this.services.delete(instanceId);
2261
2395
  }
2396
+
2397
+ async destroyAll(): Promise<void> {
2398
+ const services = [...this.services.values()];
2399
+ this.services.clear();
2400
+ await Promise.allSettled(services.map((service) => service.disconnect()));
2401
+ }
2262
2402
  }
2263
2403
 
2264
2404
  export const ibkrGatewayManager = new IbkrGatewayServiceManager();
@@ -1,7 +1,7 @@
1
1
  import type { GloomPlugin, GloomPluginContext } from "../../types/plugin";
2
2
  import { ibkrBroker } from "./broker-adapter";
3
3
  import { buildPersistedIbkrGatewayConfig } from "./config";
4
- import { setResolvedIbkrGatewayListener } from "./gateway-service";
4
+ import { ibkrGatewayManager, setResolvedIbkrGatewayListener } from "./gateway-service";
5
5
  import {
6
6
  getTradeTicketState,
7
7
  getTradingPaneState,
@@ -150,4 +150,10 @@ export const ibkrPlugin: GloomPlugin = {
150
150
  },
151
151
  });
152
152
  },
153
+
154
+ dispose() {
155
+ lastSelectedTickerSymbol = null;
156
+ setResolvedIbkrGatewayListener(null);
157
+ void ibkrGatewayManager.destroyAll().catch(() => {});
158
+ },
153
159
  };
@@ -71,7 +71,7 @@ describe("pane-manager split-tree drops", () => {
71
71
  const next = gridlockAllPanes(layout);
72
72
 
73
73
  expect(next.floating).toHaveLength(0);
74
- expect(getDockedPaneIds(next)).toHaveLength(4);
74
+ expect(getDockedPaneIds(next)).toHaveLength(5);
75
75
  });
76
76
 
77
77
  test("gridlock infers a matching tiled layout from arranged windows", () => {
@@ -2,6 +2,7 @@ import {
2
2
  createContext,
3
3
  useCallback,
4
4
  useContext,
5
+ useRef,
5
6
  useSyncExternalStore,
6
7
  type ReactNode,
7
8
  type SetStateAction,
@@ -101,12 +102,21 @@ export function usePluginPaneState<T>(key: string, fallback: T, paneId?: string)
101
102
  const { pluginId } = usePluginRenderContext();
102
103
  const scopedPaneId = paneId ?? usePaneInstanceId();
103
104
  const { state, dispatch } = useAppState();
105
+ const stateRef = useRef(state);
106
+ const fallbackRef = useRef(fallback);
107
+ stateRef.current = state;
108
+ fallbackRef.current = fallback;
104
109
  const paneState = state.paneState[scopedPaneId];
105
110
  const value = getPluginPaneStateValue(paneState, pluginId, key, fallback);
106
111
 
107
112
  const setValue = useCallback((nextValue: SetStateAction<T>) => {
108
- const currentPaneState = state.paneState[scopedPaneId];
109
- const currentValue = getPluginPaneStateValue(currentPaneState, pluginId, key, fallback);
113
+ const currentPaneState = stateRef.current.paneState[scopedPaneId];
114
+ const currentValue = getPluginPaneStateValue(
115
+ currentPaneState,
116
+ pluginId,
117
+ key,
118
+ fallbackRef.current,
119
+ );
110
120
  const resolved = typeof nextValue === "function"
111
121
  ? (nextValue as (previousValue: T) => T)(currentValue)
112
122
  : nextValue;
@@ -122,7 +132,7 @@ export function usePluginPaneState<T>(key: string, fallback: T, paneId?: string)
122
132
  key,
123
133
  value: resolved,
124
134
  });
125
- }, [dispatch, fallback, key, pluginId, scopedPaneId, state.paneState]);
135
+ }, [dispatch, key, pluginId, scopedPaneId]);
126
136
 
127
137
  return [value, setValue];
128
138
  }
@@ -130,6 +140,8 @@ export function usePluginPaneState<T>(key: string, fallback: T, paneId?: string)
130
140
  export function usePluginState<T>(key: string, fallback: T, options?: { schemaVersion?: number }): [T, (value: SetStateAction<T>) => void] {
131
141
  const { pluginId, runtime } = usePluginRenderContext();
132
142
  const schemaVersion = options?.schemaVersion;
143
+ const fallbackRef = useRef(fallback);
144
+ fallbackRef.current = fallback;
133
145
 
134
146
  const subscribe = useCallback((listener: () => void) => (
135
147
  runtime.subscribeResumeState(pluginId, key, listener)
@@ -142,7 +154,9 @@ export function usePluginState<T>(key: string, fallback: T, options?: { schemaVe
142
154
  const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
143
155
 
144
156
  const setValue = useCallback((nextValue: SetStateAction<T>) => {
145
- const currentValue = runtime.getResumeState<T>(pluginId, key, schemaVersion) ?? fallback;
157
+ const currentValue =
158
+ runtime.getResumeState<T>(pluginId, key, schemaVersion) ??
159
+ fallbackRef.current;
146
160
  const resolved = typeof nextValue === "function"
147
161
  ? (nextValue as (previousValue: T) => T)(currentValue)
148
162
  : nextValue;
@@ -150,7 +164,7 @@ export function usePluginState<T>(key: string, fallback: T, options?: { schemaVe
150
164
  return;
151
165
  }
152
166
  runtime.setResumeState(pluginId, key, resolved, schemaVersion);
153
- }, [fallback, key, pluginId, runtime, schemaVersion]);
167
+ }, [key, pluginId, runtime, schemaVersion]);
154
168
 
155
169
  return [value, setValue];
156
170
  }
@@ -158,10 +172,16 @@ export function usePluginState<T>(key: string, fallback: T, options?: { schemaVe
158
172
  export function usePluginConfigState<T>(key: string, fallback: T): [T, (value: SetStateAction<T>) => void] {
159
173
  const { pluginId, runtime } = usePluginRenderContext();
160
174
  const { state } = useAppState();
175
+ const stateRef = useRef(state);
176
+ const fallbackRef = useRef(fallback);
177
+ stateRef.current = state;
178
+ fallbackRef.current = fallback;
161
179
  const value = (state.config.pluginConfig[pluginId]?.[key] as T | undefined) ?? fallback;
162
180
 
163
181
  const setValue = useCallback((nextValue: SetStateAction<T>) => {
164
- const currentValue = (state.config.pluginConfig[pluginId]?.[key] as T | undefined) ?? fallback;
182
+ const currentValue =
183
+ (stateRef.current.config.pluginConfig[pluginId]?.[key] as T | undefined) ??
184
+ fallbackRef.current;
165
185
  const resolved = typeof nextValue === "function"
166
186
  ? (nextValue as (previousValue: T) => T)(currentValue)
167
187
  : nextValue;
@@ -169,7 +189,7 @@ export function usePluginConfigState<T>(key: string, fallback: T): [T, (value: S
169
189
  return;
170
190
  }
171
191
  void runtime.setConfigState(pluginId, key, resolved);
172
- }, [fallback, key, pluginId, runtime, state.config.pluginConfig]);
192
+ }, [key, pluginId, runtime]);
173
193
 
174
194
  return [value, setValue];
175
195
  }
@@ -0,0 +1,28 @@
1
+ import type { CliCommandDef } from "../../types/plugin";
2
+ import { createPredictionLaunchRequest, parsePredictionCommandArgs } from "./launch";
3
+
4
+ export const predictionMarketsCliCommand: CliCommandDef = {
5
+ name: "predictions",
6
+ aliases: ["prediction-markets", "pm"],
7
+ description: "Launch the UI with Prediction Markets focused",
8
+ help: {
9
+ usage: ["predictions [...]"],
10
+ sections: [{
11
+ title: "Prediction Launch",
12
+ columns: [
13
+ { header: "Input" },
14
+ { header: "Example" },
15
+ ],
16
+ rows: [
17
+ ["gloomberb predictions [venue] [category] [browse-tab] [search...]", "gloomberb predictions world"],
18
+ ["venue", "all | polymarket | kalshi"],
19
+ ["category", "all | politics | world | macro | crypto | science | sports | entertainment | climate | social"],
20
+ ["browse-tab", "top | ending | new | watchlist"],
21
+ ],
22
+ }],
23
+ },
24
+ execute: async (args) => ({
25
+ kind: "launch-ui",
26
+ request: createPredictionLaunchRequest(parsePredictionCommandArgs(args)),
27
+ }),
28
+ };
@@ -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,9 +37,52 @@ 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
+ return `${fallback} ${message}`;
80
+ }
81
+
39
82
  export function usePredictionMarketsDataState({
40
83
  browseTab,
41
84
  categoryId,
85
+ detailOpen,
42
86
  effectiveVenueScope,
43
87
  focused,
44
88
  historyRange,
@@ -52,6 +96,7 @@ export function usePredictionMarketsDataState({
52
96
  }: {
53
97
  browseTab: PredictionBrowseTab;
54
98
  categoryId: PredictionCategoryId;
99
+ detailOpen: boolean;
55
100
  effectiveVenueScope: PredictionVenueScope;
56
101
  focused: boolean;
57
102
  historyRange: "1D" | "1W" | "1M" | "ALL";
@@ -150,14 +195,84 @@ export function usePredictionMarketsDataState({
150
195
  ].filter((value): value is string => value != null),
151
196
  [includeKalshi, includePolymarket, kalshiCatalogKey, polymarketCatalogKey],
152
197
  );
198
+ const activeCatalogSources = useMemo(
199
+ () =>
200
+ [
201
+ includePolymarket
202
+ ? {
203
+ venue: "polymarket" as const,
204
+ cacheKey: polymarketCatalogKey,
205
+ error: catalogErrors[polymarketCatalogKey] ?? null,
206
+ markets: polymarketCatalog,
207
+ }
208
+ : null,
209
+ includeKalshi
210
+ ? {
211
+ venue: "kalshi" as const,
212
+ cacheKey: kalshiCatalogKey,
213
+ error: catalogErrors[kalshiCatalogKey] ?? null,
214
+ markets: kalshiCatalog,
215
+ }
216
+ : null,
217
+ ].filter(
218
+ (
219
+ value,
220
+ ): value is {
221
+ venue: PredictionVenue;
222
+ cacheKey: string;
223
+ error: string | null;
224
+ markets: PredictionMarketSummary[];
225
+ } => value != null,
226
+ ),
227
+ [
228
+ catalogErrors,
229
+ includeKalshi,
230
+ includePolymarket,
231
+ kalshiCatalog,
232
+ kalshiCatalogKey,
233
+ polymarketCatalog,
234
+ polymarketCatalogKey,
235
+ ],
236
+ );
153
237
  const catalogLoadCount = activeCatalogKeys.reduce(
154
238
  (count, cacheKey) => count + (catalogPending[cacheKey] ?? 0),
155
239
  0,
156
240
  );
157
- const catalogError = activeCatalogKeys
158
- .map((cacheKey) => catalogErrors[cacheKey])
159
- .filter((value): value is string => !!value)
160
- .join(" ") || null;
241
+ const catalogStatus = useMemo(() => {
242
+ const failingSources = activeCatalogSources.filter((source) => !!source.error);
243
+ if (failingSources.length === 0) {
244
+ return null;
245
+ }
246
+
247
+ const loadedSources = activeCatalogSources.filter(
248
+ (source) => !source.error && source.markets.length > 0,
249
+ );
250
+ if (failingSources.length < activeCatalogSources.length) {
251
+ const unavailableVenues = joinPredictionVenueLabels(
252
+ failingSources.map((source) => source.venue),
253
+ );
254
+ if (loadedSources.length > 0) {
255
+ return {
256
+ tone: "warning" as const,
257
+ message: `${unavailableVenues} unavailable right now; showing ${joinPredictionVenueLabels(
258
+ loadedSources.map((source) => source.venue),
259
+ )} markets.`,
260
+ };
261
+ }
262
+ return {
263
+ tone: "warning" as const,
264
+ message: `${unavailableVenues} unavailable right now.`,
265
+ };
266
+ }
267
+
268
+ return {
269
+ tone: "danger" as const,
270
+ message: failingSources
271
+ .map((source) => source.error)
272
+ .filter((value): value is string => !!value)
273
+ .join(" "),
274
+ };
275
+ }, [activeCatalogSources]);
161
276
 
162
277
  const allMarkets = useMemo(() => {
163
278
  const merged: PredictionMarketSummary[] = [];
@@ -201,7 +316,11 @@ export function usePredictionMarketsDataState({
201
316
  [selectedRowKey, visibleRows],
202
317
  );
203
318
  const selectedSummary = useMemo(
204
- () =>
319
+ () => {
320
+ if (!detailOpen || !selectedRow) {
321
+ return null;
322
+ }
323
+ return (
205
324
  selectedRow?.markets.find(
206
325
  (market) => market.key === selectedDetailMarketKey,
207
326
  ) ??
@@ -209,8 +328,10 @@ export function usePredictionMarketsDataState({
209
328
  (market) => market.key === selectedRow.focusMarketKey,
210
329
  ) ??
211
330
  selectedRow?.representative ??
212
- null,
213
- [selectedDetailMarketKey, selectedRow],
331
+ null
332
+ );
333
+ },
334
+ [detailOpen, selectedDetailMarketKey, selectedRow],
214
335
  );
215
336
  const selectedSummaryKey = selectedSummary?.key ?? null;
216
337
  const selectedSummaryVenue = selectedSummary?.venue ?? null;
@@ -268,9 +389,7 @@ export function usePredictionMarketsDataState({
268
389
  updatePredictionErrorState(
269
390
  current,
270
391
  cacheKey,
271
- error instanceof Error
272
- ? error.message
273
- : "Could not load Polymarket markets.",
392
+ formatPredictionLoadError("polymarket", "markets", error),
274
393
  ),
275
394
  );
276
395
  } finally {
@@ -302,9 +421,7 @@ export function usePredictionMarketsDataState({
302
421
  updatePredictionErrorState(
303
422
  current,
304
423
  cacheKey,
305
- error instanceof Error
306
- ? error.message
307
- : "Could not load Kalshi markets.",
424
+ formatPredictionLoadError("kalshi", "markets", error),
308
425
  ),
309
426
  );
310
427
  } finally {
@@ -352,9 +469,7 @@ export function usePredictionMarketsDataState({
352
469
  updatePredictionErrorState(
353
470
  current,
354
471
  cacheKey,
355
- error instanceof Error
356
- ? error.message
357
- : "Could not load market detail.",
472
+ formatPredictionLoadError(summary.venue, "market detail", error),
358
473
  ),
359
474
  );
360
475
  if (currentDetailCacheKeyRef.current === cacheKey) {
@@ -597,7 +712,7 @@ export function usePredictionMarketsDataState({
597
712
  }, []);
598
713
 
599
714
  return {
600
- catalogError,
715
+ catalogStatus,
601
716
  catalogLoadCount,
602
717
  debouncedSearchQuery,
603
718
  detail,