gloomberb 0.17.2 → 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 (200) 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/futures-curve.ts +16 -0
  5. package/src/api-client/index.ts +22 -9
  6. package/src/api-client/normalizers.ts +21 -0
  7. package/src/api-client/socket.ts +27 -0
  8. package/src/api-client/types.ts +3 -1
  9. package/src/app/runtime/startup.ts +4 -0
  10. package/src/cli/commands/market.ts +60 -9
  11. package/src/cli/commands/search.ts +43 -2
  12. package/src/cli/commands/ticker.ts +100 -21
  13. package/src/cli/desktop-pane-shot.ts +34 -9
  14. package/src/cli/fx-pair.ts +17 -0
  15. package/src/cli/glossary.ts +134 -6
  16. package/src/cli/history-rows.ts +16 -4
  17. package/src/cli/index.ts +44 -12
  18. package/src/cli/known-gaps.ts +21 -2
  19. package/src/cli/listing-arg.ts +19 -4
  20. package/src/cli/listing-suggestion.ts +33 -2
  21. package/src/cli/pane-functions/dom.ts +39 -6
  22. package/src/cli/pane-functions/freshness.ts +48 -5
  23. package/src/cli/pane-functions/function-help.ts +1 -1
  24. package/src/cli/pane-functions/headless.ts +85 -19
  25. package/src/cli/pane-functions/index.ts +60 -4
  26. package/src/cli/pane-functions/market-session.ts +69 -6
  27. package/src/cli/pane-functions/resolver.ts +4 -2
  28. package/src/cli/pane-functions/screenshot.ts +14 -1
  29. package/src/cli/scroll-truncation.ts +5 -1
  30. package/src/cli/symbol-args.ts +41 -3
  31. package/src/cli/symbol-list.ts +19 -7
  32. package/src/cli/unknown-command.ts +70 -0
  33. package/src/components/chart/native/surface/visibility.ts +37 -7
  34. package/src/components/index.ts +1 -1
  35. package/src/components/layout/header.tsx +348 -102
  36. package/src/components/markdown-text.tsx +40 -66
  37. package/src/components/market-board/index.tsx +12 -6
  38. package/src/components/metric-treemap/heat-surface.tsx +76 -14
  39. package/src/components/metric-treemap/heat-tiles.ts +5 -3
  40. package/src/components/metric-treemap/index.tsx +1 -1
  41. package/src/components/reader/detail-reader.tsx +28 -6
  42. package/src/components/ticker/badge/index.tsx +2 -0
  43. package/src/components/ui/button.tsx +24 -9
  44. package/src/components/ui/display.tsx +6 -1
  45. package/src/components/ui/external-link.tsx +10 -2
  46. package/src/components/ui/index.ts +1 -1
  47. package/src/components/ui/status.tsx +148 -5
  48. package/src/data/memory-resource-store.ts +1 -1
  49. package/src/data/resource-store.ts +13 -1
  50. package/src/docs/snapshot.json +13 -13
  51. package/src/i18n/es.ts +1 -0
  52. package/src/i18n/ja.ts +1 -0
  53. package/src/i18n/ko.ts +1 -0
  54. package/src/i18n/zh-cn.ts +1 -0
  55. package/src/i18n/zh-tw.ts +1 -0
  56. package/src/layout-marketplace/gallery-desktop.tsx +2 -2
  57. package/src/market-data/futures-board.ts +44 -1
  58. package/src/market-data/futures-contract.ts +10 -0
  59. package/src/market-data/market/format.ts +7 -2
  60. package/src/market-data/market/freshness.ts +25 -1
  61. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  62. package/src/market-data/quotes/resolution.ts +2 -0
  63. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  64. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  65. package/src/plugins/builtin/alerts/cli.ts +72 -11
  66. package/src/plugins/builtin/attention/headless.ts +19 -10
  67. package/src/plugins/builtin/attention/model.ts +22 -0
  68. package/src/plugins/builtin/awards/headless.ts +5 -1
  69. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  70. package/src/plugins/builtin/catalysts/headless.ts +5 -1
  71. package/src/plugins/builtin/changelog/index.tsx +11 -8
  72. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  73. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  74. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  75. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  76. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  77. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  78. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  79. package/src/plugins/builtin/chat/controller/connections.ts +18 -0
  80. package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
  81. package/src/plugins/builtin/chat/controller/index.ts +49 -21
  82. package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
  83. package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
  84. package/src/plugins/builtin/chat/layout.ts +91 -31
  85. package/src/plugins/builtin/chat/markdown.ts +94 -0
  86. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  87. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  88. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  89. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  90. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  91. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  92. package/src/plugins/builtin/company-attention/model.ts +53 -2
  93. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  94. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  95. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  96. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  97. package/src/plugins/builtin/cot/client.ts +12 -4
  98. package/src/plugins/builtin/cot/model.ts +20 -0
  99. package/src/plugins/builtin/cot/pane.tsx +2 -2
  100. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  101. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  102. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  103. package/src/plugins/builtin/debug/index.tsx +15 -15
  104. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  105. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  106. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  107. package/src/plugins/builtin/docs/index.tsx +5 -3
  108. package/src/plugins/builtin/docs/pane.tsx +19 -2
  109. package/src/plugins/builtin/docs/targets.ts +5 -3
  110. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  111. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  112. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  113. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  114. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  115. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  116. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  117. package/src/plugins/builtin/futures/index.tsx +33 -6
  118. package/src/plugins/builtin/futures/model.ts +69 -5
  119. package/src/plugins/builtin/futures/series.ts +3 -3
  120. package/src/plugins/builtin/futures/table.tsx +31 -11
  121. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  122. package/src/plugins/builtin/holders/pane.tsx +6 -1
  123. package/src/plugins/builtin/jobs/pane.tsx +12 -9
  124. package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
  125. package/src/plugins/builtin/mna/client.ts +12 -2
  126. package/src/plugins/builtin/mna/detail.tsx +2 -2
  127. package/src/plugins/builtin/mna/pane.tsx +2 -2
  128. package/src/plugins/builtin/news/headless.ts +4 -2
  129. package/src/plugins/builtin/notes/owner.tsx +5 -4
  130. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  131. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  132. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  133. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  134. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  135. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  136. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  137. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
  138. package/src/plugins/builtin/research/event-model.ts +14 -0
  139. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  140. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  141. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  142. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  143. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  144. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  145. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  146. package/src/plugins/builtin/research-search/data.ts +11 -0
  147. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  148. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  149. package/src/plugins/builtin/seasonality/client.ts +23 -0
  150. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  151. package/src/plugins/builtin/sectors/index.tsx +7 -3
  152. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  153. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  154. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  155. package/src/plugins/builtin/short-interest/model.ts +62 -3
  156. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  157. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  158. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  159. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  160. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  161. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  162. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  163. package/src/plugins/builtin/themes/headless.ts +1 -1
  164. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  165. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  166. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  167. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  168. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  169. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  170. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  171. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  172. package/src/renderers/dom/build-assets.ts +17 -4
  173. package/src/renderers/dom/desktop/controls.tsx +2 -0
  174. package/src/renderers/dom/styles.css +6 -0
  175. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
  176. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
  177. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
  178. package/src/sources/gloomberb-cloud/normalizers.ts +13 -4
  179. package/src/sources/provider-router/batches.ts +2 -1
  180. package/src/sources/provider-router/cache.ts +3 -0
  181. package/src/sync/baseline.ts +19 -14
  182. package/src/sync/controller.ts +140 -40
  183. package/src/sync/core-contributors.ts +256 -32
  184. package/src/sync/types.ts +44 -0
  185. package/src/time-series/resolve.ts +24 -5
  186. package/src/types/data-provider.ts +8 -0
  187. package/src/types/financials.ts +40 -4
  188. package/src/types/headless.ts +43 -0
  189. package/src/types/plugin.ts +7 -0
  190. package/src/ui/resize-settle.ts +128 -0
  191. package/src/utils/calendar-date.ts +56 -0
  192. package/src/utils/decimal-comma.ts +31 -0
  193. package/src/utils/declared-dividends.ts +39 -0
  194. package/src/utils/forward-dividend.ts +34 -11
  195. package/src/utils/fundamentals.ts +13 -2
  196. package/src/utils/futures-generic.ts +25 -4
  197. package/src/utils/markdown.ts +180 -0
  198. package/src/utils/market-capitalization.ts +22 -0
  199. package/src/utils/plugin-proxy-hosts.json +5 -0
  200. package/src/utils/utc-time.ts +14 -3
@@ -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"],
@@ -165,7 +165,7 @@ export function CalendarAccountTab({ width, sessionMarker }: { width: number; se
165
165
  <EmptyState
166
166
  status="error"
167
167
  title={loadError}
168
- actions={<Button label={t("Retry")} onPress={() => { void load(); }} />}
168
+ retry={{ onRetry: () => load(), pending: loading }}
169
169
  />
170
170
  );
171
171
  }
@@ -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}
@@ -2,12 +2,14 @@ import { withConfigData, withMarketData } from "../../../cli/scoped-context";
2
2
  import { CLI_COMMAND_GROUPS } from "../../../cli/help";
3
3
  import { dryRunNote } from "../../../cli/helpers";
4
4
  import { requireArg, takeOption } from "../../../cli/commands/command-utils";
5
- import { EXCHANGE_OPTION, listingReadNotes, requireListingArg } from "../../../cli/listing-arg";
5
+ import { EXCHANGE_OPTION, listingReadNotes, requireListingArg, type ListingArg } from "../../../cli/listing-arg";
6
6
  import { saveConfig } from "../../../data/config/store";
7
7
  import type { AppConfig } from "../../../types/config";
8
- import type { CliCommandDef } from "../../../types/plugin";
8
+ import type { CliCommandContext, CliCommandDef } from "../../../types/plugin";
9
+ import { getActiveQuoteDisplay } from "../../../market-data/market/status";
10
+ import { settleWithin } from "../../../utils/async-deadline";
9
11
  import { cliStyles } from "../../../utils/cli-output";
10
- import { ambiguousNumberMessage, parsePastedNumber } from "../../../utils/decimal-comma";
12
+ import { ambiguousNumberMessage, meantThousandsDot, parsePastedNumber, thousandsDotReadings, type ThousandsDotReadings } from "../../../utils/decimal-comma";
11
13
  import { newYorkToday } from "../earnings/board-model";
12
14
  import { createAlert, deserializeAlerts, rearmAlert, serializeAlerts } from "./alert-engine";
13
15
  import { ALERTS_KEY } from "./constants";
@@ -43,20 +45,28 @@ const DAYS_RULE = `--days takes a whole number of days from 1 to ${MAX_LEAD_DAYS
43
45
 
44
46
  /** What `alerts add` was asked for, before any listing or date is looked up. */
45
47
  export type AlertAddRequest =
46
- | { kind: "price"; symbol: string; condition: AlertCondition; price: number }
48
+ | {
49
+ kind: "price"; symbol: string; condition: AlertCondition; price: number;
50
+ /** A price typed as 81.000, which the last price may show was 81000 with a dot between thousands. */
51
+ thousandsDot?: ThousandsDotReadings & { text: string };
52
+ }
47
53
  | { kind: "earnings"; symbol: string; leadDays: number }
48
54
  | { kind: "macro"; event: MacroAlertEvent; leadDays: number };
49
55
 
50
56
  const CONDITIONS: readonly AlertCondition[] = ["above", "below", "crosses"];
51
57
 
58
+ /** The first word of a price typed with a space between thousands and no quotes (`81 000`): one to three digits. */
59
+ const LEADING_GROUP = /^[+-]?\d{1,3}$/;
60
+
52
61
  /**
53
62
  * Reads `alerts add` arguments: `<symbol> above 250`, `<symbol> earnings`, or
54
63
  * `CPI` / `FOMC`, with `--days` (already taken out of `args`, `null` when the
55
- * flag came without a value) for the event forms only. Throws a message that
56
- * names what the command takes.
64
+ * flag came without a value) for the event forms only, and `--exchange`, which
65
+ * a command to type again keeps. Throws a message that names what the command
66
+ * takes.
57
67
  */
58
- export function parseAlertAddArgs(args: readonly string[], days: string | null | undefined): AlertAddRequest {
59
- const [first, second, third] = args;
68
+ export function parseAlertAddArgs(args: readonly string[], days: string | null | undefined, exchange?: string | null): AlertAddRequest {
69
+ const [first, second, third, ...rest] = args;
60
70
  if (!first) throw new Error(ADD_USAGE);
61
71
  const leadDays = () => {
62
72
  const parsed = days === null ? null : parseLeadDays(days);
@@ -83,11 +93,57 @@ export function parseAlertAddArgs(args: readonly string[], days: string | null |
83
93
  }
84
94
  if (days !== undefined) throw new Error("--days is for earnings and CPI or FOMC alerts, not price alerts.");
85
95
  if (third === undefined) throw new Error(ADD_USAGE);
96
+ const retype = (price: number) => retypeCommand(first, condition, price, exchange);
97
+ // `81 000` without quotes arrives as 81 and a stray 000; neither reading is taken for granted.
98
+ const spaced = rest.length > 0 && LEADING_GROUP.test(third) ? parsePastedNumber([third, ...rest].join(" ")) : null;
99
+ if (spaced?.kind === "number") {
100
+ const typed = Number(third);
101
+ throw new Error(`${[third, ...rest].join(" ")} could be ${spaced.value} (thousands) or ${typed} and a stray ${rest.join(" ")}; type ${retype(spaced.value)} or ${retype(typed)}`);
102
+ }
86
103
  // A price pasted from a sheet (1.350,5, 1,350.5, 1 350,5) reads as it is written; `1,350` could be either, so it asks.
87
104
  const pasted = parsePastedNumber(third);
88
105
  if (pasted.kind === "ambiguous") throw new Error(ambiguousNumberMessage(third, pasted));
89
106
  if (pasted.kind === "invalid") throw new Error("Alert price must be a finite number.");
90
- return { kind: "price", symbol: first, condition, price: pasted.value };
107
+ if (rest.length > 0) throw new Error(`A price alert takes one price, not "${rest.join(" ")}" after ${third}; type ${retype(pasted.value)}`);
108
+ const thousandsDot = thousandsDotReadings(third);
109
+ return { kind: "price", symbol: first, condition, price: pasted.value, ...(thousandsDot ? { thousandsDot: { ...thousandsDot, text: third.trim() } } : {}) };
110
+ }
111
+
112
+ /** `alerts add` as typed, with another price: what an error tells someone to type. */
113
+ function retypeCommand(symbol: string, condition: AlertCondition, price: number, exchange: string | null | undefined): string {
114
+ return `gloomberb alerts add ${symbol} ${condition} ${price}${exchange ? ` --exchange ${exchange}` : ""}`;
115
+ }
116
+
117
+ const LAST_PRICE_TIMEOUT_MS = 10_000;
118
+
119
+ /**
120
+ * The listing's last price, as the alert will check it, for telling 81.000
121
+ * typed for 81000 from 81; null when there is none, and the price is then
122
+ * read as written.
123
+ */
124
+ async function lastPrice(listing: ListingArg, ctx: Pick<CliCommandContext, "initMarketData">): Promise<number | null> {
125
+ const quote = await settleWithin(
126
+ withMarketData(ctx, (market) => market.dataProvider.getQuote(listing.key.toUpperCase(), listing.exchange)),
127
+ LAST_PRICE_TIMEOUT_MS,
128
+ );
129
+ const price = quote ? getActiveQuoteDisplay(quote)?.price ?? quote.price : null;
130
+ return price != null && Number.isFinite(price) && price !== 0 ? price : null;
131
+ }
132
+
133
+ /**
134
+ * Asks which price `81.000` meant when the last price shows it was a dot
135
+ * between thousands: both readings, and the command to type for each.
136
+ */
137
+ export function thousandsDotMessage(
138
+ request: Extract<AlertAddRequest, { kind: "price" }>,
139
+ listing: string,
140
+ last: number | null,
141
+ exchange?: string | null,
142
+ ): string | null {
143
+ const dot = request.thousandsDot;
144
+ if (!dot || !meantThousandsDot(dot, last)) return null;
145
+ const retype = (price: number) => retypeCommand(request.symbol, request.condition, price, exchange);
146
+ return `${dot.text} could be ${dot.thousands} (thousands) or ${dot.decimal} (decimal point), and ${listing.toUpperCase()} last traded at ${Number(last!.toFixed(2))}; type ${retype(dot.thousands)} or ${retype(dot.decimal)}`;
91
147
  }
92
148
 
93
149
  /** One row of `alerts list`: a price alert as stored, or an event alert with its next date. */
@@ -222,13 +278,17 @@ export const alertsCliCommand: CliCommandDef = {
222
278
 
223
279
  const parseAdd = (): AlertAddRequest => {
224
280
  try {
225
- return parseAlertAddArgs(args.slice(1), days);
281
+ return parseAlertAddArgs(args.slice(1), days, exchangeOption);
226
282
  } catch (error) {
227
283
  return ctx.fail(error instanceof Error ? error.message : String(error));
228
284
  }
229
285
  };
230
286
  const request = action === "add" ? parseAdd() : null;
231
287
  const priceListing = request?.kind === "price" ? await requireListingArg(request.symbol, exchangeOption, ctx) : null;
288
+ if (request?.kind === "price" && request.thousandsDot && priceListing) {
289
+ const question = thousandsDotMessage(request, priceListing.key, await lastPrice(priceListing, ctx), exchangeOption);
290
+ if (question) ctx.fail(question);
291
+ }
232
292
  // What a typed listing was read as (7203:TSE as 7203:JPX), said beside what was added.
233
293
  let read = priceListing ? listingReadNotes(priceListing) : [];
234
294
  let newEventRule: EventAlertRule | null = null;
@@ -309,7 +369,8 @@ export const alertsCliCommand: CliCommandDef = {
309
369
  const alert = createAlert(symbol, condition, price, priceListing?.exchange);
310
370
  await save({ alerts: [...alerts, alert] });
311
371
  ctx.printResult({ data: { changed: !ctx.cliOptions.dryRun, dryRun: ctx.cliOptions.dryRun, alert }, ...(read.length > 0 ? { warnings: read } : {}) }, {
312
- text: (data) => `Added alert ${data.alert.id}: ${symbol} ${condition} ${price}.${dryRunNote(data.dryRun)}`,
372
+ // No full stop after the price, which would read as part of it (below 81.).
373
+ text: (data) => `Added alert ${data.alert.id}: ${symbol} ${condition} ${price}${dryRunNote(data.dryRun)}`,
313
374
  });
314
375
  return;
315
376
  }
@@ -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 type { AwardFilter, AwardRow, AwardType } from "../../../api-client/awards";
2
2
  import { notATickerSymbol } from "../../../sources/provider-errors";
3
3
  import type { HeadlessBundleSection, HeadlessPaneColumn, HeadlessPaneContext, HeadlessPaneDefinition, HeadlessPaneRow } from "../../../types/headless";
4
+ import { isoDateOption } from "../../../utils/calendar-date";
4
5
  import { resolveHeadlessInstrument } from "../shared/headless-market-data";
5
6
  import { fetchAward, fetchAwards } from "./client";
6
7
  import { AWARD_TABS, awardScope, awardsPreviewNote, awardTypeLabel, futureAwardDatesNote, money } from "./model";
@@ -85,7 +86,10 @@ export const awardsHeadless: HeadlessPaneDefinition<"bundle"> = {
85
86
  options: [
86
87
  { key: "tab", type: "enum", settingKey: "tab", defaultValue: "feed", values: [...AWARD_TABS.map(({ value }) => ({ value })), { value: "all" }], description: "Feed, company history, agencies, sectors, events, or all sections." },
87
88
  { key: "view", type: "enum", settingKey: "leaderView", defaultValue: "companies", values: [{ value: "companies" }, { value: "sectors" }], description: "Sectors tab: company leaders or sector totals." },
88
- ...["jurisdiction", "currency", "source", "agency", "sector", "parentId", "from", "to", "query"].map((key) => ({ key, type: "string" as const, settingKey: key, description: `Filter by ${key}; dates use YYYY-MM-DD.` })),
89
+ ...FILTER_KEYS.map((key) => key === "from" || key === "to"
90
+ ? { key, type: "string" as const, settingKey: key, placeholder: "YYYY-MM-DD", description: `${key === "from" ? "Start" : "End"} of the date range. Dates use YYYY-MM-DD.`,
91
+ normalize: (value: string) => isoDateOption(value, key) }
92
+ : { key, type: "string" as const, settingKey: key, description: `Filter by ${key}.` }),
89
93
  { key: "type", type: "enum", settingKey: "awardType", defaultValue: "prime", values: [{ value: "prime" }, { value: "subaward" }, { value: "notice" }, { value: "modification" }], description: "Keep record types separate." },
90
94
  { key: "cursor", type: "string", description: "Continue after the previous response's nextCursor." },
91
95
  { key: "limit", type: "integer", minimum: 1, maximum: 200, defaultValue: 100, description: "Maximum feed rows in this response." },
@@ -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;
@@ -1,5 +1,6 @@
1
1
  import { CATALYST_TYPES } from "../../../api-client/catalysts";
2
2
  import type { HeadlessPaneDefinition } from "../../../types/plugin";
3
+ import { isoDateOption } from "../../../utils/calendar-date";
3
4
  import { fetchCatalystDetail, fetchCatalysts } from "./client";
4
5
  import { catalystCell, catalystChangeText, catalystDate, catalystQuery, catalystTickers } from "./model";
5
6
 
@@ -11,7 +12,10 @@ function definition(litigation: boolean): HeadlessPaneDefinition<"bundle"> {
11
12
  options: [
12
13
  { key: "tab", type: "enum", values: [{ value: "calendar" }, { value: "changes" }], defaultValue: "calendar", description: "Dated events or observed revisions." },
13
14
  { key: "type", type: "enum", values: CATALYST_TYPES.map((value) => ({ value })), description: "Event type." },
14
- ...["agency", "country", "sector", "status", "search", "from", "to"].map((key) => ({ key, type: "string" as const, description: `Filter by ${key}. Dates use YYYY-MM-DD.` })),
15
+ ...["agency", "country", "sector", "status"].map((key) => ({ key, type: "string" as const, description: `Filter by ${key}.` })),
16
+ { key: "search", type: "string", description: "Filter by text." },
17
+ { key: "from", type: "string", placeholder: "YYYY-MM-DD", description: "Start of the calendar window. Dates use YYYY-MM-DD.", normalize: (value) => isoDateOption(value, "from") },
18
+ { key: "to", type: "string", placeholder: "YYYY-MM-DD", description: "End of the calendar window. Dates use YYYY-MM-DD.", normalize: (value) => isoDateOption(value, "to") },
15
19
  { key: "dateField", type: "enum", values: ["any", "announced", "effective", "deadline", "observed"].map((value) => ({ value })), defaultValue: "any", description: "Date field for the calendar window." },
16
20
  { key: "upcoming", type: "boolean", defaultValue: false, description: "Only future effective dates and deadlines." },
17
21
  { key: "offset", type: "integer", defaultValue: 0, description: "Zero-based event offset." },
@@ -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,