gloomberb 0.12.0 → 0.13.2

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 (219) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -2
  3. package/src/api-client/auth.ts +2 -1
  4. package/src/api-client/index.test.ts +34 -0
  5. package/src/api-client/index.ts +10 -2
  6. package/src/api-client/request.ts +4 -1
  7. package/src/api-client/research-activity.ts +61 -0
  8. package/src/api-client/types.ts +2 -1
  9. package/src/cli/commands/plugins.ts +4 -3
  10. package/src/cli/desktop-pane-shot.ts +79 -10
  11. package/src/cli/pane-functions/capabilities.ts +76 -23
  12. package/src/cli/pane-functions/cloud-session.test.ts +54 -0
  13. package/src/cli/pane-functions/cloud-session.ts +38 -0
  14. package/src/cli/pane-functions/dom.test.ts +41 -0
  15. package/src/cli/pane-functions/dom.ts +199 -0
  16. package/src/cli/pane-functions/index.test.ts +13 -2
  17. package/src/cli/pane-functions/index.ts +9 -5
  18. package/src/cli/pane-functions/intraday-shot.test.ts +307 -0
  19. package/src/cli/pane-functions/intraday-shot.ts +334 -0
  20. package/src/cli/pane-functions/report.test.ts +29 -1
  21. package/src/cli/pane-functions/report.ts +68 -7
  22. package/src/cli/pane-functions/screenshot.ts +195 -42
  23. package/src/cli/restore-plugins.ts +10 -3
  24. package/src/components/chart/composite/composite-chart.test.tsx +68 -1
  25. package/src/components/chart/composite/composite-chart.tsx +87 -21
  26. package/src/components/chart/composite/rasterizer.ts +33 -0
  27. package/src/components/chart/composite/renderers.test.ts +51 -4
  28. package/src/components/chart/composite/scene.test.ts +128 -2
  29. package/src/components/chart/composite/scene.ts +92 -16
  30. package/src/components/chart/composite/text-renderer.ts +13 -0
  31. package/src/components/chart/composite/time-scale.ts +30 -10
  32. package/src/components/chart/composite/types.ts +26 -0
  33. package/src/components/command-bar/assist/runtime.ts +38 -30
  34. package/src/components/command-bar/surface/assist.test.tsx +63 -9
  35. package/src/components/command-bar/surface/test-harness.tsx +23 -4
  36. package/src/components/onboarding/account-step/chooser-panel.tsx +2 -2
  37. package/src/components/onboarding/account-step/qr-panel.tsx +2 -10
  38. package/src/components/onboarding/onboarding-wizard.test.tsx +38 -12
  39. package/src/components/onboarding/onboarding-wizard.tsx +49 -14
  40. package/src/data/config/store/index.test.ts +23 -0
  41. package/src/data/config/store/node.ts +7 -1
  42. package/src/data/config/store/normalize.ts +4 -0
  43. package/src/news/types.ts +1 -0
  44. package/src/plugins/builtin/cds/client.test.ts +20 -0
  45. package/src/plugins/builtin/cds/client.ts +13 -3
  46. package/src/plugins/builtin/cds/headless.test.ts +30 -0
  47. package/src/plugins/builtin/cds/headless.ts +113 -0
  48. package/src/plugins/builtin/cds/index.tsx +4 -0
  49. package/src/plugins/builtin/chart-composer/cli-options.ts +4 -1
  50. package/src/plugins/builtin/chart-composer/index.tsx +1 -1
  51. package/src/plugins/builtin/chat/controller/channels.ts +2 -1
  52. package/src/plugins/builtin/chat/controller/index.ts +13 -5
  53. package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
  54. package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
  55. package/src/plugins/builtin/chat/controller/state.ts +1 -0
  56. package/src/plugins/builtin/chat/controller.test.ts +260 -4
  57. package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
  58. package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
  59. package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
  60. package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
  61. package/src/plugins/builtin/cloud/askg/protocol.test.ts +190 -0
  62. package/src/plugins/builtin/cloud/askg/protocol.ts +286 -0
  63. package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
  64. package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
  65. package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
  66. package/src/plugins/builtin/cloud/auth-dialog.tsx +7 -1
  67. package/src/plugins/builtin/cloud/device-signin-dialog.tsx +21 -8
  68. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  69. package/src/plugins/builtin/cloud/verification-panel.tsx +56 -0
  70. package/src/plugins/builtin/congress-trades/client.ts +11 -0
  71. package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
  72. package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
  73. package/src/plugins/builtin/congress-trades/headless.ts +145 -0
  74. package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
  75. package/src/plugins/builtin/credit-conditions/client.ts +1 -1
  76. package/src/plugins/builtin/credit-conditions/headless.test.ts +31 -0
  77. package/src/plugins/builtin/credit-conditions/headless.ts +55 -0
  78. package/src/plugins/builtin/credit-conditions/index.tsx +4 -0
  79. package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
  80. package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
  81. package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
  82. package/src/plugins/builtin/dividend-yield/model.ts +4 -22
  83. package/src/plugins/builtin/dividend-yield/view.ts +17 -0
  84. package/src/plugins/builtin/earnings/headless.test.ts +82 -0
  85. package/src/plugins/builtin/earnings/headless.ts +162 -0
  86. package/src/plugins/builtin/earnings/index.tsx +2 -0
  87. package/src/plugins/builtin/earnings-calls/data.ts +29 -8
  88. package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
  89. package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
  90. package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
  91. package/src/plugins/builtin/earnings-calls/model.ts +238 -0
  92. package/src/plugins/builtin/earnings-calls/pane.tsx +4 -0
  93. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +13 -11
  94. package/src/plugins/builtin/fear-greed/headless.test.ts +53 -0
  95. package/src/plugins/builtin/fear-greed/headless.ts +84 -0
  96. package/src/plugins/builtin/fear-greed/index.tsx +4 -0
  97. package/src/plugins/builtin/holders/client.ts +29 -0
  98. package/src/plugins/builtin/holders/headless.test.ts +58 -0
  99. package/src/plugins/builtin/holders/headless.ts +162 -0
  100. package/src/plugins/builtin/holders/index.tsx +14 -8
  101. package/src/plugins/builtin/holders/pane.tsx +7 -1
  102. package/src/plugins/builtin/insider/client.ts +50 -0
  103. package/src/plugins/builtin/insider/headless.test.ts +122 -0
  104. package/src/plugins/builtin/insider/headless.ts +117 -0
  105. package/src/plugins/builtin/insider/index.tsx +21 -50
  106. package/src/plugins/builtin/insider/insider-data.ts +1 -1
  107. package/src/plugins/builtin/insider/model.ts +72 -0
  108. package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
  109. package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
  110. package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
  111. package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
  112. package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
  113. package/src/plugins/builtin/market-movers/client.ts +76 -0
  114. package/src/plugins/builtin/market-movers/headless.test.ts +34 -0
  115. package/src/plugins/builtin/market-movers/headless.ts +73 -0
  116. package/src/plugins/builtin/market-movers/index.tsx +11 -51
  117. package/src/plugins/builtin/news/headless.test.ts +138 -0
  118. package/src/plugins/builtin/news/headless.ts +190 -0
  119. package/src/plugins/builtin/news/index.tsx +13 -9
  120. package/src/plugins/builtin/news/wire/index.tsx +2 -1
  121. package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
  122. package/src/plugins/builtin/research/analyst-headless.ts +172 -0
  123. package/src/plugins/builtin/research/analyst-model.ts +219 -0
  124. package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
  125. package/src/plugins/builtin/research/client.ts +12 -0
  126. package/src/plugins/builtin/research/corporate-actions-pane.tsx +3 -237
  127. package/src/plugins/builtin/research/event-model.ts +245 -0
  128. package/src/plugins/builtin/research/events-headless.test.ts +32 -0
  129. package/src/plugins/builtin/research/events-headless.ts +87 -0
  130. package/src/plugins/builtin/research/headless.test.ts +93 -0
  131. package/src/plugins/builtin/research/headless.ts +161 -0
  132. package/src/plugins/builtin/research/index.tsx +32 -18
  133. package/src/plugins/builtin/research-search/pane.tsx +4 -0
  134. package/src/plugins/builtin/scanner/headless.test.ts +59 -0
  135. package/src/plugins/builtin/scanner/headless.ts +125 -0
  136. package/src/plugins/builtin/scanner/index.ts +4 -0
  137. package/src/plugins/builtin/sec/client.ts +16 -0
  138. package/src/plugins/builtin/sec/headless.test.ts +72 -0
  139. package/src/plugins/builtin/sec/headless.ts +69 -0
  140. package/src/plugins/builtin/sec/index.tsx +20 -75
  141. package/src/plugins/builtin/sec/model.ts +89 -0
  142. package/src/plugins/builtin/sectors/client.ts +54 -0
  143. package/src/plugins/builtin/sectors/headless.test.ts +28 -0
  144. package/src/plugins/builtin/sectors/headless.ts +80 -0
  145. package/src/plugins/builtin/sectors/index.tsx +6 -51
  146. package/src/plugins/builtin/shared/cloud-upgrade.ts +8 -4
  147. package/src/plugins/builtin/short-interest/client.ts +5 -2
  148. package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
  149. package/src/plugins/builtin/short-interest/headless.ts +111 -0
  150. package/src/plugins/builtin/short-interest/index.tsx +15 -9
  151. package/src/plugins/builtin/thirteenf/data.ts +13 -11
  152. package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
  153. package/src/plugins/builtin/thirteenf/headless.ts +286 -0
  154. package/src/plugins/builtin/thirteenf/index.tsx +4 -0
  155. package/src/plugins/builtin/thirteenf/model.ts +13 -0
  156. package/src/plugins/builtin/thirteenf/table.ts +3 -15
  157. package/src/plugins/builtin/ticker-detail/pane.tsx +14 -0
  158. package/src/plugins/builtin/treasury-auctions/headless.test.ts +34 -0
  159. package/src/plugins/builtin/treasury-auctions/headless.ts +95 -0
  160. package/src/plugins/builtin/treasury-auctions/index.tsx +4 -0
  161. package/src/plugins/builtin/volatility/client.ts +16 -3
  162. package/src/plugins/builtin/volatility/headless.test.ts +35 -0
  163. package/src/plugins/builtin/volatility/headless.ts +75 -0
  164. package/src/plugins/builtin/volatility/index.tsx +4 -0
  165. package/src/plugins/builtin/world-indices/client.ts +43 -0
  166. package/src/plugins/builtin/world-indices/headless.test.ts +32 -0
  167. package/src/plugins/builtin/world-indices/headless.ts +109 -0
  168. package/src/plugins/builtin/world-indices/index.tsx +4 -0
  169. package/src/plugins/builtin/yield-curve/headless.test.ts +24 -0
  170. package/src/plugins/builtin/yield-curve/headless.ts +67 -0
  171. package/src/plugins/builtin/yield-curve/index.tsx +6 -8
  172. package/src/plugins/builtin/yield-curve/treasury-data.ts +16 -6
  173. package/src/plugins/bundle.test.ts +58 -1
  174. package/src/plugins/bundle.ts +4 -2
  175. package/src/plugins/catalog-backend.test.ts +26 -0
  176. package/src/plugins/catalog-backend.ts +9 -2
  177. package/src/plugins/catalog-browser.test.ts +0 -13
  178. package/src/plugins/catalog-browser.ts +4 -0
  179. package/src/plugins/catalog-manifest.test.ts +15 -0
  180. package/src/plugins/catalog-manifest.ts +12 -5
  181. package/src/plugins/catalog-ui.ts +0 -6
  182. package/src/plugins/host-contract.ts +9 -0
  183. package/src/plugins/host-modules.ts +12 -0
  184. package/src/plugins/loader.ts +63 -15
  185. package/src/plugins/prediction-markets/headless.test.ts +119 -0
  186. package/src/plugins/prediction-markets/headless.ts +255 -0
  187. package/src/plugins/prediction-markets/index.tsx +2 -0
  188. package/src/plugins/seed.ts +3 -0
  189. package/src/public/broker.ts +6 -0
  190. package/src/remote/schema.test.ts +65 -0
  191. package/src/remote/schema.ts +322 -39
  192. package/src/remote/types.ts +49 -2
  193. package/src/renderers/browser/app-services.ts +3 -0
  194. package/src/renderers/browser/config-host.ts +24 -8
  195. package/src/renderers/browser/deeplink-bridge.test.ts +11 -0
  196. package/src/renderers/browser/deeplink-bridge.ts +15 -3
  197. package/src/renderers/browser/main.tsx +3 -1
  198. package/src/renderers/browser/research-entry.test.ts +8 -0
  199. package/src/renderers/browser/research-entry.ts +7 -0
  200. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +2 -2
  201. package/src/renderers/electrobun/bun/desktop/initialization.ts +10 -3
  202. package/src/renderers/electrobun/bun/external-plugins.ts +20 -4
  203. package/src/renderers/electrobun/view/build-assets.ts +0 -2
  204. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +63 -10
  205. package/src/sources/gloomberb-cloud/news.ts +1 -0
  206. package/src/sync/core-contributors.test.ts +111 -0
  207. package/src/sync/core-contributors.ts +155 -1
  208. package/src/time-series/resolve.test.ts +118 -1
  209. package/src/time-series/resolve.ts +14 -16
  210. package/src/types/config.ts +2 -0
  211. package/src/plugins/broker-sync/broker-sync.test.ts +0 -188
  212. package/src/plugins/broker-sync/native-loader.ts +0 -10
  213. package/src/plugins/broker-sync/normalize.ts +0 -308
  214. package/src/plugins/broker-sync/public.ts +0 -122
  215. package/src/plugins/broker-sync/robinhood-native.ts +0 -367
  216. package/src/plugins/broker-sync/robinhood.ts +0 -114
  217. package/src/plugins/broker-sync/simplefin-native.ts +0 -201
  218. package/src/plugins/broker-sync/simplefin.ts +0 -70
  219. package/src/renderers/electrobun/view/native-stubs/broker-sync-native-loader.ts +0 -7
@@ -16,9 +16,11 @@ import type {
16
16
  } from "./types";
17
17
  import {
18
18
  buildCompositeTimeScale,
19
+ COMPOSITE_RIGHT_OFFSET_RATIO,
19
20
  projectCompositeTimestamp,
21
+ unprojectCompositeTimestamp,
20
22
  } from "./time-scale";
21
- import type { CompositeTimeScale } from "./types";
23
+ import type { CompositeLastPriceMarker, CompositeTimeScale } from "./types";
22
24
 
23
25
  const DAY_MS = 24 * 60 * 60 * 1000;
24
26
 
@@ -331,7 +333,8 @@ function projectSeries(
331
333
  series: ResolvedSeries,
332
334
  domain: CompositeAxisDomain,
333
335
  startTime: number,
334
- endTime: number,
336
+ /** Where a step series holds its last level to, never past the newest bar. */
337
+ stepEndTime: number,
335
338
  timeScale: CompositeTimeScale,
336
339
  ): CompositeProjectedPoint[] {
337
340
  const projected: CompositeProjectedPoint[] = [];
@@ -372,16 +375,74 @@ function projectSeries(
372
375
  if (stepSeries && projected.length > 0) {
373
376
  const last = projected.at(-1)!;
374
377
  const trailingGap = normalizedSourcePoints(series, timeScale).some(({ timestamp, value }) => (
375
- timestamp > last.timestamp && timestamp <= endTime
378
+ timestamp > last.timestamp && timestamp <= stepEndTime
376
379
  && (value === null || projectCompositeValue(value, domain) === null)
377
380
  ));
378
- if (last.timestamp < endTime && !trailingGap) {
379
- projected.push({ ...last, timestamp: endTime, xRatio: 1, breakBefore: false });
381
+ // The synthetic tail is a level, not an observation, so it lands on its own
382
+ // timestamp rather than snapping forward to the next trading slot.
383
+ const trailingRatio = projectCompositeTimestamp(timeScale, stepEndTime)?.ratio;
384
+ if (last.timestamp < stepEndTime && !trailingGap && trailingRatio !== undefined) {
385
+ projected.push({
386
+ ...last,
387
+ timestamp: stepEndTime,
388
+ xRatio: Math.min(trailingRatio, 1),
389
+ xSlot: undefined,
390
+ breakBefore: false,
391
+ });
380
392
  }
381
393
  }
382
394
  return projected;
383
395
  }
384
396
 
397
+ /**
398
+ * Unit groups a price can wear, raw or transformed. Volume, oscillators and
399
+ * other derived study outputs are deliberately absent: a level line there
400
+ * would mark a quantity that has no last price to read.
401
+ */
402
+ const PRICE_UNIT_GROUPS = /^(?:price|currency|percent|index|log)(?::|$)/;
403
+
404
+ /**
405
+ * The chart's primary price series: the first exchange-traded price, in
406
+ * authored order. Study outputs are appended after the series they derive
407
+ * from, so an overlay indicator never wins over the price it follows.
408
+ */
409
+ function primaryPriceSeries(series: readonly ResolvedSeries[]): ResolvedSeries | null {
410
+ return series.find((entry) => (
411
+ entry.timeBasis?.kind === "market"
412
+ && PRICE_UNIT_GROUPS.test(entry.unitGroup.toLowerCase())
413
+ )) ?? null;
414
+ }
415
+
416
+ function lastCloseOf(points: readonly CompositeProjectedPoint[]): number | null {
417
+ const last = points.at(-1);
418
+ if (!last) return null;
419
+ return finiteNumber(last.point.close) ? last.point.close : last.value;
420
+ }
421
+
422
+ /** Marks the newest close of the primary price series on the panel that draws it. */
423
+ function attachLastPriceMarker(
424
+ panels: CompositePanelScene[],
425
+ series: readonly ResolvedSeries[],
426
+ ): void {
427
+ const primary = primaryPriceSeries(series);
428
+ if (!primary) return;
429
+ const panel = panels.find((entry) => entry.id === primary.panelId);
430
+ const projected = panel?.series.find((entry) => entry.source.id === primary.id);
431
+ const domain = panel?.axes[primary.axis];
432
+ if (!panel || !projected || !domain) return;
433
+ const value = lastCloseOf(projected.points);
434
+ if (value === null) return;
435
+ const yRatio = projectCompositeValue(value, domain);
436
+ if (yRatio === null) return;
437
+ panel.lastPrice = {
438
+ seriesId: primary.id,
439
+ color: primary.color,
440
+ axis: primary.axis,
441
+ value,
442
+ yRatio,
443
+ };
444
+ }
445
+
385
446
  function nearestDate(dates: Date[], requested: Date): Date | null {
386
447
  const target = requested.getTime();
387
448
  if (!Number.isFinite(target) || dates.length === 0) return null;
@@ -483,7 +544,16 @@ export function buildCompositeChartScene(
483
544
  timelineSeries,
484
545
  startTime,
485
546
  endTime,
547
+ options.rightOffsetRatio ?? COMPOSITE_RIGHT_OFFSET_RATIO,
486
548
  );
549
+ // The plot reaches past the viewport by the reserved right offset. Panned
550
+ // back into history that space holds real observations, so navigation and
551
+ // the cursor follow the drawn edge rather than the viewport end.
552
+ const plotEndTime = Math.max(endTime, unprojectCompositeTimestamp(timeScale, 1));
553
+ // A step holds its level to the newest observation and no further: the space
554
+ // reserved after it stays empty. With data still ahead of the window, the
555
+ // level runs to the drawn edge instead.
556
+ const stepTrailingTime = Math.min(plotEndTime, Math.max(lastTime, endTime));
487
557
  const scopedSeries = viewport
488
558
  ? dataSeries.flatMap((entry) => scopeSeriesToViewport(entry, startTime, endTime, timeScale) ?? [])
489
559
  : dataSeries;
@@ -493,11 +563,11 @@ export function buildCompositeChartScene(
493
563
  const usableSeries = emptyRange
494
564
  ? dataSeries.map((entry) => ({ ...entry, points: [] }))
495
565
  : scopedSeries;
496
- const visibleTimes = uniqueTimes.filter((time) => time >= startTime && time <= endTime);
566
+ const visibleTimes = uniqueTimes.filter((time) => time >= startTime && time <= plotEndTime);
497
567
  const marketTimes = timeScale.kind === "market"
498
568
  ? timeScale.anchors
499
569
  .map(({ timestamp }) => timestamp)
500
- .filter((time) => time >= startTime && time <= endTime)
570
+ .filter((time) => time >= startTime && time <= plotEndTime)
501
571
  : [];
502
572
  const cursorTimes = timeScale.kind === "market" ? marketTimes : visibleTimes;
503
573
  const dates = (cursorTimes.length > 0
@@ -514,22 +584,24 @@ export function buildCompositeChartScene(
514
584
  const cursorXRatio = cursorDate
515
585
  ? projectCompositeTimestamp(timeScale, cursorDate.getTime())?.ratio ?? null
516
586
  : null;
517
- const orderedPanels = panelSpecsForSeries(usableSeries, panels);
587
+ // Panels belong to the authored series, not to whichever of them happen to
588
+ // hold observations right now. A panel that disappears while its data loads
589
+ // reflows every other panel, and the chart jumps again when it comes back.
590
+ const orderedPanels = panelSpecsForSeries(series, panels);
518
591
  const panelHeights = allocateCompositePanelHeights(orderedPanels, options.height);
519
592
 
520
- const panelScenes: CompositePanelScene[] = orderedPanels.flatMap((panel) => {
593
+ const panelScenes: CompositePanelScene[] = orderedPanels.map((panel) => {
521
594
  const panelSeries = usableSeries.filter((entry) => entry.panelId === panel.id);
522
- if (panelSeries.length === 0) return [];
523
595
  const scale = panel.scale ?? "linear";
524
- // An empty range has no in-view values, so scale its axes to the loaded
525
- // history rather than the meaningless 0..1 fallback.
526
- const domainSeries = emptyRange
596
+ // With no in-view values, scale the axes to the loaded history rather than
597
+ // the meaningless 0..1 fallback, so the panel keeps its gutter and grid.
598
+ const domainSeries = emptyRange || panelSeries.length === 0
527
599
  ? dataSeries.filter((entry) => entry.panelId === panel.id)
528
600
  : panelSeries;
529
601
  const left = buildAxisDomain("left", domainSeries, scale);
530
602
  const right = buildAxisDomain("right", domainSeries, scale);
531
603
  const axes: Partial<Record<CompositeAxisSide, CompositeAxisDomain>> = { left, right };
532
- return [{
604
+ return {
533
605
  id: panel.id,
534
606
  label: panel.label,
535
607
  height: panelHeights.get(panel.id) ?? 1,
@@ -538,11 +610,15 @@ export function buildCompositeChartScene(
538
610
  series: panelSeries.flatMap((entry) => {
539
611
  const domain = axes[entry.axis];
540
612
  return domain
541
- ? [{ source: entry, points: projectSeries(entry, domain, startTime, endTime, timeScale) }]
613
+ ? [{
614
+ source: entry,
615
+ points: projectSeries(entry, domain, startTime, stepTrailingTime, timeScale),
616
+ }]
542
617
  : [];
543
618
  }),
544
- }];
619
+ };
545
620
  });
621
+ attachLastPriceMarker(panelScenes, usableSeries);
546
622
 
547
623
  return {
548
624
  width: Math.max(1, Math.floor(options.width)),
@@ -251,6 +251,19 @@ export function renderCompositePanelText(
251
251
  }
252
252
  }
253
253
 
254
+ // Blank cells only, so the dashed level reads as if it ran under the marks.
255
+ if (panel.lastPrice) {
256
+ const row = clamp(
257
+ Math.round(panel.lastPrice.yRatio * Math.max(height - 1, 0)),
258
+ 0,
259
+ Math.max(height - 1, 0),
260
+ );
261
+ for (let x = 0; x < plotWidth; x += 1) {
262
+ const current = rows[row]?.[x];
263
+ if (current === " " || current === "·") setCell(rows, x, row, "╌");
264
+ }
265
+ }
266
+
254
267
  if (cursorXRatio !== null) {
255
268
  const cursorX = clamp(Math.round(cursorXRatio * Math.max(plotWidth - 1, 0)), 0, Math.max(plotWidth - 1, 0));
256
269
  for (let y = 0; y < height; y += 1) {
@@ -3,6 +3,14 @@ import type { CompositeTimeScale } from "./types";
3
3
 
4
4
  const DAY_MS = 24 * 60 * 60 * 1_000;
5
5
  const QUARTER_HOUR_MS = 15 * 60 * 1_000;
6
+ /**
7
+ * Empty space kept after the newest observation, as a fraction of the visible
8
+ * span, so the last bar is drawn whole with air after it instead of hugging the
9
+ * value axis. Six percent is three to five bar slots on the windows these
10
+ * charts show, and because it scales with the plot it reads the same in a
11
+ * narrow terminal pane and at desktop pixel widths.
12
+ */
13
+ export const COMPOSITE_RIGHT_OFFSET_RATIO = 0.06;
6
14
  const MINIMUM_SLOT_FRACTION = 0.25;
7
15
  const dateFormatters = new Map<string, Intl.DateTimeFormat>();
8
16
  // Session dates are looked up once per quarter hour, not once per bar: a day
@@ -202,11 +210,13 @@ export function buildCompositeTimeScale(
202
210
  timelineSeries: readonly ResolvedSeries[],
203
211
  startTime: number,
204
212
  endTime: number,
213
+ rightOffsetRatio = 0,
205
214
  ): CompositeTimeScale {
215
+ const offset = Number.isFinite(rightOffsetRatio) && rightOffsetRatio > 0 ? rightOffsetRatio : 0;
206
216
  const anchorSeries = timelineSeries.find((series) => series.timeBasis?.kind === "market");
207
217
  const resolved = anchorSeries ? resolveMarketAnchors(anchorSeries) : null;
208
218
  if (!anchorSeries || !resolved || resolved.anchors.length === 0) {
209
- return { kind: "calendar", startTime, endTime };
219
+ return { kind: "calendar", startTime, endTime, rightOffsetRatio: offset };
210
220
  }
211
221
  const { cadenceMs, anchors } = resolved;
212
222
  const startPosition = positionForTimestamp({ anchors, cadenceMs }, startTime);
@@ -220,9 +230,24 @@ export function buildCompositeTimeScale(
220
230
  anchors,
221
231
  startPosition,
222
232
  endPosition: endPosition > startPosition ? endPosition : startPosition + 1,
233
+ rightOffsetRatio: offset,
223
234
  };
224
235
  }
225
236
 
237
+ /** Fraction of the visible span held empty after the newest observation. */
238
+ export function compositeRightOffsetRatio(scale: CompositeTimeScale): number {
239
+ const ratio = scale.rightOffsetRatio ?? 0;
240
+ return Number.isFinite(ratio) && ratio > 0 ? ratio : 0;
241
+ }
242
+
243
+ /** Span the plot's full width covers, viewport plus reserved right offset. */
244
+ function projectionSpan(scale: CompositeTimeScale): number {
245
+ const viewportSpan = scale.kind === "calendar"
246
+ ? Math.max(scale.endTime - scale.startTime, 1)
247
+ : Math.max(scale.endPosition - scale.startPosition, Number.EPSILON);
248
+ return viewportSpan * (1 + compositeRightOffsetRatio(scale));
249
+ }
250
+
226
251
  /** Slot position of a timestamp on a market scale, or wall-clock time on a calendar scale. */
227
252
  export function compositeTimePosition(scale: CompositeTimeScale, timestamp: number): number {
228
253
  return scale.kind === "calendar" ? timestamp : positionForTimestamp(scale, timestamp);
@@ -261,8 +286,7 @@ export function projectCompositeTimestamp(
261
286
  ): { ratio: number; xSlot?: number } | null {
262
287
  if (!Number.isFinite(timestamp)) return null;
263
288
  if (scale.kind === "calendar") {
264
- const span = Math.max(scale.endTime - scale.startTime, 1);
265
- return { ratio: (timestamp - scale.startTime) / span };
289
+ return { ratio: (timestamp - scale.startTime) / projectionSpan(scale) };
266
290
  }
267
291
 
268
292
  let position: number;
@@ -279,8 +303,7 @@ export function projectCompositeTimestamp(
279
303
  position = positionForTimestamp(scale, timestamp);
280
304
  if (exact?.timestamp === timestamp) xSlot = index;
281
305
  }
282
- const span = Math.max(scale.endPosition - scale.startPosition, Number.EPSILON);
283
- return { ratio: (position - scale.startPosition) / span, xSlot };
306
+ return { ratio: (position - scale.startPosition) / projectionSpan(scale), xSlot };
284
307
  }
285
308
 
286
309
  export function unprojectCompositeTimestamp(
@@ -289,10 +312,7 @@ export function unprojectCompositeTimestamp(
289
312
  ): number {
290
313
  const safeRatio = Math.max(0, Math.min(1, ratio));
291
314
  if (scale.kind === "calendar") {
292
- return scale.startTime + (scale.endTime - scale.startTime) * safeRatio;
315
+ return scale.startTime + projectionSpan(scale) * safeRatio;
293
316
  }
294
- return compositeTimeAtPosition(
295
- scale,
296
- scale.startPosition + (scale.endPosition - scale.startPosition) * safeRatio,
297
- );
317
+ return compositeTimeAtPosition(scale, scale.startPosition + projectionSpan(scale) * safeRatio);
298
318
  }
@@ -43,6 +43,12 @@ export interface CompositeCalendarTimeScale {
43
43
  kind: "calendar";
44
44
  startTime: number;
45
45
  endTime: number;
46
+ /**
47
+ * Empty space reserved after `endTime`, as a fraction of the visible span.
48
+ * The 0..1 projection covers `startTime` to `endTime` plus that space, so
49
+ * the newest observation is drawn whole with air after it. Absent means none.
50
+ */
51
+ rightOffsetRatio?: number;
46
52
  }
47
53
 
48
54
  export interface CompositeMarketTimeScale {
@@ -56,7 +62,10 @@ export interface CompositeMarketTimeScale {
56
62
  position: number;
57
63
  }>;
58
64
  startPosition: number;
65
+ /** Slot of `endTime`; the reserved right offset extends past it. */
59
66
  endPosition: number;
67
+ /** Empty slots reserved after `endPosition`, as a fraction of the visible span. */
68
+ rightOffsetRatio?: number;
60
69
  }
61
70
 
62
71
  export type CompositeTimeScale = CompositeCalendarTimeScale | CompositeMarketTimeScale;
@@ -66,6 +75,15 @@ export interface CompositeProjectedSeries {
66
75
  points: CompositeProjectedPoint[];
67
76
  }
68
77
 
78
+ /** Level line at the newest close of the chart's primary price series. */
79
+ export interface CompositeLastPriceMarker {
80
+ seriesId: string;
81
+ color: string;
82
+ axis: CompositeAxisSide;
83
+ value: number;
84
+ yRatio: number;
85
+ }
86
+
69
87
  export interface CompositePanelScene {
70
88
  id: string;
71
89
  label?: string;
@@ -73,6 +91,8 @@ export interface CompositePanelScene {
73
91
  scale: PanelScale;
74
92
  axes: Partial<Record<CompositeAxisSide, CompositeAxisDomain>>;
75
93
  series: CompositeProjectedSeries[];
94
+ /** Present only on the panel that holds the primary price series. */
95
+ lastPrice?: CompositeLastPriceMarker;
76
96
  }
77
97
 
78
98
  export interface CompositeCursorValue {
@@ -109,6 +129,12 @@ export interface BuildCompositeChartSceneOptions {
109
129
  };
110
130
  /** Authored series order retained even when the primary anchor is hidden. */
111
131
  timelineSeries?: ResolvedSeries[];
132
+ /**
133
+ * Empty space kept after the newest observation, as a fraction of the visible
134
+ * span. Defaults to `COMPOSITE_RIGHT_OFFSET_RATIO`; pass 0 for charts whose x
135
+ * is not time, where the last observation belongs at the right edge.
136
+ */
137
+ rightOffsetRatio?: number;
112
138
  }
113
139
 
114
140
  export interface CompositeChartXMarker {
@@ -42,17 +42,21 @@ export function useCommandBarAssist({
42
42
  askAssist: () => void;
43
43
  resetAssist: () => boolean;
44
44
  } {
45
- const [assistState, setAssistState] = useState<AssistRequestState>({ status: "idle" });
45
+ const [assistState, setRenderedAssistState] = useState<AssistRequestState>({ status: "idle" });
46
46
  const assistStateRef = useRef(assistState);
47
- assistStateRef.current = assistState;
47
+ /**
48
+ * Event handlers can run again before React commits the state they just
49
+ * queued. Keep their read model current synchronously so repeated Enter
50
+ * claims one request instead of aborting it and starting another.
51
+ */
52
+ const updateAssistState = useCallback((next: AssistRequestState) => {
53
+ assistStateRef.current = next;
54
+ setRenderedAssistState(next);
55
+ }, []);
48
56
  const abortRef = useRef<AbortController | null>(null);
49
57
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
50
58
  const answersRef = useRef(new Map<string, AssistCommandCandidate[]>());
51
- /**
52
- * The query the runtime has already acted on, updated the moment a request
53
- * starts. Rendered state lags a tick behind, and reading it instead would let
54
- * a re-render slip a second debounce past an ask that is already in flight.
55
- */
59
+ /** Query the runtime has already acted on, updated when a request starts. */
56
60
  const handledQueryRef = useRef<string | null>(null);
57
61
  /** Query the user dismissed with Esc; the section stays gone until it changes. */
58
62
  const dismissedQueryRef = useRef<string | null>(null);
@@ -65,12 +69,13 @@ export function useCommandBarAssist({
65
69
  getInventoryRef.current = getInventory;
66
70
 
67
71
  const cancelPending = useCallback(() => {
68
- if (debounceRef.current) {
69
- clearTimeout(debounceRef.current);
70
- debounceRef.current = null;
71
- }
72
- abortRef.current?.abort();
72
+ const debounce = debounceRef.current;
73
+ debounceRef.current = null;
74
+ if (debounce !== null) clearTimeout(debounce);
75
+
76
+ const controller = abortRef.current;
73
77
  abortRef.current = null;
78
+ controller?.abort();
74
79
  }, []);
75
80
 
76
81
  const resetAssist = useCallback((): boolean => {
@@ -79,9 +84,9 @@ export function useCommandBarAssist({
79
84
  if (active.status === "idle" || active.source === "auto") return false;
80
85
  cancelPending();
81
86
  dismissedQueryRef.current = active.query;
82
- setAssistState({ status: "idle" });
87
+ updateAssistState({ status: "idle" });
83
88
  return true;
84
- }, [cancelPending]);
89
+ }, [cancelPending, updateAssistState]);
85
90
 
86
91
  const runAssist = useCallback((query: string, source: AssistRequestSource) => {
87
92
  const trimmed = query.trim();
@@ -96,35 +101,35 @@ export function useCommandBarAssist({
96
101
 
97
102
  const cached = answersRef.current.get(trimmed);
98
103
  if (cached) {
99
- setAssistState({ status: "answered", query: trimmed, source: resolveSource(), candidates: cached });
104
+ updateAssistState({ status: "answered", query: trimmed, source: resolveSource(), candidates: cached });
100
105
  return;
101
106
  }
102
107
 
103
108
  const controller = new AbortController();
104
109
  abortRef.current = controller;
105
- setAssistState({ status: "loading", query: trimmed, source: resolveSource() });
110
+ updateAssistState({ status: "loading", query: trimmed, source: resolveSource() });
106
111
 
107
112
  void (async () => {
108
113
  try {
109
114
  const response = await apiClient.assistCommand(trimmed, getInventoryRef.current(), {
110
115
  signal: controller.signal,
111
116
  });
112
- if (controller.signal.aborted) return;
117
+ if (controller.signal.aborted || abortRef.current !== controller) return;
113
118
  const candidates = response?.candidates ?? [];
114
119
  answersRef.current.set(trimmed, candidates);
115
- setAssistState({ status: "answered", query: trimmed, source: resolveSource(), candidates });
120
+ updateAssistState({ status: "answered", query: trimmed, source: resolveSource(), candidates });
116
121
  } catch (error) {
117
- if (controller.signal.aborted) return;
122
+ if (controller.signal.aborted || abortRef.current !== controller) return;
118
123
  const kind = classifyAssistError(error);
119
124
  if (kind === "rate-limited") {
120
125
  rateLimitedUntilRef.current = Date.now() + ASSIST_RATE_LIMIT_BACKOFF_MS;
121
126
  }
122
- setAssistState({ status: "error", query: trimmed, source: resolveSource(), kind });
127
+ updateAssistState({ status: "error", query: trimmed, source: resolveSource(), kind });
123
128
  } finally {
124
129
  if (abortRef.current === controller) abortRef.current = null;
125
130
  }
126
131
  })();
127
- }, [cancelPending]);
132
+ }, [cancelPending, updateAssistState]);
128
133
 
129
134
  const askAssist = useCallback(() => {
130
135
  const trimmed = rootQueryRef.current.trim();
@@ -134,17 +139,17 @@ export function useCommandBarAssist({
134
139
  // The background ask already on the wire asks this exact question; asking
135
140
  // again would only abort it and start the wait over.
136
141
  if (active.status === "loading" && active.query === trimmed) {
137
- setAssistState({ ...active, source: "explicit" });
142
+ updateAssistState({ ...active, source: "explicit" });
138
143
  return;
139
144
  }
140
145
  runAssist(trimmed, "explicit");
141
- }, [runAssist]);
146
+ }, [runAssist, updateAssistState]);
142
147
 
143
148
  useEffect(() => {
144
149
  const trimmed = rootQuery.trim();
145
150
  const active = assistStateRef.current;
146
151
  if (active.status !== "idle" && active.query !== trimmed) {
147
- setAssistState({ status: "idle" });
152
+ updateAssistState({ status: "idle" });
148
153
  }
149
154
  if (handledQueryRef.current !== null && handledQueryRef.current !== trimmed) {
150
155
  // Whatever is in flight describes text the user has already moved past.
@@ -158,21 +163,24 @@ export function useCommandBarAssist({
158
163
  const cached = answersRef.current.get(trimmed);
159
164
  if (cached) {
160
165
  handledQueryRef.current = trimmed;
161
- setAssistState({ status: "answered", query: trimmed, source: "auto", candidates: cached });
166
+ updateAssistState({ status: "answered", query: trimmed, source: "auto", candidates: cached });
162
167
  return;
163
168
  }
164
169
  if (Date.now() < rateLimitedUntilRef.current) return;
165
170
 
166
- debounceRef.current = setTimeout(() => {
171
+ const debounce = setTimeout(() => {
172
+ // A cleared timer can already be queued. Only the timer still owned by
173
+ // this effect may start a request.
174
+ if (debounceRef.current !== debounce) return;
167
175
  debounceRef.current = null;
168
176
  runAssist(trimmed, "auto");
169
177
  }, ASSIST_DEBOUNCE_MS);
178
+ debounceRef.current = debounce;
170
179
  return () => {
171
- if (!debounceRef.current) return;
172
- clearTimeout(debounceRef.current);
173
- debounceRef.current = null;
180
+ clearTimeout(debounce);
181
+ if (debounceRef.current === debounce) debounceRef.current = null;
174
182
  };
175
- }, [autoAsk, cancelPending, rootQuery, runAssist]);
183
+ }, [autoAsk, cancelPending, rootQuery, runAssist, updateAssistState]);
176
184
 
177
185
  useEffect(() => cancelPending, [cancelPending]);
178
186
 
@@ -1,4 +1,5 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { act } from "react";
2
3
  import { apiClient, setCloudApiFetchTransport } from "../../../api-client";
3
4
  import { testRender } from "../../../renderers/opentui/test-utils";
4
5
  import type { PluginRegistry } from "../../../plugins/registry";
@@ -7,9 +8,22 @@ import {
7
8
  CommandBarHarness,
8
9
  createCommandBarTestControls,
9
10
  emitKeypress,
11
+ settleFrame,
10
12
  } from "./test-harness";
11
13
 
12
14
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
15
+ const originalWebSocket = globalThis.WebSocket;
16
+
17
+ beforeEach(() => {
18
+ apiClient.dispose();
19
+ // The header subscribes to SPY. A real socket rejects this test's fake token
20
+ // and can mark the account unverified before the assist debounce completes.
21
+ globalThis.WebSocket = class {
22
+ static readonly OPEN = 1;
23
+ readyState = 0;
24
+ close() { this.readyState = 3; }
25
+ } as unknown as typeof WebSocket;
26
+ });
13
27
 
14
28
  afterEach(() => {
15
29
  setCloudApiFetchTransport(null);
@@ -18,6 +32,8 @@ afterEach(() => {
18
32
  testSetup.renderer.destroy();
19
33
  testSetup = undefined;
20
34
  }
35
+ apiClient.dispose();
36
+ globalThis.WebSocket = originalWebSocket;
21
37
  });
22
38
 
23
39
  const { waitForFrameToContain } = createCommandBarTestControls(() => testSetup!);
@@ -84,8 +100,7 @@ const ASSIST_WAIT_ATTEMPTS = 40;
84
100
  async function waitForRequest(requests: string[]): Promise<void> {
85
101
  for (let attempt = 0; attempt < ASSIST_WAIT_ATTEMPTS; attempt++) {
86
102
  if (requests.length > 0) return;
87
- await Bun.sleep(50);
88
- await testSetup!.renderOnce();
103
+ await settleFrame(testSetup!);
89
104
  }
90
105
  throw new Error("Timed out waiting for the assist request.");
91
106
  }
@@ -94,8 +109,7 @@ async function waitForFrameWithout(text: string): Promise<string> {
94
109
  for (let attempt = 0; attempt < ASSIST_WAIT_ATTEMPTS; attempt++) {
95
110
  const frame = testSetup!.captureCharFrame();
96
111
  if (!frame.includes(text)) return frame;
97
- await Bun.sleep(50);
98
- await testSetup!.renderOnce();
112
+ await settleFrame(testSetup!);
99
113
  }
100
114
  throw new Error(`Timed out waiting for "${text}" to disappear.`);
101
115
  }
@@ -236,13 +250,54 @@ describe("CommandBar AI assist", () => {
236
250
 
237
251
  releaseResponse();
238
252
  for (let attempt = 0; attempt < ASSIST_WAIT_ATTEMPTS && created.length === 0; attempt++) {
239
- await Bun.sleep(50);
240
- await testSetup.renderOnce();
253
+ await settleFrame(testSetup);
241
254
  }
242
255
  expect(created).toEqual([{ templateId: "new-chat-pane", options: { arg: "#general" } }]);
243
256
  expect(requests).toHaveLength(1);
244
257
  });
245
258
 
259
+ test("does not restart an ask when Enter repeats before loading renders", async () => {
260
+ signInVerified();
261
+ let releaseResponse = () => {};
262
+ const held = new Promise<void>((resolve) => { releaseResponse = resolve; });
263
+ const requests = mockAssistTransport(async () => {
264
+ await held;
265
+ return jsonResponse({
266
+ candidates: [{ input: "CHAT #general", title: "Open the general channel", prefix: "CHAT", confidence: 0.9 }],
267
+ });
268
+ });
269
+
270
+ testSetup = await testRender(
271
+ <CommandBarHarness query="new chat pane" />,
272
+ { width: 120, height: 20 },
273
+ );
274
+
275
+ await testSetup.renderOnce();
276
+ expect(testSetup.captureCharFrame()).toContain("Thinking…");
277
+
278
+ await act(async () => {
279
+ for (let press = 0; press < 2; press += 1) {
280
+ testSetup!.renderer.keyInput.emit("keypress", {
281
+ ctrl: false,
282
+ meta: false,
283
+ option: false,
284
+ shift: false,
285
+ eventType: "press",
286
+ name: "return",
287
+ sequence: "\r",
288
+ repeated: press > 0,
289
+ stopPropagation: () => {},
290
+ preventDefault: () => {},
291
+ } as any);
292
+ }
293
+ await testSetup!.renderOnce();
294
+ });
295
+
296
+ expect(requests).toHaveLength(1);
297
+ releaseResponse();
298
+ await waitForFrameToContain("#general · Open the general channel", ASSIST_WAIT_ATTEMPTS);
299
+ });
300
+
246
301
  test("drops the section when a background ask fails", async () => {
247
302
  signInVerified();
248
303
  const requests = mockAssistTransport(() => jsonResponse({ error: "assist-unavailable" }, 503));
@@ -275,8 +330,7 @@ describe("CommandBar AI assist", () => {
275
330
  await testSetup.renderOnce();
276
331
  expect(testSetup.captureCharFrame()).toContain("Ask AI — sign up to enable");
277
332
 
278
- await Bun.sleep(700);
279
- await testSetup.renderOnce();
333
+ await settleFrame(testSetup, 700);
280
334
  expect(requests).toEqual([]);
281
335
 
282
336
  // The offer sits under the list and never takes the Enter that belongs to
@@ -41,6 +41,24 @@ export async function emitKeypress(
41
41
  });
42
42
  }
43
43
 
44
+ /**
45
+ * Advances one polling step: sleeps, then renders. Both happen inside `act` so
46
+ * that anything a resolving promise queued during the sleep is flushed before
47
+ * the next frame is captured. Polling outside `act` leaves the update to
48
+ * React's scheduler, which is why a loaded CI box could time out waiting for a
49
+ * frame the app had already produced.
50
+ */
51
+ export async function settleFrame(
52
+ renderer: Awaited<ReturnType<typeof testRender>>,
53
+ delayMs = 50,
54
+ ): Promise<void> {
55
+ await act(async () => {
56
+ await Bun.sleep(delayMs);
57
+ });
58
+ // Paint after React has committed the updates collected by act.
59
+ await renderer.renderOnce();
60
+ }
61
+
44
62
  export function createCommandBarTestControls(
45
63
  getRenderer: () => Awaited<ReturnType<typeof testRender>>,
46
64
  ) {
@@ -51,10 +69,9 @@ export function createCommandBarTestControls(
51
69
  if (frame.includes(text)) {
52
70
  return frame;
53
71
  }
54
- await Bun.sleep(delayMs);
55
- await renderer.renderOnce();
72
+ await settleFrame(renderer, delayMs);
56
73
  }
57
- throw new Error(`Timed out waiting for frame to contain "${text}".`);
74
+ throw new Error(`Timed out waiting for frame to contain "${text}".\n${renderer.captureCharFrame()}`);
58
75
  };
59
76
 
60
77
  const clickFrameText = async (text: string): Promise<void> => {
@@ -76,7 +93,9 @@ export function createCommandBarTestControls(
76
93
  const renderFrames = async (count = 2): Promise<void> => {
77
94
  const renderer = getRenderer();
78
95
  for (let index = 0; index < count; index += 1) {
79
- await renderer.renderOnce();
96
+ await act(async () => {
97
+ await renderer.renderOnce();
98
+ });
80
99
  }
81
100
  };
82
101