gloomberb 0.17.2 → 0.17.3

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 (159) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/futures-curve.ts +16 -0
  3. package/src/api-client/index.ts +18 -8
  4. package/src/api-client/types.ts +3 -1
  5. package/src/cli/commands/market.ts +60 -9
  6. package/src/cli/commands/search.ts +43 -2
  7. package/src/cli/commands/ticker.ts +100 -21
  8. package/src/cli/desktop-pane-shot.ts +34 -9
  9. package/src/cli/fx-pair.ts +17 -0
  10. package/src/cli/glossary.ts +134 -6
  11. package/src/cli/history-rows.ts +16 -4
  12. package/src/cli/index.ts +44 -12
  13. package/src/cli/known-gaps.ts +21 -2
  14. package/src/cli/listing-arg.ts +19 -4
  15. package/src/cli/listing-suggestion.ts +33 -2
  16. package/src/cli/pane-functions/dom.ts +39 -6
  17. package/src/cli/pane-functions/freshness.ts +48 -5
  18. package/src/cli/pane-functions/function-help.ts +1 -1
  19. package/src/cli/pane-functions/headless.ts +85 -19
  20. package/src/cli/pane-functions/index.ts +60 -4
  21. package/src/cli/pane-functions/market-session.ts +69 -6
  22. package/src/cli/pane-functions/resolver.ts +4 -2
  23. package/src/cli/pane-functions/screenshot.ts +14 -1
  24. package/src/cli/scroll-truncation.ts +5 -1
  25. package/src/cli/symbol-args.ts +41 -3
  26. package/src/cli/symbol-list.ts +19 -7
  27. package/src/cli/unknown-command.ts +70 -0
  28. package/src/components/chart/native/surface/visibility.ts +37 -7
  29. package/src/components/layout/header.tsx +348 -102
  30. package/src/components/markdown-text.tsx +40 -66
  31. package/src/components/market-board/index.tsx +12 -6
  32. package/src/components/reader/detail-reader.tsx +28 -6
  33. package/src/components/ticker/badge/index.tsx +2 -0
  34. package/src/components/ui/display.tsx +6 -1
  35. package/src/components/ui/external-link.tsx +10 -2
  36. package/src/data/memory-resource-store.ts +1 -1
  37. package/src/data/resource-store.ts +13 -1
  38. package/src/docs/snapshot.json +13 -13
  39. package/src/i18n/es.ts +1 -0
  40. package/src/i18n/ja.ts +1 -0
  41. package/src/i18n/ko.ts +1 -0
  42. package/src/i18n/zh-cn.ts +1 -0
  43. package/src/i18n/zh-tw.ts +1 -0
  44. package/src/market-data/futures-contract.ts +10 -0
  45. package/src/market-data/market/format.ts +7 -2
  46. package/src/market-data/market/freshness.ts +25 -1
  47. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  48. package/src/market-data/quotes/resolution.ts +2 -0
  49. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  50. package/src/plugins/builtin/attention/headless.ts +19 -10
  51. package/src/plugins/builtin/attention/model.ts +22 -0
  52. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  53. package/src/plugins/builtin/changelog/index.tsx +11 -8
  54. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  55. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  56. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  57. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  58. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  59. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  60. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  61. package/src/plugins/builtin/chat/layout.ts +91 -31
  62. package/src/plugins/builtin/chat/markdown.ts +94 -0
  63. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  64. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  65. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  66. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  67. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  68. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  69. package/src/plugins/builtin/company-attention/model.ts +53 -2
  70. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  71. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  72. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  73. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  74. package/src/plugins/builtin/cot/client.ts +12 -4
  75. package/src/plugins/builtin/cot/pane.tsx +2 -2
  76. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  77. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  78. package/src/plugins/builtin/debug/index.tsx +15 -15
  79. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  80. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  81. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  82. package/src/plugins/builtin/docs/index.tsx +5 -3
  83. package/src/plugins/builtin/docs/pane.tsx +18 -1
  84. package/src/plugins/builtin/docs/targets.ts +5 -3
  85. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  86. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  87. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  88. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  89. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  90. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  91. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  92. package/src/plugins/builtin/futures/index.tsx +32 -4
  93. package/src/plugins/builtin/futures/model.ts +65 -3
  94. package/src/plugins/builtin/futures/table.tsx +31 -11
  95. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  96. package/src/plugins/builtin/holders/pane.tsx +6 -1
  97. package/src/plugins/builtin/mna/client.ts +12 -2
  98. package/src/plugins/builtin/mna/detail.tsx +2 -2
  99. package/src/plugins/builtin/mna/pane.tsx +2 -2
  100. package/src/plugins/builtin/notes/owner.tsx +5 -4
  101. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  102. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  103. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  104. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  105. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  106. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  107. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -9
  108. package/src/plugins/builtin/research/event-model.ts +14 -0
  109. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  110. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  111. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  112. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  113. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  114. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  115. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  116. package/src/plugins/builtin/research-search/data.ts +11 -0
  117. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  118. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  119. package/src/plugins/builtin/seasonality/client.ts +23 -0
  120. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  121. package/src/plugins/builtin/sectors/index.tsx +7 -3
  122. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  123. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  124. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  125. package/src/plugins/builtin/short-interest/model.ts +62 -3
  126. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  127. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  128. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  129. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  130. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  131. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  132. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  133. package/src/plugins/builtin/themes/headless.ts +1 -1
  134. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  135. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  136. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  137. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  138. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  139. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  140. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  141. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  142. package/src/renderers/dom/styles.css +6 -0
  143. package/src/sources/gloomberb-cloud/normalizers.ts +1 -0
  144. package/src/sources/provider-router/batches.ts +2 -1
  145. package/src/sources/provider-router/cache.ts +3 -0
  146. package/src/sync/baseline.ts +19 -14
  147. package/src/sync/controller.ts +140 -40
  148. package/src/sync/core-contributors.ts +256 -32
  149. package/src/sync/types.ts +44 -0
  150. package/src/time-series/resolve.ts +19 -2
  151. package/src/types/data-provider.ts +8 -0
  152. package/src/types/financials.ts +40 -4
  153. package/src/types/headless.ts +43 -0
  154. package/src/types/plugin.ts +7 -0
  155. package/src/utils/declared-dividends.ts +39 -0
  156. package/src/utils/forward-dividend.ts +34 -11
  157. package/src/utils/fundamentals.ts +13 -2
  158. package/src/utils/markdown.ts +180 -0
  159. package/src/utils/market-capitalization.ts +22 -0
@@ -1,4 +1,4 @@
1
- import type { MarketState } from "../../types/financials";
1
+ import type { MarketState, QuoteIndexSession } from "../../types/financials";
2
2
  import { canonicalExchange, EXCHANGE_TIME_ZONES, isUsListingExchange } from "../../utils/exchanges";
3
3
  import { hasPublishedApacCalendar, isPublishedApacClosure } from "../published-apac-sessions";
4
4
  import { hasPublishedCnCalendar, isPublishedCnClosure } from "../published-cn-sessions";
@@ -675,3 +675,27 @@ function isTimestampStaleForExchangeSessionUnsafe(
675
675
  }
676
676
  return false;
677
677
  }
678
+
679
+ function readSessionWindow(value: unknown): { start: number; end: number } | null {
680
+ const { start, end } = (value ?? {}) as { start?: unknown; end?: unknown };
681
+ return typeof start === "number" && typeof end === "number" && Number.isFinite(start) && Number.isFinite(end) && end > start
682
+ ? { start, end }
683
+ : null;
684
+ }
685
+
686
+ /**
687
+ * The regular sessions an index quote carries, read without trusting the
688
+ * shape it arrived in: its zone, the session its day follows and the latest
689
+ * ones. Null when the zone or the current session is missing.
690
+ */
691
+ export function readIndexSession(value: unknown): QuoteIndexSession | null {
692
+ const { timeZone, current, regular } = (value ?? {}) as { timeZone?: unknown; current?: unknown; regular?: unknown };
693
+ const now = readSessionWindow(current);
694
+ if (typeof timeZone !== "string" || !timeZone.includes("/") || !now) return null;
695
+ return {
696
+ timeZone,
697
+ current: now,
698
+ // The latest two: the one a print after the close belongs to, and the one before it.
699
+ regular: Array.isArray(regular) ? regular.flatMap((window) => readSessionWindow(window) ?? []).slice(-2) : [],
700
+ };
701
+ }
@@ -1,4 +1,4 @@
1
- import type { Fundamentals, Quote, TickerFinancials } from "../../types/financials";
1
+ import type { EnterpriseValueCheck, Fundamentals, Quote, TickerFinancials } from "../../types/financials";
2
2
  import { priceEarningsEps } from "../../utils/fundamentals";
3
3
  import { getRegularSessionDisplay } from "../market/status";
4
4
 
@@ -39,6 +39,33 @@ interface FigureMoves {
39
39
  enterpriseValue: number;
40
40
  }
41
41
 
42
+ /**
43
+ * The served EV check at the moved figures, so its note reads the EV and the
44
+ * market cap the rows show: the EV it describes is the moved one, and the
45
+ * computed EV moves with the cap. A withdrawn EV, shown nowhere, moves with
46
+ * the cap as the computed one does. The status stays the one Gloom Cloud
47
+ * judged.
48
+ */
49
+ function movedEnterpriseValueCheck(
50
+ check: EnterpriseValueCheck | undefined,
51
+ served: number | undefined,
52
+ enterpriseValue: number | undefined,
53
+ movedCap: number | undefined,
54
+ ): EnterpriseValueCheck | undefined {
55
+ if (!check || movedCap == null || !Number.isFinite(movedCap) || !Number.isFinite(check.marketCap)) return check;
56
+ const shift = movedCap - check.marketCap;
57
+ if (shift === 0 && served === enterpriseValue) return check;
58
+ const source = check.status !== "withdrawn" && served === check.source && enterpriseValue != null
59
+ ? enterpriseValue
60
+ : check.source + shift;
61
+ return {
62
+ ...check,
63
+ source,
64
+ marketCap: movedCap,
65
+ ...(check.computed != null && Number.isFinite(check.computed) ? { computed: check.computed + shift } : {}),
66
+ };
67
+ }
68
+
42
69
  function moveFigures(fundamentals: Fundamentals, currency: string | undefined, moves: FigureMoves): Fundamentals {
43
70
  const cap = fundamentals.marketCapCurrency === currency ? fundamentals.marketCap : undefined;
44
71
  const movedCap = cap != null ? scaledAmount(cap, moves.marketCap) : undefined;
@@ -47,8 +74,12 @@ function moveFigures(fundamentals: Fundamentals, currency: string | undefined, m
47
74
  const enterpriseValue = served != null && Number.isFinite(served) && served !== 0 && movedCap != null && Number.isFinite(movedCap)
48
75
  ? Math.round(served + movedCap * (1 - 1 / moves.enterpriseValue)) : served;
49
76
  const enterpriseValueRatio = positive(served) && positive(enterpriseValue) ? enterpriseValue / served : null;
77
+ const check = fundamentals.enterpriseValueCheck?.currency === currency
78
+ ? movedEnterpriseValueCheck(fundamentals.enterpriseValueCheck, served, enterpriseValue, movedCap)
79
+ : fundamentals.enterpriseValueCheck;
50
80
  return {
51
81
  ...fundamentals,
82
+ ...(check ? { enterpriseValueCheck: check } : {}),
52
83
  trailingPE: scaled(fundamentals.trailingPE, moves.trailingPE),
53
84
  forwardPE: scaled(fundamentals.forwardPE, moves.forwardPE),
54
85
  dividendYield: scaled(fundamentals.dividendYield, 1 / moves.dividendYield),
@@ -532,6 +532,8 @@ export function resolveCanonicalQuote(
532
532
  overnightChange: priceProvider?.overnightChange,
533
533
  overnightChangePercent: priceProvider?.overnightChangePercent,
534
534
  overnightPriceKind: priceProvider?.overnightPriceKind,
535
+ // An index's regular sessions come with the print they date.
536
+ indexSession: priceProvider?.indexSession,
535
537
  bid: resolved.bid as Quote["bid"],
536
538
  ask: resolved.ask as Quote["ask"],
537
539
  bidSize: resolved.bidSize as Quote["bidSize"],
@@ -1019,19 +1019,23 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
1019
1019
  />
1020
1020
  </Box>
1021
1021
  <Box flexDirection="row" gap={1}>
1022
- <Button
1023
- label={t("Change Password")}
1024
- active={activeField === "passwordAction"}
1025
- onPress={openPasswordDialog}
1026
- disabled={!!busy}
1027
- />
1028
- <Button
1029
- label={busy === "delete" ? t("Deleting...") : t("Delete Account")}
1030
- variant="danger"
1031
- active={activeField === "deleteAccountAction"}
1032
- onPress={() => { void deleteAccount(); }}
1033
- disabled={!!busy}
1034
- />
1022
+ <Box ref={fieldNodeRef("passwordAction")} flexDirection="row">
1023
+ <Button
1024
+ label={t("Change Password")}
1025
+ active={activeField === "passwordAction"}
1026
+ onPress={openPasswordDialog}
1027
+ disabled={!!busy}
1028
+ />
1029
+ </Box>
1030
+ <Box ref={fieldNodeRef("deleteAccountAction")} flexDirection="row">
1031
+ <Button
1032
+ label={busy === "delete" ? t("Deleting...") : t("Delete Account")}
1033
+ variant="danger"
1034
+ active={activeField === "deleteAccountAction"}
1035
+ onPress={() => { void deleteAccount(); }}
1036
+ disabled={!!busy}
1037
+ />
1038
+ </Box>
1035
1039
  </Box>
1036
1040
  </>
1037
1041
  ) : null}
@@ -1,6 +1,6 @@
1
1
  import type { HeadlessPaneDefinition } from "../../../types/plugin";
2
2
  import { fetchAttention } from "./client";
3
- import { ATTENTION_TABS, attentionTab, attentionWindow, attentionRows, WINDOWS } from "./model";
3
+ import { ATTENTION_TABS, attentionPreviewNote, attentionTab, attentionWindow, attentionRows, emptyAttention, WINDOWS } from "./model";
4
4
  export const attentionHeadless: HeadlessPaneDefinition<"bundle"> = {
5
5
  shape: "bundle", argument: { kind: "free-text", optional: true, placeholder: "TICKER", description: "Optional listing-qualified ticker, such as 6758:JPX." },
6
6
  options: [{ key: "tab", type: "enum", values: ATTENTION_TABS.map(({ value }) => ({ value })), defaultValue: "ranking", description: "Rankings, abnormal attention, groups, history or evidence." },
@@ -11,25 +11,34 @@ export const attentionHeadless: HeadlessPaneDefinition<"bundle"> = {
11
11
  async load(args, ctx) {
12
12
  const argument = Array.isArray(args.argument) ? args.argument.join(" ") : args.argument;
13
13
  const tab = attentionTab(args.options.tab);
14
- const data = await fetchAttention(attentionWindow(args.options.window), argument?.trim().toUpperCase() || undefined, ctx.apiClient);
14
+ const window = attentionWindow(args.options.window);
15
+ const asked = argument?.trim().toUpperCase() || undefined;
16
+ let data = await fetchAttention(window, asked, ctx.apiClient);
17
+ // The free preview picks its rows before it looks the ticker up, so one outside them comes back empty: show the preview's own rows and say they are not the ticker's.
18
+ const outside = !!asked && data.entitlement === "preview" && !data.rows.length && data.counts.rows > 0;
19
+ if (outside) data = await fetchAttention(window, undefined, ctx.apiClient);
20
+ const note = attentionPreviewNote(data, asked, outside);
21
+ const lock = note ? { errors: [note] } : {};
22
+ // A board that has published nothing yet says so, whatever the plan.
23
+ const empty = data.status === "ready" ? {} : { empty: emptyAttention(data) };
15
24
  const metadata = { asOf: data.asOf, stale: data.stale, complete: data.status === "ready" && !data.truncated,
16
25
  status: data.status, periodStart: data.periodStart, periodEnd: data.periodEnd, entitlement: data.entitlement,
17
26
  privacy: data.privacy, coverage: data.coverage, methodologyUrl: data.methodologyUrl, selectedListing: data.selectedListing ?? null, historyEvidence: data.historyEvidence ?? null, unit: "rounded researcher-hours" };
18
27
  // Published hour by hour, so not a feed, but the service says when publication has fallen behind.
19
28
  const freshness = { status: data.stale ? "stale" as const : "not-a-feed" as const, basis: "hourly publication" };
20
- if (tab === "sectors" || tab === "countries") return { metadata, freshness, sections: [{ title: tab === "sectors" ? "Sector attention" : "Country attention",
21
- columns: [{ key: "name", header: "Group" }, { key: "researchUnits", header: "Research hours" }, { key: "sharePct", header: "Share %" }, { key: "tickers", header: "Tickers" }], rows: data[tab].map((row) => ({ ...row })) }] };
22
- if (tab === "history") return { metadata, freshness, sections: [{ title: "Published hourly history", columns: [{ key: "symbol", header: "Ticker" },
29
+ if (tab === "sectors" || tab === "countries") return { metadata, freshness, ...lock, sections: [{ title: tab === "sectors" ? "Sector attention" : "Country attention",
30
+ columns: [{ key: "name", header: "Group" }, { key: "researchUnits", header: "Research hours" }, { key: "sharePct", header: "Share %" }, { key: "tickers", header: "Tickers" }], rows: data[tab].map((row) => ({ ...row })), ...empty }] };
31
+ if (tab === "history") return { metadata, freshness, ...lock, sections: [{ title: "Published hourly history", columns: [{ key: "symbol", header: "Ticker" },
23
32
  { key: "bucketStart", header: "Hour (UTC)" }, { key: "researchUnits", header: "Research hours" }],
24
- rows: data.history && data.selectedListing ? data.history.map((point) => ({ symbol: data.selectedListing!.symbol, ...point })) : data.rows.flatMap((row) => row.history.map((point) => ({ symbol: row.symbol, ...point }))) }] };
25
- if (tab === "evidence") return { metadata, freshness, sections: [{ title: "Publication evidence", columns: [{ key: "symbol", header: "Ticker" },
33
+ rows: data.history && data.selectedListing ? data.history.map((point) => ({ symbol: data.selectedListing!.symbol, ...point })) : data.rows.flatMap((row) => row.history.map((point) => ({ symbol: row.symbol, ...point }))), ...empty }] };
34
+ if (tab === "evidence") return { metadata, freshness, ...lock, sections: [{ title: "Publication evidence", columns: [{ key: "symbol", header: "Ticker" },
26
35
  { key: "periodStart", header: "Period start" }, { key: "periodEnd", header: "Period end" }, { key: "asOf", header: "Published" },
27
36
  { key: "confidence", header: "Quality" }, { key: "marketAsOf", header: "Market as of" }],
28
- rows: data.rows.map((row) => ({ symbol: row.symbol, ...row.evidence, baselinePeriods: row.baselinePeriods, marketAsOf: row.marketAsOf, news: row.news })) }] };
29
- return { metadata, freshness, sections: [{ title: tab === "abnormal" ? "Abnormal attention" : "Research ranking", columns: [
37
+ rows: data.rows.map((row) => ({ symbol: row.symbol, ...row.evidence, baselinePeriods: row.baselinePeriods, marketAsOf: row.marketAsOf, news: row.news })), ...empty }] };
38
+ return { metadata, freshness, ...lock, sections: [{ title: tab === "abnormal" ? "Abnormal attention" : "Research ranking", columns: [
30
39
  { key: "rank", header: "Rank" }, { key: "symbol", header: "Ticker" }, { key: "name", header: "Name" }, { key: "researchUnits", header: "Research hours" },
31
40
  { key: "sharePct", header: "Share %" }, { key: "zScore", header: "Latest-hour Z-score" }, { key: "priceChangePct", header: "Price %" },
32
41
  { key: "relativeVolume", header: "Relative volume" }, { key: "sector", header: "Sector" }, { key: "country", header: "Country" },
33
- ], rows: attentionRows(data.rows, tab, "", tab === "abnormal" ? "zScore" : "rank", tab === "abnormal" ? "desc" : "asc").map((row) => ({ ...row })) }] };
42
+ ], rows: attentionRows(data.rows, tab, "", tab === "abnormal" ? "zScore" : "rank", tab === "abnormal" ? "desc" : "asc").map((row) => ({ ...row })), ...empty }] };
34
43
  },
35
44
  };
@@ -1,5 +1,6 @@
1
1
  import type { AttentionPayload, AttentionPoint, AttentionRow, AttentionWindow } from "../../../api-client/attention";
2
2
  import type { DataTableColumn } from "../../../components";
3
+ import { countOf, freePreviewNote, previewCantFilter } from "../shared/free-preview";
3
4
  export const ATTENTION_TABS = [{ value: "ranking", label: "Ranking" }, { value: "abnormal", label: "Abnormal" },
4
5
  { value: "sectors", label: "Sectors" }, { value: "countries", label: "Countries" }, { value: "history", label: "History" }, { value: "evidence", label: "Evidence" }] as const;
5
6
  export type AttentionTab = typeof ATTENTION_TABS[number]["value"];
@@ -44,6 +45,27 @@ export function rowCell(row: AttentionRow, column: string) {
44
45
  export const emptyAttention = (data: AttentionPayload | null | undefined) => data?.status === "disabled"
45
46
  ? "Research attention collection is not enabled yet." : "Waiting for privacy-qualified research activity.";
46
47
 
48
+ /** How many tickers the free preview lists: the top of the board by research hours. */
49
+ const PREVIEW_ROWS = 3;
50
+
51
+ /**
52
+ * What the free preview of the board shows and what Pro adds, for the lock
53
+ * line of `fn ATTN`. The server picks the preview's rows before it looks a
54
+ * ticker up, so a ticker outside them comes back empty and `outside` says the
55
+ * rows are the board's, not the ticker's. Null when there is nothing to
56
+ * preview, which includes a board still collecting.
57
+ */
58
+ export function attentionPreviewNote(data: AttentionPayload, asked: string | undefined, outside: boolean): string | null {
59
+ if (data.entitlement !== "preview" || !data.rows.length) return null;
60
+ const total = Math.max(data.counts.rows, data.rows.length);
61
+ const listed = total > data.rows.length ? `${data.rows.length} of ${total.toLocaleString("en-US")} tickers` : countOf(data.rows.length, "ticker");
62
+ const shows = asked && outside ? `the top ${listed}, not ${asked}: ${previewCantFilter(["ticker"])}`
63
+ : asked ? `${data.rows[0]!.symbol}, one of the top ${PREVIEW_ROWS} of ${total.toLocaleString("en-US")} tickers, for the latest hour`
64
+ : `the top ${listed}, for the latest hour`;
65
+ const hidden = total - PREVIEW_ROWS;
66
+ return freePreviewNote({ shows, adds: `${hidden > 0 ? `the other ${hidden.toLocaleString("en-US")} tickers and ` : ""}hourly history for any ticker` });
67
+ }
68
+
47
69
  /** An unpublished hour is a gap, never a measured zero or an interpolated count. */
48
70
  export function historyPoints(history: readonly AttentionPoint[]) {
49
71
  const points = [...history].sort((a, b) => a.bucketStart.localeCompare(b.bucketStart));
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useMemo, useRef, useState } from "react";
2
2
  import { Box, ScrollBox } from "../../../ui";
3
3
  import { DataTableView, FieldGrid, KeyValueRow, Notice, QueryBar, Section, usePaneNoticeFooter, usePaneTabs, type GridField, type SelectControl } from "../../../components";
4
+ import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
4
5
  import { usePaneSettingValue, usePaneStateValue, useShortcut } from "../../../public/react";
5
6
  import { useAsyncResource } from "../../../react/async-resource";
6
7
  import { colors } from "../../../theme/colors";
@@ -70,6 +71,7 @@ export function BondCalculatorPane({ focused, width, height }: PaneProps) {
70
71
  }, [result, draft.quote, update]);
71
72
  const selectTab = useCallback((value: string) => { setActiveField(null); setTab(value); }, [setTab]);
72
73
  const { strip: tabStrip, rows: tabRows } = usePaneTabs({ tabs: TABS, activeValue: tab, onSelect: selectTab, focused: focused && !activeField, dense: true });
74
+ usePaneRefreshKey(() => { void load(); }, { focused });
73
75
  useShortcut((event) => {
74
76
  if (event.defaultPrevented || event.propagationStopped || event.ctrl || event.alt || event.meta || event.super) return;
75
77
  const consume = () => { event.preventDefault(); event.stopPropagation(); };
@@ -77,7 +79,6 @@ export function BondCalculatorPane({ focused, width, height }: PaneProps) {
77
79
  // Shifted letters are global chords (Shift+R refreshes everything).
78
80
  if (event.targetEditable || event.shift) return;
79
81
  if (event.name === "e") { consume(); setTab("valuation"); setActiveField("settlement"); }
80
- else if (event.name === "r") { consume(); void load(); }
81
82
  else if ((event.name === "j" || event.name === "k") && tab === "valuation") {
82
83
  consume();
83
84
  const index = activeField ? FIELDS.indexOf(activeField) : -1;
@@ -3,6 +3,7 @@ import { Box, TextAttributes, type ScrollBoxRenderable } from "../../../ui";
3
3
  import { usePaneInstance } from "../../../state/app/context";
4
4
  import {
5
5
  DataTableStackView,
6
+ PaneLinkMenu,
6
7
  PaneStatusBody,
7
8
  useExternalLinkFooter,
8
9
  type DataTableCell,
@@ -66,14 +67,16 @@ function ChangelogDetail({
66
67
  }) {
67
68
  // The stack title already carries the version.
68
69
  return (
69
- <DetailReader
70
- ref={scrollRef}
71
- resetScrollKey={release.id}
72
- width={width}
73
- meta={[formatShortDate(release.publishedAt, { fallback: "" })]}
74
- body={release.body}
75
- bodyFormat="markdown"
76
- />
70
+ <PaneLinkMenu>
71
+ <DetailReader
72
+ ref={scrollRef}
73
+ resetScrollKey={release.id}
74
+ width={width}
75
+ meta={[formatShortDate(release.publishedAt, { fallback: "" })]}
76
+ body={release.body}
77
+ bodyFormat="markdown"
78
+ />
79
+ </PaneLinkMenu>
77
80
  );
78
81
  }
79
82
 
@@ -79,6 +79,7 @@ export function DesktopSeriesEditorFields({ fields }: { fields: SeriesEditorFiel
79
79
  <Box flexDirection="row" width="100%" style={{ gap: 6 }}>
80
80
  <Box flexGrow={1} minWidth={0}>
81
81
  <SelectField
82
+ label={field.label}
82
83
  value={field.value}
83
84
  options={field.options}
84
85
  width="100%"
@@ -89,6 +90,7 @@ export function DesktopSeriesEditorFields({ fields }: { fields: SeriesEditorFiel
89
90
  </Box>
90
91
  ) : (
91
92
  <SelectField
93
+ label={field.label}
92
94
  value={field.value}
93
95
  options={field.options}
94
96
  width="100%"
@@ -8,6 +8,7 @@ import { useThemeColors } from "../../../../theme/theme-context";
8
8
  import { t, tf } from "../../../../i18n";
9
9
  import type { ChatAttachment } from "../../../../api-client";
10
10
  import type { ChatDraftAttachment } from "../controller/attachments";
11
+ import { chatPlainText } from "../markdown";
11
12
  import {
12
13
  CHAT_IMAGE_TYPES,
13
14
  chatImageReviewNote,
@@ -355,7 +356,7 @@ export function DesktopChatAttachmentStrip({
355
356
  }
356
357
 
357
358
  function imageAlt(index: number, count: number, caption: string, author: string): string {
358
- const text = caption.replace(/\s+/g, " ").trim();
359
+ const text = chatPlainText(caption).replace(/\s+/g, " ").trim();
359
360
  const position = count > 1 ? ` ${index + 1} of ${count}` : "";
360
361
  if (text) return `Image${position}: ${text.length > 120 ? `${text.slice(0, 117)}...` : text}`;
361
362
  return `Image${position} from @${author}`;
@@ -1,5 +1,6 @@
1
1
  import { ApiRequestError } from "../../../../api-client/errors";
2
2
  import type { ChatAttachment, ChatMessage } from "../../../../api-client";
3
+ import { chatPlainText } from "../markdown";
3
4
 
4
5
  /** What the server takes: four images a message, each up to 5 MB, as PNG, JPEG, WebP or GIF. */
5
6
  export const MAX_CHAT_IMAGES = 4;
@@ -76,10 +77,11 @@ function chatImageCountLabel(count: number): string {
76
77
  return count > 1 ? `[${count} images]` : "[image]";
77
78
  }
78
79
 
79
- /** A message read as one line: its images as a placeholder in front of the text. */
80
+ /** A message read as one line, without its markdown: its images as a placeholder in front of the text. */
80
81
  export function chatTextWithImages(content: string, imageCount = 0): string {
81
- if (imageCount <= 0) return content;
82
- const text = content.trim();
82
+ const plain = chatPlainText(content);
83
+ if (imageCount <= 0) return plain;
84
+ const text = plain.trim();
83
85
  const placeholder = chatImageCountLabel(imageCount);
84
86
  return text ? `${placeholder} ${text}` : placeholder;
85
87
  }
@@ -278,6 +278,11 @@ export function useChatFooter({
278
278
  if (replyToId) {
279
279
  menu.push({ id: "original-message", label: "Go to Original Message", onSelect: () => latest.current.jumpToMessage(replyToId) });
280
280
  }
281
+ // The message as its author wrote it, markdown and all; a selection copies what the row draws.
282
+ const messageText = composing ? "" : selectedMessage?.content ?? "";
283
+ if (messageText) {
284
+ menu.push({ id: "copy-message", label: "Copy Message Text", onSelect: () => { void rendererHost.copyText(messageText); } });
285
+ }
281
286
  if (needsProfileSetup) {
282
287
  menu.push({ id: "profile-setup", label: "Set Up Profile", onSelect: () => latest.current.openProfileSetup() });
283
288
  }
@@ -302,6 +307,7 @@ export function useChatFooter({
302
307
  notificationsEnabled,
303
308
  openTargetTitle,
304
309
  openTargets.length,
310
+ rendererHost,
305
311
  replyToId,
306
312
  selectedIdx,
307
313
  selectedMessage,
@@ -1,6 +1,6 @@
1
1
  import type { InlineTickerCatalogEntry } from "../../../../state/hooks/inline-tickers";
2
2
  import type { ChatAttachment } from "../../../../api-client";
3
- import { tokenizeInlineContent } from "../../../../utils/inline-content-tokenizer";
3
+ import { chatMessageTokens } from "../markdown";
4
4
  import { chatImageLabel } from "../attachments/model";
5
5
  import { wirePaneTarget, type ChatMessagePaneLink } from "../pane-links/model";
6
6
  import { chatPaneLinkPane, peekSharePreview } from "../pane-links/previews";
@@ -26,7 +26,8 @@ function paneLinkLabel(link: ChatMessagePaneLink): string {
26
26
  * What a message lets you open, in reading order: its chips (the panes its
27
27
  * share links and Wire link open), the ticker badges its text draws (a symbol
28
28
  * the catalog does not know stays plain text), its links, then its images.
29
- * `content` is the text the message draws, without the links its chips stand for.
29
+ * `content` is the text the message draws, without the links its chips stand
30
+ * for. Code is text, so nothing in it is offered.
30
31
  */
31
32
  export function chatMessageOpenTargets(
32
33
  content: string,
@@ -36,7 +37,14 @@ export function chatMessageOpenTargets(
36
37
  ): ChatOpenTarget[] {
37
38
  const targets: ChatOpenTarget[] = paneLinks.map((link) => ({ kind: "pane", link, label: paneLinkLabel(link) }));
38
39
  const seen = new Set<string>();
39
- for (const token of tokenizeInlineContent(content)) {
40
+ // The link just listed: a label with marks in it ("[the **keys**](url)") is several tokens of one link.
41
+ let open: Extract<ChatOpenTarget, { kind: "link" }> | null = null;
42
+ for (const token of chatMessageTokens(content)) {
43
+ if (token.kind === "link" && open?.url === token.url) {
44
+ open.label += token.value;
45
+ continue;
46
+ }
47
+ open = null;
40
48
  if (token.kind === "ticker") {
41
49
  const entry = catalog[token.symbol];
42
50
  if (!entry || entry.status === "missing" || seen.has(`ticker:${token.symbol}`)) continue;
@@ -44,7 +52,8 @@ export function chatMessageOpenTargets(
44
52
  targets.push({ kind: "ticker", symbol: token.symbol });
45
53
  } else if (token.kind === "link" && !seen.has(`link:${token.url}`)) {
46
54
  seen.add(`link:${token.url}`);
47
- targets.push({ kind: "link", url: token.url, label: token.value });
55
+ open = { kind: "link", url: token.url, label: token.value };
56
+ targets.push(open);
48
57
  }
49
58
  }
50
59
  attachments.forEach((attachment, index) => {
@@ -10,6 +10,7 @@ import { DesktopChatMessage, desktopChatMessageWidth } from "../message/desktop"
10
10
  import { UserProfilePopover } from "../message/profile-popover";
11
11
  import { TerminalChatMessage } from "../message/terminal";
12
12
  import { NewMessagesJump } from "./new-messages";
13
+ import { useWholeMessageCopy } from "./whole-message-copy";
13
14
 
14
15
  interface MutableRef<T> {
15
16
  current: T;
@@ -138,6 +139,7 @@ export function ChatTranscript({
138
139
  useLayoutEffect(() => {
139
140
  takeBackScrollPlace();
140
141
  }, [takeBackScrollPlace]);
142
+ useWholeMessageCopy(nativePaneChrome === true, messages);
141
143
  const reactionProps = {
142
144
  reactionsEnabled,
143
145
  currentUserId: user?.id ?? null,
@@ -0,0 +1,70 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { ChatMessage } from "../../../../api-client";
3
+
4
+ const BODY_SELECTOR = "[data-gloom-role=chat-message-body]";
5
+ const MESSAGE_ID_ATTRIBUTE = "data-gloom-chat-message-id";
6
+
7
+ /** Whether the selection takes in every character the body draws, first to last. */
8
+ function coversBody(range: Range, body: Element): boolean {
9
+ const walker = body.ownerDocument.createTreeWalker(body, 4 /* NodeFilter.SHOW_TEXT */);
10
+ let first: [Node, number] | null = null;
11
+ let last: [Node, number] | null = null;
12
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
13
+ const text = node.textContent ?? "";
14
+ const start = text.search(/\S/);
15
+ if (start < 0) continue;
16
+ first ??= [node, start];
17
+ last = [node, text.trimEnd().length];
18
+ }
19
+ if (!first || !last) return false;
20
+ try {
21
+ return range.comparePoint(first[0], first[1]) === 0 && range.comparePoint(last[0], last[1]) === 0;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * What a copy of the selection puts on the clipboard when it takes in whole
29
+ * messages of this list: each as it was written, marks and all, one per line.
30
+ * Null when it takes in part of a message, which copies as drawn, or anything
31
+ * outside this list's messages.
32
+ */
33
+ function wholeMessagesText(doc: Document, messages: readonly ChatMessage[]): string | null {
34
+ const selection = doc.getSelection();
35
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
36
+ const range = selection.getRangeAt(0);
37
+ const bodies = Array.from(doc.querySelectorAll(BODY_SELECTOR)).filter((body) => range.intersectsNode(body));
38
+ if (bodies.length === 0) return null;
39
+ const parts: string[] = [];
40
+ for (const body of bodies) {
41
+ const id = body.closest(`[${MESSAGE_ID_ATTRIBUTE}]`)?.getAttribute(MESSAGE_ID_ATTRIBUTE);
42
+ const message = id ? messages.find((candidate) => candidate.id === id) : undefined;
43
+ if (!message || !coversBody(range, body)) return null;
44
+ parts.push(message.content);
45
+ }
46
+ return parts.join("\n");
47
+ }
48
+
49
+ /**
50
+ * Desktop and web: copying whole messages copies what their authors wrote,
51
+ * markdown and share links included, not the text as drawn. Copying part of a
52
+ * message copies what it shows.
53
+ */
54
+ export function useWholeMessageCopy(enabled: boolean, messages: readonly ChatMessage[]): void {
55
+ const messagesRef = useRef(messages);
56
+ messagesRef.current = messages;
57
+ useEffect(() => {
58
+ const doc = (globalThis as { document?: Document }).document;
59
+ if (!enabled || !doc) return;
60
+ const onCopy = (event: ClipboardEvent) => {
61
+ if (event.defaultPrevented || !event.clipboardData) return;
62
+ const text = wholeMessagesText(doc, messagesRef.current);
63
+ if (text === null) return;
64
+ event.clipboardData.setData("text/plain", text);
65
+ event.preventDefault();
66
+ };
67
+ doc.addEventListener("copy", onCopy);
68
+ return () => doc.removeEventListener("copy", onCopy);
69
+ }, [enabled]);
70
+ }