gloomberb 0.17.3 → 0.17.4

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 (50) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/chat.ts +28 -0
  3. package/src/api-client/errors.ts +11 -1
  4. package/src/api-client/index.ts +4 -1
  5. package/src/api-client/normalizers.ts +21 -0
  6. package/src/api-client/socket.ts +27 -0
  7. package/src/app/runtime/startup.ts +4 -0
  8. package/src/cli/pane-functions/function-help.ts +1 -1
  9. package/src/components/index.ts +1 -1
  10. package/src/components/metric-treemap/heat-surface.tsx +76 -14
  11. package/src/components/metric-treemap/heat-tiles.ts +5 -3
  12. package/src/components/metric-treemap/index.tsx +1 -1
  13. package/src/components/ui/button.tsx +24 -9
  14. package/src/components/ui/index.ts +1 -1
  15. package/src/components/ui/status.tsx +148 -5
  16. package/src/layout-marketplace/gallery-desktop.tsx +2 -2
  17. package/src/market-data/futures-board.ts +44 -1
  18. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  19. package/src/plugins/builtin/alerts/cli.ts +72 -11
  20. package/src/plugins/builtin/awards/headless.ts +5 -1
  21. package/src/plugins/builtin/catalysts/headless.ts +5 -1
  22. package/src/plugins/builtin/chat/controller/connections.ts +18 -0
  23. package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
  24. package/src/plugins/builtin/chat/controller/index.ts +49 -21
  25. package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
  26. package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
  27. package/src/plugins/builtin/cot/model.ts +20 -0
  28. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  29. package/src/plugins/builtin/docs/pane.tsx +1 -1
  30. package/src/plugins/builtin/futures/index.tsx +2 -3
  31. package/src/plugins/builtin/futures/model.ts +4 -2
  32. package/src/plugins/builtin/futures/series.ts +3 -3
  33. package/src/plugins/builtin/jobs/pane.tsx +12 -9
  34. package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
  35. package/src/plugins/builtin/news/headless.ts +4 -2
  36. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  37. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -3
  38. package/src/renderers/dom/build-assets.ts +17 -4
  39. package/src/renderers/dom/desktop/controls.tsx +2 -0
  40. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
  41. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
  42. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
  43. package/src/sources/gloomberb-cloud/normalizers.ts +12 -4
  44. package/src/time-series/resolve.ts +5 -3
  45. package/src/ui/resize-settle.ts +128 -0
  46. package/src/utils/calendar-date.ts +56 -0
  47. package/src/utils/decimal-comma.ts +31 -0
  48. package/src/utils/futures-generic.ts +25 -4
  49. package/src/utils/plugin-proxy-hosts.json +5 -0
  50. package/src/utils/utc-time.ts +14 -3
@@ -1,12 +1,11 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { Box, useUiCapabilities } from "../../../ui";
3
3
  import {
4
- buildHeatTreemapScene,
5
4
  findMetricTreemapNeighbor,
6
5
  formatHeatTileMove,
7
- heatTreemapCanvas,
8
6
  HeatTreemapSurface,
9
7
  settleTreemapLayoutItems,
8
+ useHeatTreemapScene,
10
9
  } from "../../../components/metric-treemap";
11
10
  import {
12
11
  loadingText,
@@ -262,7 +261,7 @@ function useSettledLayoutItems<T>(items: readonly MetricTreemapItem<T>[], tolera
262
261
  function MarketHeatmapPane({ focused, width, height }: PaneProps) {
263
262
  const { pinTicker } = usePluginTickerActions();
264
263
  const liveStreaming = useLiveStreamingSetting();
265
- const { cellWidthPx = 8, cellHeightPx = 18, nativePaneChrome } = useUiCapabilities();
264
+ const { nativePaneChrome } = useUiCapabilities();
266
265
  // A pane setting, not private pane state, so the settings dialog can show it.
267
266
  const [universeSetting, setActiveUniverse] = usePaneSettingValue<string>("universe", "us-equity");
268
267
  const [linkPortfolio] = usePaneSettingValue<boolean>("linkPortfolio", false);
@@ -370,10 +369,6 @@ function MarketHeatmapPane({ focused, width, height }: PaneProps) {
370
369
  });
371
370
 
372
371
  const chartHeight = Math.max(1, height - (tabsInHeader ? 0 : 1));
373
- const canvas = useMemo(
374
- () => heatTreemapCanvas(width, chartHeight, { nativePaneChrome, cellWidthPx, cellHeightPx }),
375
- [cellHeightPx, cellWidthPx, chartHeight, nativePaneChrome, width],
376
- );
377
372
 
378
373
  // Stream weights by market-cap rank, fixed for this load of the board.
379
374
  const quoteWeightsRef = useRef<{ tab: string; weights: Map<string, number> } | null>(null);
@@ -430,14 +425,9 @@ function MarketHeatmapPane({ focused, width, height }: PaneProps) {
430
425
  useMemo(() => buildLayoutItems(boardAssets, layoutAssets, grouping, sizeBy), [boardAssets, grouping, layoutAssets, sizeBy]),
431
426
  byMove ? DAY_MOVE_LAYOUT_TOLERANCE : LAYOUT_TOLERANCE,
432
427
  );
433
- // The board on screen seeds the next layout's order, so a refresh nudges tiles instead of reshuffling them.
434
- const sceneRef = useRef<{ tab: string; scene: ReturnType<typeof buildHeatTreemapScene<HeatmapBoardAsset>> } | null>(null);
435
- const scene = useMemo(() => {
436
- const previous = sceneRef.current?.tab === activeUniverse ? sceneRef.current.scene : null;
437
- const next = buildHeatTreemapScene(layoutItems, canvas, previous);
438
- sceneRef.current = { tab: activeUniverse, scene: next };
439
- return next;
440
- }, [activeUniverse, canvas, layoutItems]);
428
+ // The board on screen seeds the next layout's order, so a refresh nudges
429
+ // tiles instead of reshuffling them; a resize lays it out once it settles.
430
+ const { scene, resizing } = useHeatTreemapScene(layoutItems, width, chartHeight, activeUniverse);
441
431
  // The terminal repaints whole cells: colours there move at most four times a second.
442
432
  const paintAssets = useThrottledMemo(
443
433
  () => resolvedAssets,
@@ -726,6 +716,7 @@ function MarketHeatmapPane({ focused, width, height }: PaneProps) {
726
716
  >
727
717
  <HeatTreemapSurface
728
718
  scene={scene}
719
+ resizing={resizing}
729
720
  items={paintItems}
730
721
  width={width}
731
722
  height={chartHeight}
@@ -11,13 +11,15 @@ import {
11
11
  mapCloudNewsArticle,
12
12
  } from "../../../sources/gloomberb-cloud/news";
13
13
  import { parsePublicTickerKey } from "../../../utils/exchanges";
14
+ import { formatUtcSeconds } from "../../../utils/utc-time";
14
15
  import { cloudNewsFreshness, cloudRealtimeAccess } from "../shared/report-freshness";
15
16
 
17
+ // Stories carry their time to the second, so a story published on a whole minute prints its seconds too.
16
18
  const NEWS_COLUMNS = [
17
- { key: "publishedAt", header: "Published" },
19
+ { key: "publishedAt", header: "Published", textFormat: formatUtcSeconds },
18
20
  { key: "source", header: "Source" },
19
21
  { key: "headline", header: "Headline" },
20
- { key: "tickers", header: "Tickers" },
22
+ { key: "tickers", header: "Tickers", textFormat: (value: unknown) => Array.isArray(value) ? value.join(", ") : String(value ?? "") },
21
23
  { key: "sentiment", header: "Sentiment" },
22
24
  { key: "importance", header: "Score", align: "right" as const },
23
25
  ];
@@ -886,7 +886,7 @@ export function PluginMarketplacePane({ focused, width, height }: PaneProps) {
886
886
  <EmptyState
887
887
  title="Plugin catalog unavailable."
888
888
  status="error"
889
- actions={<Button label="Retry" variant="primary" compact onPress={() => refresh(true)} />}
889
+ retry={{ onRetry: () => refresh(true) }}
890
890
  />
891
891
  ) : query || category ? (
892
892
  <EmptyState title="No plugins match." />
@@ -571,10 +571,9 @@ export function EquityDiagnosticView({ focused, width, height }: {
571
571
  return <Box paddingX={1} paddingY={1}><DiagnosticLoading step={loadingStep} /></Box>;
572
572
  }
573
573
  if (!report) {
574
- const retryAction = <Button label="Retry" variant="secondary" onPress={retry} />;
575
574
  return failure
576
- ? <PaneStatusBody error={failureText(failure)} actions={retryAction} />
577
- : <PaneStatusBody empty emptyTitle={t("No diagnostic available yet.")} actions={retryAction} />;
575
+ ? <PaneStatusBody error={failureText(failure)} retry={{ onRetry: retry }} />
576
+ : <PaneStatusBody empty emptyTitle={t("No diagnostic available yet.")} retry={{ onRetry: retry }} />;
578
577
  }
579
578
 
580
579
  return (
@@ -25,6 +25,18 @@ interface ViewBundleOptions {
25
25
  aliasRules?: AliasRule[];
26
26
  failureMessage: string;
27
27
  missingEntryMessage: string;
28
+ /**
29
+ * Leave identifiers unminified (whitespace and syntax still are), so the
30
+ * components keep their names and a page audit can say which one drew an
31
+ * element (`scripts/pane-chrome/ui-audit.ts`). Bun's `keepNames` alone did
32
+ * not keep them.
33
+ */
34
+ keepNames?: boolean;
35
+ }
36
+
37
+ /** Bun's minify setting: everything, or everything but identifiers when names are kept. */
38
+ function minifyOption(keepNames: boolean | undefined) {
39
+ return keepNames ? { whitespace: true, syntax: true, identifiers: false } : true;
28
40
  }
29
41
 
30
42
  // From this file rather than the working directory, so a build started from
@@ -60,6 +72,7 @@ export async function buildViewBundle({
60
72
  aliasRules = [],
61
73
  failureMessage,
62
74
  missingEntryMessage,
75
+ keepNames,
63
76
  }: ViewBundleOptions): Promise<{ script: string; stylesheet: string | null }> {
64
77
  const chunks = new Map<string, string>();
65
78
  const result = await Bun.build({
@@ -69,7 +82,7 @@ export async function buildViewBundle({
69
82
  format: "esm",
70
83
  splitting: false,
71
84
  sourcemap,
72
- minify: true,
85
+ minify: minifyOption(keepNames),
73
86
  define: {
74
87
  "process.env.NODE_ENV": "\"production\"",
75
88
  __GLOOM_WORLD_MAP_DATA__: JSON.stringify(WORLD_MAP_DATA_SPECIFIER),
@@ -88,7 +101,7 @@ export async function buildViewBundle({
88
101
 
89
102
  const script = result.outputs.find((output) => output.kind === "entry-point" && output.path.endsWith(".js"));
90
103
  if (!script) throw new Error(missingEntryMessage);
91
- await buildLazyChunks(outdir, chunks, failureMessage);
104
+ await buildLazyChunks(outdir, chunks, failureMessage, keepNames);
92
105
 
93
106
  const stylesheet = result.outputs.find((output) => output.path.endsWith(".css"))?.path ?? null;
94
107
  if (stylesheet) await writeFile(stylesheet, withTitlebarOverlayHeight(await readFile(stylesheet, "utf8")));
@@ -181,7 +194,7 @@ function lazyChunkPlugin(rootOutdir: string, emitDir: string, chunks: Map<string
181
194
  }
182
195
 
183
196
  /** Builds each lazy chunk on its own, and the chunks those load in turn. */
184
- async function buildLazyChunks(rootOutdir: string, chunks: Map<string, string>, failureMessage: string): Promise<void> {
197
+ async function buildLazyChunks(rootOutdir: string, chunks: Map<string, string>, failureMessage: string, keepNames?: boolean): Promise<void> {
185
198
  const built = new Set<string>();
186
199
  let pending = [...chunks.entries()];
187
200
  while (pending.length) {
@@ -197,7 +210,7 @@ async function buildLazyChunks(rootOutdir: string, chunks: Map<string, string>,
197
210
  format: "esm",
198
211
  splitting: false,
199
212
  sourcemap: "none",
200
- minify: true,
213
+ minify: minifyOption(keepNames),
201
214
  plugins: [lazyChunkPlugin(rootOutdir, dirname(output), nested)],
202
215
  });
203
216
  if (!result.success) {
@@ -41,6 +41,7 @@ export function WebButton({
41
41
  onPress,
42
42
  variant = "secondary",
43
43
  disabled = false,
44
+ pending = false,
44
45
  active = false,
45
46
  shortcut,
46
47
  width,
@@ -61,6 +62,7 @@ export function WebButton({
61
62
  aria-keyshortcuts={ariaKeyShortcut(shortcut)}
62
63
  title={title}
63
64
  aria-expanded={expanded}
65
+ aria-busy={pending || undefined}
64
66
  disabled={disabled}
65
67
  onMouseDown={(event) => {
66
68
  if (stopPropagation) event.stopPropagation();
@@ -15,14 +15,15 @@ export function setMacosDockBadge(label: string): void {
15
15
  const dockTile = objc.send(app, objc.selector("dockTile"));
16
16
  if (!dockTile) return;
17
17
  const text = label
18
- ? objc.sendObject(
18
+ ? objc.sendCString(
19
19
  objc.send(objc.getClass("NSString"), objc.selector("alloc")),
20
20
  objc.selector("initWithUTF8String:"),
21
21
  cString(label),
22
22
  )
23
- : null;
23
+ : 0n;
24
24
  // AppKit only takes this on the main thread, and Bun runs on another one.
25
- // The call retains the string until it has run; a null label is nil.
25
+ // The call retains the string until it has run; no label is nil. A label as
26
+ // short as a count is a tagged pointer, which is why objects travel as ids.
26
27
  objc.sendOnMainThread(
27
28
  dockTile,
28
29
  objc.selector("performSelectorOnMainThread:withObject:waitUntilDone:"),
@@ -1,11 +1,10 @@
1
- import type { Pointer } from "bun:ffi";
2
1
  import { appIconColors, appIconSvg } from "./app-icon";
3
2
  import { getTheme } from "../../../../theme/themes";
4
- import { objcOrNull, type Objc } from "./macos-objc";
3
+ import { objcOrNull, type Objc, type ObjcId } from "./macos-objc";
5
4
 
6
5
  let appliedThemeId: string | null = null;
7
6
 
8
- function createImage(objc: Objc, svg: string): Pointer | null {
7
+ function createImage(objc: Objc, svg: string): ObjcId | null {
9
8
  const bytes = Buffer.from(svg);
10
9
  const data = objc.sendBytes(
11
10
  objc.send(objc.getClass("NSData"), objc.selector("alloc")),
@@ -4,28 +4,39 @@ const LIBOBJC = "/usr/lib/libobjc.A.dylib";
4
4
 
5
5
  export type Objc = ReturnType<typeof loadObjc>;
6
6
 
7
+ /** An Objective-C object or class as its address, `0n` for nil. */
8
+ export type ObjcId = bigint;
9
+
7
10
  let cachedObjc: Objc | null | undefined;
8
11
 
9
12
  export const cString = (value: string) => Buffer.from(`${value}\0`);
10
13
 
14
+ // Objects cross the FFI as u64, never as ptr. Bun hands a ptr result to
15
+ // JavaScript as a double, exact only up to 2**53, and Foundation keeps short
16
+ // strings and small numbers (a Dock badge label such as "3") in tagged
17
+ // pointers whose data sits in the top bits. Through a ptr they come back
18
+ // rounded, and the next message to one crashes the process.
19
+ const ID = FFIType.u64;
20
+
11
21
  // objc_msgSend has to be called with each method's exact C signature, so it is
12
22
  // bound once per call shape.
13
23
  function loadObjc() {
14
24
  const runtime = dlopen(LIBOBJC, {
15
- objc_getClass: { args: [FFIType.ptr], returns: FFIType.ptr },
25
+ objc_getClass: { args: [FFIType.ptr], returns: ID },
16
26
  sel_registerName: { args: [FFIType.ptr], returns: FFIType.ptr },
17
27
  }).symbols;
18
28
  const msgSend = <const Args extends FFIType[], Returns extends FFIType>(args: Args, returns: Returns) =>
19
- dlopen(LIBOBJC, { objc_msgSend: { args: [FFIType.ptr, FFIType.ptr, ...args], returns } }).symbols.objc_msgSend;
29
+ dlopen(LIBOBJC, { objc_msgSend: { args: [ID, FFIType.ptr, ...args], returns } }).symbols.objc_msgSend;
20
30
 
21
31
  return {
22
32
  getClass: (name: string) => runtime.objc_getClass(cString(name)),
23
33
  selector: (name: string) => runtime.sel_registerName(cString(name)),
24
- send: msgSend([], FFIType.ptr),
34
+ send: msgSend([], ID),
25
35
  sendVoid: msgSend([], FFIType.void),
26
- sendObject: msgSend([FFIType.ptr], FFIType.ptr),
27
- sendBytes: msgSend([FFIType.ptr, FFIType.u64], FFIType.ptr),
28
- sendOnMainThread: msgSend([FFIType.ptr, FFIType.ptr, FFIType.bool], FFIType.void),
36
+ sendObject: msgSend([ID], ID),
37
+ sendCString: msgSend([FFIType.ptr], ID),
38
+ sendBytes: msgSend([FFIType.ptr, FFIType.u64], ID),
39
+ sendOnMainThread: msgSend([FFIType.ptr, ID, FFIType.bool], FFIType.void),
29
40
  };
30
41
  }
31
42
 
@@ -1,4 +1,4 @@
1
- import { futuresGenericListing, futuresGenericPriceBasis } from "../../utils/futures-generic";
1
+ import { futuresListingPriceBasis } from "../../utils/futures-generic";
2
2
  import type { TimeRange } from "../../time-series/range";
3
3
  import { RANGE_HISTORY_RESOLUTION } from "../../time-series/resolution";
4
4
  import type {
@@ -55,6 +55,12 @@ function isImpossiblePreviousClose(
55
55
  && previousClose * IMPOSSIBLE_CLOSE_FACTOR < low52w;
56
56
  }
57
57
 
58
+ /** A quote that says it is something other than a future (an equity, a fund) keeps its own price basis. */
59
+ function isNonFutureType(instrumentType: string | undefined): boolean {
60
+ const type = instrumentType?.trim().toUpperCase();
61
+ return !!type && type !== "FUTURE" && type !== "FUTURES" && type !== "FUT";
62
+ }
63
+
58
64
  export function mapQuote(
59
65
  quote: CloudQuotePayload,
60
66
  providerMeta?: CloudProviderMeta,
@@ -76,12 +82,14 @@ export function mapQuote(
76
82
  const changePercent = !impossibleClose && typeof quote.changePercent === "number" && Number.isFinite(quote.changePercent)
77
83
  ? quote.changePercent
78
84
  : Number.NaN;
79
- // A generic future (VX1, TY1) reads in points or 32nds, not dollars.
80
- const generic = futuresGenericListing(quote.symbol, quote.listingExchangeName ?? quote.exchangeName);
85
+ // A generic future (VX1, TY1) reads in points or 32nds, and an index future's
86
+ // continuous symbol or contract (ES=F, SXOZ26.CME) in points, not dollars.
87
+ const futuresBasis = isNonFutureType(quote.instrumentType) ? null
88
+ : futuresListingPriceBasis(quote.symbol, quote.listingExchangeName ?? quote.exchangeName);
81
89
  const priceObservation = quote.priceBasis === "nav" ? "nav" : undefined;
82
90
  const priceBasis = quote.priceBasis === "nav"
83
91
  ? "per-unit"
84
- : (generic ? futuresGenericPriceBasis(generic) : null) ?? quote.priceBasis;
92
+ : futuresBasis ?? quote.priceBasis;
85
93
  return withoutUndefinedFields(reconcileQuoteDayRange({
86
94
  ...quote,
87
95
  priceBasis,
@@ -83,7 +83,7 @@ import { getPricePointTimestamp, isPriceHistoryStaleForCurrentWindow } from "../
83
83
  import { timeZoneLabel, zonedDateKey, zonedWallClockMs } from "../utils/zoned-date-time";
84
84
  import { formatShortDate } from "../utils/datetime-format";
85
85
  import { latestSessionStart, latestTradingSessionOpen, listingTimeZone, priorSessionClose } from "../market-data/market/trading-sessions";
86
- import { futuresGenericCaption, futuresGenericListing, futuresGenericPriceBasis } from "../utils/futures-generic";
86
+ import { futuresGenericCaption, futuresGenericListing, futuresListingPriceBasis } from "../utils/futures-generic";
87
87
  import { isOhlcSeriesStyle } from "./spec";
88
88
  import { SERIES_COLORS } from "../theme/series-colors";
89
89
  import type {
@@ -1166,8 +1166,10 @@ function baseSecuritySeries(
1166
1166
  : field.unit;
1167
1167
  const generic = field.unitGroup === "price"
1168
1168
  ? futuresGenericListing(spec.source.instrument.symbol, spec.source.instrument.exchange) : null;
1169
- // A generic future on an index, VIX or Treasuries reads in points or 32nds, not dollars.
1170
- const genericBasis = generic && isMarketFieldId(field.id) ? futuresGenericPriceBasis(generic) : null;
1169
+ // A generic future on an index, VIX or Treasuries reads in points or 32nds, and an index
1170
+ // future's continuous symbol or contract in points, not dollars, as its quote does.
1171
+ const genericBasis = field.unitGroup === "price" && isMarketFieldId(field.id)
1172
+ ? futuresListingPriceBasis(spec.source.instrument.symbol, spec.source.instrument.exchange) : null;
1171
1173
  const genericUnit = genericBasis === "points" ? "points" : genericBasis === "thirty-seconds" ? "32nds" : null;
1172
1174
  const unit = field.id === "market.volume" ? volumeUnit ?? ""
1173
1175
  : genericUnit ?? (unitTemplate.startsWith("currency") && currency ? unitTemplate.replace("currency", currency) : unitTemplate);
@@ -0,0 +1,128 @@
1
+ import { useContext, useEffect, useRef, useState } from "react";
2
+ import { isLiveResizing, onLiveResizeEnd } from "./live-resize";
3
+ import { LivePaneFrameContext, type CellSize } from "./live-stretch";
4
+
5
+ /**
6
+ * How long a box's size has to hold still before what is laid out in it
7
+ * settles at that size. A resize's content catches up every 100 ms
8
+ * (live-resize.ts), so a pane under the pointer stays resizing between its
9
+ * catch-ups.
10
+ */
11
+ export const RESIZE_SETTLE_MS = 120;
12
+
13
+ /**
14
+ * How far a box may stretch from the size its layout was made for, on either
15
+ * axis, before the layout is made again mid-resize: past this, tiles and
16
+ * blocks drawn in percentages of the old size would look squashed or pulled.
17
+ */
18
+ const MAX_HELD_STRETCH = 1.5;
19
+
20
+ export interface ResizeHold {
21
+ /** The box's size as it last rendered. */
22
+ size: CellSize;
23
+ /** While the box is resizing, the size its layout holds to; null at rest. */
24
+ held: CellSize | null;
25
+ }
26
+
27
+ function stretch(from: number, to: number): number {
28
+ return from > 0 && to > 0 ? Math.max(from / to, to / from) : Infinity;
29
+ }
30
+
31
+ /**
32
+ * The box rendered at `size`: the first change of a resize holds the layout at
33
+ * the size it had, later ones keep it there, and a stretch past
34
+ * MAX_HELD_STRETCH lays it out again at the new size (still resizing).
35
+ */
36
+ export function nextResizeHold(state: ResizeHold, size: CellSize, maxStretch = MAX_HELD_STRETCH): ResizeHold {
37
+ if (state.size.width === size.width && state.size.height === size.height) return state;
38
+ const held = state.held ?? state.size;
39
+ const far = Math.max(stretch(held.width, size.width), stretch(held.height, size.height)) > maxStretch;
40
+ return { size, held: far ? size : held };
41
+ }
42
+
43
+ /** At rest: the layout takes the box's size. */
44
+ export function settleResizeHold(state: ResizeHold): ResizeHold {
45
+ return state.held ? { size: state.size, held: null } : state;
46
+ }
47
+
48
+ export interface SettledLayoutSize extends CellSize {
49
+ /**
50
+ * The box is being resized: from the first move of a resize that has its
51
+ * pane's frame, or its first change of size, until it has held still for
52
+ * RESIZE_SETTLE_MS with the pointer let go. Nothing laid out in it should
53
+ * animate meanwhile.
54
+ */
55
+ resizing: boolean;
56
+ }
57
+
58
+ /**
59
+ * Desktop: the size to lay out a surface at whose content is placed in
60
+ * percentages of its box (a heat map's tiles), and whether that box is being
61
+ * resized. A pane resize, a divider, a maximise or restore and a window
62
+ * resize all change the box's size, often once a frame; laying the content
63
+ * out again at every one of them costs a full restyle and moves every tile
64
+ * each time. Instead, while the size changes the layout holds to the size it
65
+ * had and the box stretches it by CSS, and once the size has held still it is
66
+ * laid out once, at the size the box settled on. A stretch too far from the
67
+ * held size lays it out again on the way.
68
+ *
69
+ * `resizing` is the shared signal for anything that animates when its layout
70
+ * changes (a glide, a fade, a pulse): none of it while the size is changing.
71
+ * Off (`enabled: false`, the terminal), the size is the box's at once.
72
+ */
73
+ export function useSettledLayoutSize(
74
+ width: number,
75
+ height: number,
76
+ { enabled = true }: { enabled?: boolean } = {},
77
+ ): SettledLayoutSize {
78
+ const [state, setState] = useState<ResizeHold>(() => ({ size: { width, height }, held: null }));
79
+ let current = state;
80
+ if (state.size.width !== width || state.size.height !== height) {
81
+ // Stored while rendering, so the render that has the new size already lays out at the held one.
82
+ current = enabled ? nextResizeHold(state, { width, height }) : { size: { width, height }, held: null };
83
+ setState(current);
84
+ }
85
+
86
+ // A resize that has this pane's frame: it holds still from the first move, before its content catches up.
87
+ const frame = useContext(LivePaneFrameContext);
88
+ const [framed, setFramed] = useState(false);
89
+ const framedRef = useRef(false);
90
+ useEffect(() => {
91
+ // Called on every move: only a resize starting or ending is news.
92
+ const follow = () => {
93
+ const next = enabled && frame !== null && frame.live() !== null;
94
+ if (next === framedRef.current) return;
95
+ framedRef.current = next;
96
+ setFramed(next);
97
+ };
98
+ follow();
99
+ return enabled ? frame?.subscribe(follow) : undefined;
100
+ }, [enabled, frame]);
101
+
102
+ useEffect(() => {
103
+ if (!state.held || framed) return;
104
+ let timer: ReturnType<typeof setTimeout> | null = null;
105
+ let stopWaiting: (() => void) | null = null;
106
+ const settle = () => {
107
+ timer = null;
108
+ // A box still under the pointer (a sidebar, a split) settles when it is let go, not when the pointer pauses.
109
+ if (isLiveResizing()) {
110
+ stopWaiting = onLiveResizeEnd(() => {
111
+ stopWaiting?.();
112
+ stopWaiting = null;
113
+ timer = setTimeout(settle, RESIZE_SETTLE_MS);
114
+ });
115
+ return;
116
+ }
117
+ setState(settleResizeHold);
118
+ };
119
+ timer = setTimeout(settle, RESIZE_SETTLE_MS);
120
+ return () => {
121
+ if (timer !== null) clearTimeout(timer);
122
+ stopWaiting?.();
123
+ };
124
+ }, [framed, state]);
125
+
126
+ const layout = current.held ?? current.size;
127
+ return { width: layout.width, height: layout.height, resizing: enabled && (current.held !== null || framed) };
128
+ }
@@ -29,3 +29,59 @@ export function isoDateDaysAgo(days: number, now = new Date()): string {
29
29
  export function isoDateYearsAgo(years: number, now = new Date()): string {
30
30
  return isoDateBefore(now, years, 0);
31
31
  }
32
+
33
+ /** YYYY-MM-DD for a day the calendar has; null for a month or day out of range. */
34
+ function calendarDay(year: number, month: number, day: number): string | null {
35
+ const date = new Date(Date.UTC(year, month - 1, day));
36
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
37
+ ? date.toISOString().slice(0, 10)
38
+ : null;
39
+ }
40
+
41
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
42
+ /** A day and a month in either order, then a four-digit year, with one separator throughout: 14/10/2026, 14.10.2026, 14-10-2026. */
43
+ const YEAR_LAST = /^(\d{1,2})([/.-])(\d{1,2})\2(\d{4})$/;
44
+ /** A four-digit year first, written another way than YYYY-MM-DD: 2026/10/14, 2026.10.14, 2026-10-4. */
45
+ const YEAR_FIRST = /^(\d{4})([/.-])(\d{1,2})\2(\d{1,2})$/;
46
+
47
+ /**
48
+ * The YYYY-MM-DD date that a date written another way names: 14/10/2026 and
49
+ * 10/14/2026 are 2026-10-14, since a part over 12 can only be the day. When
50
+ * both parts could be the month (03/04/2026), only dots are read, day first,
51
+ * as dotted dates are written (`assumedDayFirst`); a slash or a dash is
52
+ * written both ways round, so that is null. Null as well for text that is not
53
+ * a date of the calendar.
54
+ */
55
+ function isoDateReading(value: string): { date: string; assumedDayFirst: boolean } | null {
56
+ const text = value.trim();
57
+ const yearFirst = YEAR_FIRST.exec(text);
58
+ if (yearFirst) {
59
+ const date = calendarDay(Number(yearFirst[1]), Number(yearFirst[3]), Number(yearFirst[4]));
60
+ return date && date !== text ? { date, assumedDayFirst: false } : null;
61
+ }
62
+ const yearLast = YEAR_LAST.exec(text);
63
+ if (!yearLast) return null;
64
+ const [first, second, year] = [Number(yearLast[1]), Number(yearLast[3]), Number(yearLast[4])];
65
+ const monthFirst = first <= 12 && second > 12;
66
+ const either = first <= 12 && second <= 12 && first !== second;
67
+ if (either && yearLast[2] !== ".") return null;
68
+ const date = monthFirst ? calendarDay(year, first, second) : calendarDay(year, second, first);
69
+ return date ? { date, assumedDayFirst: either } : null;
70
+ }
71
+
72
+ /**
73
+ * A date option's value, checked before any request is made: YYYY-MM-DD as
74
+ * typed, or an error that names the format. A date written another way gets
75
+ * the option to type instead (`--from 2026-10-14` for 14/10/2026); one whose
76
+ * day and month could be either way round gets an example of the format. An
77
+ * empty value is no date.
78
+ */
79
+ export function isoDateOption(value: string, flag: string, now = new Date()): string {
80
+ const text = value.trim();
81
+ const iso = ISO_DATE.exec(text);
82
+ if (!text || (iso && calendarDay(Number(iso[1]), Number(iso[2]), Number(iso[3])))) return text;
83
+ const format = `Use a --${flag} date in YYYY-MM-DD format`;
84
+ const reading = isoDateReading(text);
85
+ if (reading) throw new Error(`${format}: type --${flag} ${reading.date} for ${text}${reading.assumedDayFirst ? " (day first)" : ""}.`);
86
+ throw new Error(`${format}, such as --${flag} ${isoDateToday(now)}.`);
87
+ }
@@ -92,3 +92,34 @@ export function parsePastedNumber(value: unknown): PastedNumber {
92
92
  export function ambiguousNumberMessage(text: string, reading: { thousands: number; decimal: number }): string {
93
93
  return `${text.trim()} could be ${reading.thousands} (thousands) or ${reading.decimal} (decimal comma); type ${reading.thousands} or ${reading.decimal}`;
94
94
  }
95
+
96
+ /** What a dot and exactly three digits after one to three (`81.000`, `81.250`) could mean. */
97
+ export interface ThousandsDotReadings {
98
+ /** The number as written, the dot a decimal point: 81. */
99
+ decimal: number;
100
+ /** The dot as many locales write it, between thousands: 81000. */
101
+ thousands: number;
102
+ }
103
+
104
+ const THOUSANDS_DOT = /^([+-]?)([1-9]\d{0,2})\.(\d{3})$/;
105
+
106
+ /** Both readings of `81.000`; null for any other text, which has one. */
107
+ export function thousandsDotReadings(value: unknown): ThousandsDotReadings | null {
108
+ const match = THOUSANDS_DOT.exec(typeof value === "string" ? value.trim() : "");
109
+ if (!match) return null;
110
+ const sign = match[1] === "-" ? -1 : 1;
111
+ return { decimal: sign * Number(`${match[2]}.${match[3]}`), thousands: sign * Number(`${match[2]}${match[3]}`) };
112
+ }
113
+
114
+ /**
115
+ * Whether a dot and three digits was meant as a thousands separator, beside a
116
+ * price it must be near (the last price): the number as written is more than
117
+ * 90% away from it, while the thousands reading lands within 10%. A plain
118
+ * dotted number otherwise keeps its meaning, and without a reference it is
119
+ * read as written.
120
+ */
121
+ export function meantThousandsDot(readings: ThousandsDotReadings, reference: number | null | undefined): boolean {
122
+ if (reference == null || !Number.isFinite(reference) || reference === 0) return false;
123
+ const scale = Math.abs(reference);
124
+ return Math.abs(readings.decimal - reference) * 10 > scale * 9 && Math.abs(readings.thousands - reference) * 10 <= scale;
125
+ }
@@ -28,9 +28,11 @@ export interface FuturesGeneric {
28
28
 
29
29
  /** Roots Gloom Cloud archives daily, by the venue that lists them. */
30
30
  const ROOT_VENUES: Readonly<Record<string, string>> = Object.fromEntries(([
31
- ["CME", ["ES", "NQ", "RTY", "6E", "6J", "6B", "6A", "6C", "6S", "SR3", "LE", "GF", "HE", "LBR", "DC", "CSC", "GD", "BTC", "ETH", "SOL", "XRP"]],
32
- ["CBT", ["YM", "ZT", "ZF", "ZN", "ZB", "UB", "ZQ", "ZC", "ZS", "ZW", "ZM", "ZL", "KE", "ZO", "ZR"]],
33
- ["NYM", ["CL", "BZ", "NG", "RB", "HO", "PL", "PA", "B0", "TTF"]],
31
+ ["CME", ["ES", "NQ", "RTY", "EMD", "RS1", "NKD", "NIY", "MES", "MNQ", "M2K",
32
+ "XAE", "XAB", "XAI", "XAY", "XAP", "XAV", "XAF", "XAK", "XAZ", "XAU", "XAR", "SXO", "SXR", "SXT", "SXB", "SXI", "SOX",
33
+ "6E", "6J", "6B", "6A", "6C", "6S", "SR3", "LE", "GF", "HE", "LBR", "DC", "CSC", "GD", "BTC", "ETH", "SOL", "XRP"]],
34
+ ["CBT", ["YM", "MYM", "RX", "ZT", "ZF", "ZN", "ZB", "UB", "ZQ", "ZC", "ZS", "ZW", "ZM", "ZL", "KE", "ZO", "ZR"]],
35
+ ["NYM", ["CL", "QM", "MCL", "BZ", "NG", "QG", "MNG", "HH", "RB", "HO", "PL", "PA", "B0", "TTF"]],
34
36
  ["CMX", ["GC", "SI", "HG", "ALI", "HRC", "UX"]],
35
37
  ["NYB", ["KC", "SB", "CC", "CT", "OJ"]],
36
38
  ["CFE", ["VX"]],
@@ -128,16 +130,35 @@ function futuresGenericOrdinal(position: number): string {
128
130
  return `${position}${tens >= 11 && tens <= 13 ? "th" : ["th", "st", "nd", "rd"][position % 10] ?? "th"}`;
129
131
  }
130
132
 
133
+ /** Equity index, sector and industry futures, the S&P GSCI and VIX: their prices are index points. */
134
+ const POINT_ROOTS: ReadonlySet<string> = new Set(["ES", "NQ", "RTY", "YM", "GD", "VX", "EMD", "RS1", "NKD", "NIY", "MES", "MNQ", "M2K", "MYM", "RX",
135
+ "XAE", "XAB", "XAI", "XAY", "XAP", "XAV", "XAF", "XAK", "XAZ", "XAU", "XAR", "SXO", "SXR", "SXT", "SXB", "SXI", "SOX"]);
136
+
131
137
  /**
132
138
  * How a generic's price reads: index and VIX futures in points (not dollars),
133
139
  * Treasury futures in 32nds of par; everything else in its quote currency.
134
140
  */
135
141
  export function futuresGenericPriceBasis(generic: Pick<FuturesGeneric, "root">): "points" | "thirty-seconds" | null {
136
- if (["ES", "NQ", "RTY", "YM", "GD", "VX"].includes(generic.root)) return "points";
142
+ if (POINT_ROOTS.has(generic.root)) return "points";
137
143
  if (["ZT", "ZF", "ZN", "ZB", "UB"].includes(generic.root)) return "thirty-seconds";
138
144
  return null;
139
145
  }
140
146
 
147
+ /**
148
+ * How a futures listing's price reads, whichever form it takes: a generic by
149
+ * its root (VX1 points, TY1 32nds), and a continuous (`ES=F`, `SXO=F`) or
150
+ * listed contract (`SXOZ26.CME`) of an index, sector or industry root in index
151
+ * points, on the root's own venue or with none. The quote keeps its currency
152
+ * for what a tick is worth. Null for every other listing, a security that
153
+ * shares a root's code on another venue (XAU on Toronto, the XAR ETF) included.
154
+ */
155
+ export function futuresListingPriceBasis(symbol: string, exchange?: string | null): "points" | "thirty-seconds" | null {
156
+ const generic = futuresGenericListing(symbol, exchange);
157
+ if (generic) return futuresGenericPriceBasis(generic);
158
+ const listing = futuresListingVenue(symbol, exchange);
159
+ return listing?.root && ROOT_VENUES[listing.root] === listing.venue && POINT_ROOTS.has(listing.root) ? "points" : null;
160
+ }
161
+
141
162
  /** Whether a root's date rules count back from first notice or from last trade. */
142
163
  export function futuresGenericNotice(root: string): "first notice" | "last trade" {
143
164
  return FIRST_NOTICE_ROOTS.has(root) ? "first notice" : "last trade";
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "hosts": [
3
+ "api.crypto.com",
3
4
  "api.elections.kalshi.com",
4
5
  "api.fda.gov",
5
6
  "api.fiscaldata.treasury.gov",
7
+ "api.gemini.com",
8
+ "api.limitless.exchange",
6
9
  "api.publisher.jodidata.org",
7
10
  "api.votehub.com",
8
11
  "apps.fas.usda.gov",
9
12
  "clinicaltrials.gov",
10
13
  "clob.polymarket.com",
11
14
  "data-api.polymarket.com",
15
+ "data.pascal.trade",
12
16
  "efts.sec.gov",
13
17
  "gamma-api.polymarket.com",
18
+ "gateway.polymarket.us",
14
19
  "markets.newyorkfed.org",
15
20
  "nasdaqtrader.com",
16
21
  "production.dataviz.cnn.io",