gloomberb 0.3.3 → 0.4.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 (143) hide show
  1. package/README.md +27 -16
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  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-renderer.ts +7 -2
  17. package/src/components/chart/chart-resolution.test.ts +56 -0
  18. package/src/components/chart/chart-resolution.ts +362 -0
  19. package/src/components/chart/chart-types.ts +12 -4
  20. package/src/components/chart/chart-viewport.test.ts +25 -0
  21. package/src/components/chart/chart-viewport.ts +28 -0
  22. package/src/components/chart/chart.test.ts +26 -0
  23. package/src/components/chart/comparison-chart-data.ts +20 -27
  24. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  25. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  26. package/src/components/chart/stock-chart.tsx +484 -112
  27. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  28. package/src/components/command-bar/command-bar.test.tsx +650 -5
  29. package/src/components/command-bar/command-bar.tsx +359 -63
  30. package/src/components/command-bar/command-registry.ts +8 -0
  31. package/src/components/command-bar/helpers.test.ts +23 -1
  32. package/src/components/command-bar/helpers.ts +14 -5
  33. package/src/components/command-bar/pane-template-display.ts +12 -0
  34. package/src/components/command-bar/root-shortcuts.ts +2 -1
  35. package/src/components/command-bar/workflow-ops.ts +4 -2
  36. package/src/components/detail-feed-view.test.tsx +124 -49
  37. package/src/components/detail-feed-view.tsx +396 -160
  38. package/src/components/layout/floating-pane.tsx +1 -1
  39. package/src/components/layout/pane-header.tsx +26 -2
  40. package/src/components/layout/shell.tsx +62 -50
  41. package/src/components/layout/status-bar.test.tsx +66 -0
  42. package/src/components/layout/status-bar.tsx +26 -5
  43. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  44. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  45. package/src/components/ticker-list-table.test.tsx +83 -0
  46. package/src/components/ticker-list-table.tsx +174 -97
  47. package/src/components/ui/data-table.tsx +27 -2
  48. package/src/components/ui/index.ts +1 -0
  49. package/src/components/ui/page-stack-view.tsx +60 -0
  50. package/src/components/use-double-click-activation.ts +31 -0
  51. package/src/index.tsx +10 -10
  52. package/src/market-data/coordinator.test.ts +92 -4
  53. package/src/market-data/coordinator.ts +32 -5
  54. package/src/market-data/hooks.test.tsx +120 -0
  55. package/src/market-data/hooks.tsx +53 -16
  56. package/src/market-data/request-types.ts +4 -2
  57. package/src/market-data/selectors.ts +24 -58
  58. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  59. package/src/plugins/builtin/chat-controller.ts +310 -35
  60. package/src/plugins/builtin/chat.test.tsx +118 -6
  61. package/src/plugins/builtin/chat.tsx +175 -116
  62. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  63. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  64. package/src/plugins/builtin/news.tsx +1 -11
  65. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  66. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  67. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  68. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  69. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  70. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  71. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  72. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  73. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  74. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  75. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  76. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  77. package/src/plugins/builtin/sec.tsx +1 -11
  78. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  79. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  80. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  81. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  82. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  83. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  84. package/src/plugins/catalog.ts +65 -0
  85. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  86. package/src/plugins/ibkr/gateway-service.test.ts +75 -1
  87. package/src/plugins/ibkr/gateway-service.ts +179 -9
  88. package/src/plugins/pane-manager.test.ts +1 -1
  89. package/src/plugins/plugin-runtime.tsx +27 -7
  90. package/src/plugins/prediction-markets/chart.test.tsx +139 -0
  91. package/src/plugins/prediction-markets/chart.tsx +65 -436
  92. package/src/plugins/prediction-markets/cli.ts +28 -0
  93. package/src/plugins/prediction-markets/controller-data.ts +17 -5
  94. package/src/plugins/prediction-markets/controller.ts +141 -121
  95. package/src/plugins/prediction-markets/detail/outcomes.tsx +2 -6
  96. package/src/plugins/prediction-markets/detail/pane.tsx +4 -25
  97. package/src/plugins/prediction-markets/detail/shared.tsx +0 -1
  98. package/src/plugins/prediction-markets/detail.test.tsx +4 -0
  99. package/src/plugins/prediction-markets/index.tsx +2 -0
  100. package/src/plugins/prediction-markets/launch.ts +51 -16
  101. package/src/plugins/prediction-markets/metrics.ts +9 -5
  102. package/src/plugins/prediction-markets/pane.test.tsx +160 -58
  103. package/src/plugins/prediction-markets/pane.tsx +74 -183
  104. package/src/plugins/prediction-markets/table.tsx +6 -3
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/sources/gloomberb-cloud.test.ts +29 -0
  107. package/src/sources/gloomberb-cloud.ts +89 -1
  108. package/src/sources/provider-router.test.ts +308 -3
  109. package/src/sources/provider-router.ts +247 -17
  110. package/src/sources/yahoo-finance.test.ts +26 -0
  111. package/src/sources/yahoo-finance.ts +54 -3
  112. package/src/state/app-context-selector.test.tsx +99 -0
  113. package/src/state/app-context.test.ts +18 -0
  114. package/src/state/app-context.tsx +170 -65
  115. package/src/state/selectors.ts +25 -0
  116. package/src/theme/colors.ts +8 -8
  117. package/src/theme/themes.test.ts +2 -0
  118. package/src/theme/themes.ts +22 -10
  119. package/src/types/broker.ts +21 -0
  120. package/src/types/config.ts +17 -1
  121. package/src/types/data-provider.ts +18 -0
  122. package/src/types/financials.ts +31 -1
  123. package/src/types/plugin.ts +70 -0
  124. package/src/utils/api-client.test.ts +57 -0
  125. package/src/utils/api-client.ts +35 -12
  126. package/src/utils/back-navigation.ts +27 -0
  127. package/src/utils/format.test.ts +14 -0
  128. package/src/utils/format.ts +3 -3
  129. package/src/utils/market-status.ts +46 -1
  130. package/src/utils/price-history.test.ts +25 -0
  131. package/src/utils/price-history.ts +29 -5
  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,9 +324,46 @@ 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
 
331
+ export function parseIbkrHistoricalBarTime(value: string | number): Date {
332
+ if (typeof value === "number") {
333
+ if (!Number.isFinite(value)) return new Date(Number.NaN);
334
+
335
+ const compactDate = String(Math.trunc(value));
336
+ if (/^\d{8}$/.test(compactDate)) {
337
+ return parseIbkrHistoricalBarTime(compactDate);
338
+ }
339
+
340
+ return new Date(value > 10_000_000_000 ? value : value * 1000);
341
+ }
342
+
343
+ const trimmed = value.trim();
344
+ if (!trimmed) return new Date(Number.NaN);
345
+
346
+ const parsed = Date.parse(trimmed);
347
+ if (Number.isFinite(parsed)) {
348
+ return new Date(parsed);
349
+ }
350
+
351
+ const compactMatch = trimmed.match(/^(\d{4})(\d{2})(\d{2})(?:\D+(\d{2}):(\d{2}):(\d{2}))?/);
352
+ if (!compactMatch) {
353
+ return new Date(Number.NaN);
354
+ }
355
+
356
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText] = compactMatch;
357
+ const year = Number(yearText);
358
+ const month = Number(monthText) - 1;
359
+ const day = Number(dayText);
360
+ const hour = Number(hourText ?? "0");
361
+ const minute = Number(minuteText ?? "0");
362
+ const second = Number(secondText ?? "0");
363
+
364
+ return new Date(year, month, day, hour, minute, second);
365
+ }
366
+
313
367
  function normalizeQuoteStreamTarget(target: QuoteSubscriptionTarget): QuoteSubscriptionTarget | null {
314
368
  const symbol = target.symbol.trim().toUpperCase();
315
369
  if (!symbol) return null;
@@ -347,6 +401,65 @@ function hasDelayedMarketData(
347
401
  ].some((tickType) => marketData.has(tickType));
348
402
  }
349
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
+
350
463
  interface TickByTickBidAskUpdate {
351
464
  time: number;
352
465
  bidPrice: number;
@@ -381,9 +494,7 @@ export function applyTickByTickAllLastToQuote(
381
494
  volume: current?.volume,
382
495
  name: current?.name ?? details.longName ?? details.marketName ?? contract.symbol,
383
496
  lastUpdated: tick.time ? tick.time * 1000 : Date.now(),
384
- exchangeName: current?.exchangeName ?? details.validExchanges?.split(",")[0],
385
- fullExchangeName: current?.fullExchangeName ?? details.validExchanges?.split(",")[0],
386
- marketState: current?.marketState ?? "REGULAR",
497
+ ...ibkrVenueFields(current, contract, details),
387
498
  preMarketPrice: current?.preMarketPrice,
388
499
  preMarketChange: current?.preMarketChange,
389
500
  preMarketChangePercent: current?.preMarketChangePercent,
@@ -1006,7 +1117,68 @@ export class IbkrGatewayService {
1006
1117
  const priceDivisor = getIbkrPriceDivisor(contract, details);
1007
1118
 
1008
1119
  return bars.map((bar) => ({
1009
- date: new Date(typeof bar.time === "number" ? bar.time * 1000 : Date.parse(String(bar.time))),
1120
+ date: parseIbkrHistoricalBarTime(bar.time),
1121
+ open: normalizeIbkrPriceValue(bar.open, priceDivisor),
1122
+ high: normalizeIbkrPriceValue(bar.high, priceDivisor),
1123
+ low: normalizeIbkrPriceValue(bar.low, priceDivisor),
1124
+ close: normalizeIbkrPriceValue(bar.close ?? bar.open ?? bar.high ?? bar.low ?? 0, priceDivisor) ?? 0,
1125
+ volume: bar.volume,
1126
+ }));
1127
+ });
1128
+ }
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),
1010
1182
  open: normalizeIbkrPriceValue(bar.open, priceDivisor),
1011
1183
  high: normalizeIbkrPriceValue(bar.high, priceDivisor),
1012
1184
  low: normalizeIbkrPriceValue(bar.low, priceDivisor),
@@ -1064,7 +1236,7 @@ export class IbkrGatewayService {
1064
1236
  const priceDivisor = getIbkrPriceDivisor(contract, details);
1065
1237
 
1066
1238
  return bars.map((bar) => ({
1067
- date: new Date(typeof bar.time === "number" ? bar.time * 1000 : Date.parse(String(bar.time))),
1239
+ date: parseIbkrHistoricalBarTime(bar.time),
1068
1240
  open: normalizeIbkrPriceValue(bar.open, priceDivisor),
1069
1241
  high: normalizeIbkrPriceValue(bar.high, priceDivisor),
1070
1242
  low: normalizeIbkrPriceValue(bar.low, priceDivisor),
@@ -2114,9 +2286,7 @@ export class IbkrGatewayService {
2114
2286
  volume: marketData.get(IBApiTickType.VOLUME)?.value ?? marketData.get(IBApiTickType.DELAYED_VOLUME)?.value,
2115
2287
  name: details.longName || details.marketName || contract.symbol,
2116
2288
  lastUpdated: ingressTm,
2117
- exchangeName: details.validExchanges?.split(",")[0],
2118
- fullExchangeName: details.validExchanges?.split(",")[0],
2119
- marketState: "REGULAR",
2289
+ ...ibkrVenueFields(undefined, contract, details),
2120
2290
  bid: normalizeIbkrPriceValue(
2121
2291
  marketData.get(IBApiTickType.BID)?.value ?? marketData.get(IBApiTickType.DELAYED_BID)?.value,
2122
2292
  priceDivisor,
@@ -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,139 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { ScrollBoxRenderable } from "@opentui/core";
3
+ import { createTestRenderer } from "@opentui/core/testing";
4
+ import { createRoot } from "@opentui/react";
5
+ import { act, useEffect, useReducer, useRef } from "react";
6
+ import {
7
+ AppContext,
8
+ PaneInstanceProvider,
9
+ appReducer,
10
+ createInitialState,
11
+ } from "../../state/app-context";
12
+ import { createDefaultConfig } from "../../types/config";
13
+ import { getNativeSurfaceManager } from "../../components/chart/native/surface-manager";
14
+ import { PredictionMarketChart } from "./chart";
15
+
16
+ const TEST_PANE_ID = "prediction-scroll:test";
17
+ const SURFACE_ID = `chart-surface:${TEST_PANE_ID}:compact:base`;
18
+
19
+ let testSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined;
20
+ let root: ReturnType<typeof createRoot> | undefined;
21
+ let scrollBoxRef: ScrollBoxRenderable | null = null;
22
+ const actEnvironment = globalThis as typeof globalThis & {
23
+ IS_REACT_ACT_ENVIRONMENT?: boolean;
24
+ };
25
+
26
+ function ChartScrollHarness() {
27
+ const [state, dispatch] = useReducer(
28
+ appReducer,
29
+ (() => {
30
+ const config = createDefaultConfig("/tmp/gloomberb-test");
31
+ config.chartPreferences.renderer = "kitty";
32
+ const initial = createInitialState(config);
33
+ initial.focusedPaneId = TEST_PANE_ID;
34
+ return initial;
35
+ })(),
36
+ );
37
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
38
+
39
+ useEffect(() => {
40
+ scrollBoxRef = scrollRef.current;
41
+ });
42
+
43
+ return (
44
+ <AppContext value={{ state, dispatch }}>
45
+ <PaneInstanceProvider paneId={TEST_PANE_ID}>
46
+ <scrollbox ref={scrollRef} height={10} scrollY>
47
+ <box flexDirection="column">
48
+ <box height={14}>
49
+ <text>filler</text>
50
+ </box>
51
+ <PredictionMarketChart
52
+ history={[
53
+ { date: new Date("2026-04-01T00:00:00Z"), close: 0.45 },
54
+ { date: new Date("2026-04-02T00:00:00Z"), close: 0.48 },
55
+ { date: new Date("2026-04-03T00:00:00Z"), close: 0.51 },
56
+ { date: new Date("2026-04-04T00:00:00Z"), close: 0.49 },
57
+ ]}
58
+ width={60}
59
+ height={12}
60
+ range="1M"
61
+ onRangeSelect={() => {}}
62
+ />
63
+ </box>
64
+ </scrollbox>
65
+ </PaneInstanceProvider>
66
+ </AppContext>
67
+ );
68
+ }
69
+
70
+ async function flushFrames(count = 4) {
71
+ for (let index = 0; index < count; index += 1) {
72
+ await act(async () => {
73
+ await new Promise((resolve) => setTimeout(resolve, 0));
74
+ await Promise.resolve();
75
+ await testSetup!.renderOnce();
76
+ });
77
+ }
78
+ }
79
+
80
+ afterEach(() => {
81
+ scrollBoxRef = null;
82
+ if (root) {
83
+ act(() => {
84
+ root!.unmount();
85
+ });
86
+ root = undefined;
87
+ }
88
+ if (testSetup) {
89
+ testSetup.renderer.destroy();
90
+ testSetup = undefined;
91
+ }
92
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = false;
93
+ });
94
+
95
+ describe("PredictionMarketChart kitty scrolling", () => {
96
+ test("creates a native chart surface when scrolled into view", async () => {
97
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
98
+ testSetup = await createTestRenderer({ width: 100, height: 24 });
99
+ (testSetup.renderer as { _capabilities: unknown })._capabilities = {
100
+ kitty_graphics: true,
101
+ };
102
+ (testSetup.renderer as { _resolution: unknown })._resolution = {
103
+ width: 1000,
104
+ height: 720,
105
+ };
106
+
107
+ root = createRoot(testSetup.renderer);
108
+ act(() => {
109
+ root!.render(<ChartScrollHarness />);
110
+ });
111
+
112
+ await flushFrames();
113
+
114
+ const manager = getNativeSurfaceManager(testSetup.renderer as never) as unknown as {
115
+ surfaces: Map<
116
+ string,
117
+ {
118
+ snapshot: {
119
+ visibleRect: { x: number; y: number; width: number; height: number } | null;
120
+ };
121
+ }
122
+ >;
123
+ };
124
+
125
+ const hiddenSurface = manager.surfaces.get(SURFACE_ID);
126
+ expect(hiddenSurface).toBeUndefined();
127
+
128
+ act(() => {
129
+ scrollBoxRef!.scrollTop = 14;
130
+ });
131
+
132
+ await flushFrames();
133
+
134
+ const visibleSurface = manager.surfaces.get(SURFACE_ID);
135
+ expect(visibleSurface).toBeDefined();
136
+ expect(visibleSurface?.snapshot.visibleRect).not.toBeNull();
137
+ expect(testSetup.captureCharFrame()).toContain("1M");
138
+ });
139
+ });