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
@@ -7,6 +7,7 @@ import type { InlineTickerCatalogEntry } from "../state/hooks/inline-tickers";
7
7
  import { colors } from "../theme/colors";
8
8
  import { displayWidth } from "../utils/format";
9
9
  import { useUiCapabilities } from "../ui";
10
+ import { MARKDOWN_HEADING_RE, markdownListItem, parseInlineMarkdown, type MarkdownSegment } from "../utils/markdown";
10
11
 
11
12
  export interface MarkdownTextProps {
12
13
  text: string;
@@ -30,15 +31,9 @@ export interface MarkdownTextProps {
30
31
  onOpenLink?: (href: string) => void;
31
32
  }
32
33
 
33
- interface StyledSegment {
34
- text: string;
35
- bold?: boolean;
36
- italic?: boolean;
37
- dim?: boolean;
38
- code?: boolean;
34
+ /** A segment as drawn here: a heading or a table header has its own colour. */
35
+ interface StyledSegment extends MarkdownSegment {
39
36
  color?: string;
40
- /** Where the segment links to, when links are read. */
41
- link?: string;
42
37
  }
43
38
 
44
39
  interface ParsedLine {
@@ -47,52 +42,12 @@ interface ParsedLine {
47
42
  indent?: number;
48
43
  }
49
44
 
50
- // A backslash escapes the punctuation markdown would read ("\\*" is a star), and
51
- // an italic span may hold a bold one ("*covers **96%** of value*").
52
- const INLINE_RE =
53
- /(\\(?<escaped>[\\`*_~#>[\]()-])|\[(?<linkLabel>[^\]]+)\]\((?<linkHref>[^)\s]+)\)|\*\*(?<bold>[^*]+)\*\*|(?<!\*)\*(?!\*)(?<italic>(?:[^*]|\*\*[^*]+\*\*)+?)\*(?!\*)|`(?<code>[^`]+)`|~~(?<strike>[^~]+)~~)/g;
54
-
55
- function parseInlineMarkdown(text: string, links = false): StyledSegment[] {
56
- const segments: StyledSegment[] = [];
57
- let cursor = 0;
58
-
59
- const pattern = new RegExp(INLINE_RE.source, "g");
60
- let match: RegExpExecArray | null;
61
- while ((match = pattern.exec(text)) !== null) {
62
- if (match.groups?.linkHref != null && !links) {
63
- // Not reading links: the bracket is text, and what is inside it still styles.
64
- pattern.lastIndex = match.index + 1;
65
- continue;
66
- }
67
- if (match.index > cursor) {
68
- segments.push({ text: text.slice(cursor, match.index) });
69
- }
70
- if (match.groups?.escaped != null) {
71
- segments.push({ text: match.groups.escaped });
72
- } else if (match.groups?.linkHref != null) {
73
- for (const inner of parseInlineMarkdown(match.groups.linkLabel ?? "", false)) segments.push({ ...inner, link: match.groups.linkHref });
74
- } else if (match.groups?.bold != null) {
75
- segments.push({ text: match.groups.bold, bold: true });
76
- } else if (match.groups?.italic != null) {
77
- for (const inner of parseInlineMarkdown(match.groups.italic, links)) segments.push({ ...inner, italic: true });
78
- } else if (match.groups?.code != null) {
79
- segments.push({ text: match.groups.code, code: true });
80
- } else if (match.groups?.strike != null) {
81
- segments.push({ text: match.groups.strike, dim: true });
82
- }
83
- cursor = match.index + match[0].length;
84
- }
85
- if (cursor < text.length) {
86
- segments.push({ text: text.slice(cursor) });
87
- }
88
- return segments;
45
+ function parseInline(text: string, links = false): StyledSegment[] {
46
+ return parseInlineMarkdown(text, { links });
89
47
  }
90
48
 
91
- const HEADING_RE = /^(#{1,3})\s+(.+)$/;
92
-
93
49
  function parseLine(line: string, links: boolean): ParsedLine {
94
- // Headings
95
- const headingMatch = line.match(HEADING_RE);
50
+ const headingMatch = line.match(MARKDOWN_HEADING_RE);
96
51
  if (headingMatch) {
97
52
  return {
98
53
  heading: true,
@@ -100,22 +55,18 @@ function parseLine(line: string, links: boolean): ParsedLine {
100
55
  };
101
56
  }
102
57
 
103
- // List items
104
- const listMatch = line.match(/^(\s*)([-*]|\d+\.)\s(.*)$/);
105
- if (listMatch) {
106
- const indent = listMatch[1]!.length;
107
- const marker = listMatch[2]!;
108
- const content = listMatch[3]!;
58
+ const item = markdownListItem(line);
59
+ if (item) {
109
60
  return {
110
- indent,
61
+ indent: item.indent,
111
62
  segments: [
112
- { text: `${marker} `, bold: true, color: colors.borderFocused },
113
- ...parseInlineMarkdown(content, links),
63
+ { text: `${item.marker} `, bold: true, color: colors.borderFocused },
64
+ ...parseInline(item.text, links),
114
65
  ],
115
66
  };
116
67
  }
117
68
 
118
- return { segments: parseInlineMarkdown(line, links) };
69
+ return { segments: parseInline(line, links) };
119
70
  }
120
71
 
121
72
  type TableAlign = "left" | "center" | "right";
@@ -176,7 +127,7 @@ function columnAlign(table: MarkdownTable, column: number): TableAlign {
176
127
  const explicit = table.align[column];
177
128
  if (explicit) return explicit;
178
129
  const values = table.rows
179
- .map((row) => parseInlineMarkdown(row[column] ?? "").map((segment) => segment.text).join("").trim())
130
+ .map((row) => parseInline(row[column] ?? "").map((segment) => segment.text).join("").trim())
180
131
  .filter((value) => value !== "" && value !== "-" && value !== "\u2014");
181
132
  return values.length > 0 && values.every((value) => NUMERIC_CELL_RE.test(value)) ? "right" : "left";
182
133
  }
@@ -214,6 +165,28 @@ function parseMarkdownBlocks(text: string): MarkdownBlock[] {
214
165
  return blocks;
215
166
  }
216
167
 
168
+ /**
169
+ * The links a text reads as, in reading order, for a reader's pane menu: the
170
+ * terminal cannot press part of a line.
171
+ */
172
+ export function markdownLinks(text: string): Array<{ label: string; href: string }> {
173
+ const links: Array<{ label: string; href: string }> = [];
174
+ // A label with bold in it is several segments of one link.
175
+ const read = (segments: StyledSegment[]) => {
176
+ let previous: string | undefined;
177
+ for (const { text: part, link } of segments) {
178
+ if (link && link === previous) links[links.length - 1]!.label += part;
179
+ else if (link) links.push({ label: part, href: link });
180
+ previous = link;
181
+ }
182
+ };
183
+ for (const block of parseMarkdownBlocks(text)) {
184
+ if (block.kind === "line") read(parseLine(block.text, true).segments);
185
+ else for (const cell of [block.table.header, ...block.table.rows].flat()) read(parseInline(cell, true));
186
+ }
187
+ return links;
188
+ }
189
+
217
190
  function segmentsWidth(segments: StyledSegment[]): number {
218
191
  return segments.reduce((total, segment) => total + displayWidth(segment.text), 0);
219
192
  }
@@ -374,7 +347,7 @@ function layoutTableRows(
374
347
  }
375
348
 
376
349
  function headerCell(text: string, links: boolean): StyledSegment[] {
377
- return parseInlineMarkdown(text, links).map((segment) => ({ ...segment, bold: true, color: colors.textDim }));
350
+ return parseInline(text, links).map((segment) => ({ ...segment, bold: true, color: colors.textDim }));
378
351
  }
379
352
 
380
353
  /**
@@ -384,7 +357,7 @@ function headerCell(text: string, links: boolean): StyledSegment[] {
384
357
  */
385
358
  function layoutMarkdownTable(table: MarkdownTable, lineWidth?: number, links = false): TableLine[] {
386
359
  const header = table.header.map((cell) => headerCell(cell, links));
387
- const body = table.rows.map((row) => row.map((cell) => parseInlineMarkdown(cell, links)));
360
+ const body = table.rows.map((row) => row.map((cell) => parseInline(cell, links)));
388
361
  const align = table.header.map((_, column) => columnAlign(table, column));
389
362
  const widths = tableColumnWidths([header, ...body], lineWidth);
390
363
  if (widths) return layoutTableRows([header, ...body], widths, align);
@@ -460,7 +433,8 @@ function SegmentSpan({ segment, wrap = false, onOpenLink }: {
460
433
  const attrs =
461
434
  (segment.bold ? TextAttributes.BOLD : 0) |
462
435
  (segment.italic ? TextAttributes.ITALIC : 0) |
463
- (segment.dim ? TextAttributes.DIM : 0) |
436
+ // Struck text reads dim here.
437
+ (segment.strike ? TextAttributes.DIM : 0) |
464
438
  (segment.link ? TextAttributes.UNDERLINE : 0);
465
439
  const href = segment.link;
466
440
  // The terminal cannot press part of a line; there the pane menu lists links.
@@ -627,7 +601,7 @@ export function MarkdownText({
627
601
  if (line.trim() === "") {
628
602
  // Under a heading set in its own style, the heading's margin is the space: it sits with what it names.
629
603
  const previous = blocks[index - 1];
630
- if (nativePaneChrome && headingStyle && previous?.kind === "line" && HEADING_RE.test(previous.text)) return null;
604
+ if (nativePaneChrome && headingStyle && previous?.kind === "line" && MARKDOWN_HEADING_RE.test(previous.text)) return null;
631
605
  return nativePaneChrome && paragraphGap
632
606
  ? <Box key={index} flexShrink={0} style={{ height: paragraphGap }} />
633
607
  : <Text key={index}>{" "}</Text>;
@@ -84,13 +84,15 @@ export interface MarketBoardStackProps<T extends MarketBoardRow> {
84
84
  const FIT_STEPS = ["labelDetail", "history", "shortAsOf", "changeAsOf", "percentile"] as const;
85
85
  /** An as-of date without its year ("09-17"). */
86
86
  const SHORT_AS_OF_WIDTH = 5;
87
+ /** The word a stale row's date carries, with its space: "2026-09-17 stale". */
88
+ const STALE_WORD = " stale";
87
89
 
88
90
  function shortIsoDate(text: string): string {
89
91
  return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text.slice(5) : text;
90
92
  }
91
93
 
92
94
  /** Fit the board to the pane rather than cut its right edge mid-value. */
93
- function fitBoardColumns(candidates: DataTableColumn[], width: number): { columns: DataTableColumn[]; shortAsOf: boolean } {
95
+ function fitBoardColumns(candidates: DataTableColumn[], width: number, wordWidth: number): { columns: DataTableColumn[]; shortAsOf: boolean } {
94
96
  const withAsOfWidth = (list: DataTableColumn[], asOfWidth: number | null) => asOfWidth === null
95
97
  ? list
96
98
  : list.map((column) => column.id === "asOf" ? { ...column, width: asOfWidth } : column);
@@ -100,7 +102,7 @@ function fitBoardColumns(candidates: DataTableColumn[], width: number): { column
100
102
  if (getTableWidth(columns) <= width) break;
101
103
  if (step === "shortAsOf") {
102
104
  shortAsOf = true;
103
- columns = withAsOfWidth(columns, SHORT_AS_OF_WIDTH);
105
+ columns = withAsOfWidth(columns, SHORT_AS_OF_WIDTH + wordWidth);
104
106
  } else {
105
107
  columns = columns.filter((column) => column.id !== step);
106
108
  }
@@ -134,6 +136,8 @@ export function MarketBoardStack<T extends MarketBoardRow>({ rows, width, height
134
136
  return compareSortValues(left, right, sort.direction);
135
137
  }) : rows, [rows, sort, extraColumns]);
136
138
  const open = rows.find((row) => row.id === openId);
139
+ // A stale row's date carries a word, so the column has room for both and the date is never cut.
140
+ const wordWidth = rows.some((row) => row.status === "stale") ? STALE_WORD.length : 0;
137
141
  const { columns, shortAsOf } = fitBoardColumns([
138
142
  { id: "label", label: labelHeader, width: labelWidth, align: "left" },
139
143
  ...(rows.some((row) => row.labelDetail !== undefined)
@@ -144,8 +148,8 @@ export function MarketBoardStack<T extends MarketBoardRow>({ rows, width, height
144
148
  ...(extraColumns?.map((item) => item.column) ?? []),
145
149
  { id: "percentile", label: percentileLabel, width: 8, align: "right" },
146
150
  { id: "history", label: "1Y", width: 14, align: "left" },
147
- { id: "asOf", label: "AS OF", width: asOfWidth, align: "left" },
148
- ], width);
151
+ { id: "asOf", label: "AS OF", width: asOfWidth + wordWidth, align: "left" },
152
+ ], width, wordWidth);
149
153
  const renderCell = (row: T, column: DataTableColumn): DataTableCell => {
150
154
  const extra = extraColumns?.find((item) => item.column.id === column.id);
151
155
  if (extra) return extra.renderCell(row);
@@ -159,8 +163,10 @@ export function MarketBoardStack<T extends MarketBoardRow>({ rows, width, height
159
163
  if (column.id === "percentile") return { text: row.percentileText ?? row.percentile?.toFixed(0) ?? "--", color: row.percentile != null && (row.percentile <= 10 || row.percentile >= 90) ? colors.warning : colors.textMuted };
160
164
  if (column.id === "history") return { text: "", content: <PriceSparkline priceHistory={row.history} width={column.width} period="1Y" trend="neutral" /> };
161
165
  const asOf = row.asOfText ?? row.asOf ?? "--";
162
- // A narrow board drops the year; the export keeps the full date.
163
- return { text: shortAsOf ? shortIsoDate(asOf) : asOf, ...(row.asOf ? { value: row.asOf } : {}),
166
+ const asOfText = shortAsOf ? shortIsoDate(asOf) : asOf;
167
+ // A narrow board drops the year; the export keeps the full date. The word
168
+ // stays with the color, so a stale row is not color alone.
169
+ return { text: row.status === "stale" ? `${asOfText}${STALE_WORD}` : asOfText, ...(row.asOf ? { value: row.asOf } : {}),
164
170
  color: row.status === "stale" ? colors.warning : colors.textDim };
165
171
  };
166
172
  return <DataTableStackView columns={columns} items={items} getItemKey={(row) => row.id} renderCell={renderCell}
@@ -1,6 +1,7 @@
1
1
  import { memo, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
2
2
  import { Box, Text, TextAttributes, useUiCapabilities, type BoxRenderable } from "../../ui";
3
3
  import { useLiveStretch } from "../../ui/live-stretch";
4
+ import { useSettledLayoutSize } from "../../ui/resize-settle";
4
5
  import { blendHex } from "../../theme/colors";
5
6
  import {
6
7
  heatmapColorDistance,
@@ -91,9 +92,57 @@ export function buildHeatTreemapScene<T>(
91
92
  return buildMetricTreemapScene(items, canvas.width, canvas.height, canvas.options, previous);
92
93
  }
93
94
 
95
+ export interface HeatTreemapLayout<T> {
96
+ scene: MetricTreemapScene<T>;
97
+ /** The box is being resized (`useSettledLayoutSize`): hand it to the surface, which then holds still. */
98
+ resizing: boolean;
99
+ }
100
+
101
+ interface ShownScene<T> {
102
+ key: string;
103
+ canvas: HeatTreemapCanvas;
104
+ items: readonly MetricTreemapItem<T>[];
105
+ scene: MetricTreemapScene<T>;
106
+ }
107
+
108
+ /**
109
+ * The scene a heat map shows in a `width` by `height` box (cells). Each
110
+ * layout is seeded with the scene on screen under the same `key` (a tab), so
111
+ * a refresh nudges tiles instead of reshuffling the board. On the desktop,
112
+ * while the box is being resized the scene on screen stretches with it and a
113
+ * refresh's new sizes wait: the board is laid out once, at the size the box
114
+ * settles on.
115
+ */
116
+ export function useHeatTreemapScene<T>(
117
+ items: readonly MetricTreemapItem<T>[],
118
+ width: number,
119
+ height: number,
120
+ key = "",
121
+ ): HeatTreemapLayout<T> {
122
+ const { nativePaneChrome, cellWidthPx, cellHeightPx } = useUiCapabilities();
123
+ const layout = useSettledLayoutSize(width, height, { enabled: nativePaneChrome === true });
124
+ const canvas = useMemo(
125
+ () => heatTreemapCanvas(layout.width, layout.height, { nativePaneChrome, cellWidthPx, cellHeightPx }),
126
+ [cellHeightPx, cellWidthPx, layout.height, layout.width, nativePaneChrome],
127
+ );
128
+ const shownRef = useRef<ShownScene<T> | null>(null);
129
+ const scene = useMemo(() => {
130
+ const shown = shownRef.current?.key === key ? shownRef.current : null;
131
+ // While the box resizes, the scene on screen holds until it is laid out
132
+ // again, a refresh's new sizes with it: the board relays out once.
133
+ if (shown && shown.canvas === canvas && (layout.resizing || shown.items === items)) return shown.scene;
134
+ const next = buildHeatTreemapScene(items, canvas, shown?.scene);
135
+ shownRef.current = { key, canvas, items, scene: next };
136
+ return next;
137
+ }, [canvas, items, key, layout.resizing]);
138
+ return { scene, resizing: layout.resizing };
139
+ }
140
+
94
141
  export interface HeatTreemapSurfaceProps<T> {
95
- /** Geometry, from `buildHeatTreemapScene`; rebuilt only when sizes change. */
142
+ /** Geometry, from `useHeatTreemapScene`; rebuilt only when sizes change. */
96
143
  scene: MetricTreemapScene<T>;
144
+ /** From `useHeatTreemapScene`: while the box resizes, no tile glides, fades or pulses. */
145
+ resizing: boolean;
97
146
  /** Current colours and text, by id; they change with every tick. */
98
147
  items: readonly MetricTreemapItem<T>[];
99
148
  width: number;
@@ -223,7 +272,7 @@ const VERTICAL_LABEL_STYLE: CSSProperties = {
223
272
 
224
273
  const DomHeatTile = memo(function DomHeatTile({
225
274
  id, rect, canvasWidth, canvasHeight, gap, label, value, valueSuffix, background, foreground,
226
- selected, reducedMotion, glide, pulseCount, events,
275
+ selected, reducedMotion, resizing, glide, pulseCount, events,
227
276
  }: {
228
277
  id: string;
229
278
  rect: TreemapRect;
@@ -237,6 +286,8 @@ const DomHeatTile = memo(function DomHeatTile({
237
286
  foreground: string;
238
287
  selected: boolean;
239
288
  reducedMotion: boolean;
289
+ resizing: boolean;
290
+ /** A relayout this render may slide the tile: false for one that came with a new size. */
240
291
  glide: boolean;
241
292
  pulseCount: number;
242
293
  events: SurfaceEvents;
@@ -248,16 +299,22 @@ const DomHeatTile = memo(function DomHeatTile({
248
299
  });
249
300
  // Where this tile sat before the last relayout, kept to slide it from there.
250
301
  const placedRef = useRef(rect);
251
- const glideRef = useRef<{ dx: number; dy: number; count: number }>({ dx: 0, dy: 0, count: 0 });
302
+ const glideRef = useRef<{ dx: number; dy: number; count: number; playing: boolean }>({ dx: 0, dy: 0, count: 0, playing: false });
303
+ const allowGlide = glide && !resizing;
252
304
  if (placedRef.current !== rect) {
253
305
  const dx = placedRef.current.x - rect.x;
254
306
  const dy = placedRef.current.y - rect.y;
255
- if (glide && (Math.abs(dx) >= 0.5 || Math.abs(dy) >= 0.5)) {
256
- glideRef.current = { dx, dy, count: glideRef.current.count + 1 };
257
- }
307
+ // A slide belongs to the relayout that moved the tile: a later render
308
+ // (a tick, a hover, the end of a resize) never plays it again.
309
+ glideRef.current = allowGlide && (Math.abs(dx) >= 0.5 || Math.abs(dy) >= 0.5)
310
+ ? { dx, dy, count: glideRef.current.count + 1, playing: true }
311
+ : { ...glideRef.current, playing: false };
258
312
  placedRef.current = rect;
313
+ } else if (resizing && glideRef.current.playing) {
314
+ // A resize stops a slide under way: the tile sits where its layout has it.
315
+ glideRef.current = { ...glideRef.current, playing: false };
259
316
  }
260
- const motion = heatTileMotion({ reducedMotion, glide, glideCount: glideRef.current.count, pulseCount });
317
+ const motion = heatTileMotion({ reducedMotion, resizing, glide: glideRef.current.playing, glideCount: glideRef.current.count, pulseCount });
261
318
  const colors = useThemeColors();
262
319
  const ring = resolveHeatmapSelectionRing(colors);
263
320
  // The colour this tile showed before, kept to fade out over the new one.
@@ -274,10 +331,13 @@ const DomHeatTile = memo(function DomHeatTile({
274
331
  else if (visible) fadeRef.current = { from: shownRef.current, key: (fadeRef.current?.key ?? 0) + 1 };
275
332
  shownRef.current = background;
276
333
  }
334
+ // A resize ends a fade under way, and the pulses due meanwhile are dropped, not saved for later.
335
+ if (resizing) fadeRef.current = null;
277
336
  const fade = fadeRef.current;
278
337
  // A finished overlay leaves the DOM, so idle tiles carry no extra nodes.
279
338
  const [, setOverlayVersion] = useState(0);
280
339
  const pulseDoneRef = useRef(0);
340
+ if (resizing) pulseDoneRef.current = Math.max(pulseDoneRef.current, pulseCount);
281
341
  const pulsing = motion.pulseAnimation != null && pulseCount > pulseDoneRef.current;
282
342
  const style: CSSProperties = {
283
343
  position: "absolute",
@@ -491,7 +551,7 @@ function DomTooltip<T>({ state, itemsById, otherLines }: {
491
551
  const EMPTY_PULSES: ReadonlyMap<string, number> = new Map();
492
552
 
493
553
  function DomHeatTreemap<T>({
494
- scene, items, width, height, selectedId, onSelect, onActivate, otherTooltip, otherSummary: summarizeOther,
554
+ scene, resizing, items, width, height, selectedId, onSelect, onActivate, otherTooltip, otherSummary: summarizeOther,
495
555
  }: HeatTreemapSurfaceProps<T>) {
496
556
  const reducedMotion = usePrefersReducedMotion();
497
557
  const colors = useThemeColors();
@@ -519,13 +579,14 @@ function DomHeatTreemap<T>({
519
579
  );
520
580
  }, [itemsById, reducedMotion, scene]);
521
581
 
522
- // A resize relays out every tile; following the window with a glide would lag it.
582
+ // The layout a resize settles on comes in at once, as a chart or a table
583
+ // redraws at its new size: tiles slide only when sizes move at rest.
523
584
  const sizeKey = `${scene.width}x${scene.height}`;
524
- const lastSizeRef = useRef(sizeKey);
525
- const glide = lastSizeRef.current === sizeKey;
585
+ const restedRef = useRef({ sizeKey, resizing });
586
+ const glide = !resizing && !restedRef.current.resizing && restedRef.current.sizeKey === sizeKey;
526
587
  useEffect(() => {
527
- lastSizeRef.current = sizeKey;
528
- }, [sizeKey]);
588
+ restedRef.current = { sizeKey, resizing };
589
+ }, [resizing, sizeKey]);
529
590
 
530
591
  const gap = scene.width < NARROW_CANVAS_PX ? 0.5 : 1;
531
592
  const otherLines = useMemo(
@@ -603,6 +664,7 @@ function DomHeatTreemap<T>({
603
664
  foreground={tileColors.foreground}
604
665
  selected={tile.item.id === selectedId}
605
666
  reducedMotion={reducedMotion}
667
+ resizing={resizing}
606
668
  glide={glide}
607
669
  pulseCount={pulses.get(tile.item.id) ?? 0}
608
670
  events={events}
@@ -755,7 +817,7 @@ function TerminalHeatTreemap<T>({
755
817
  * calm tiles (ticker, then the move, as space allows), a continuous scale
756
818
  * from the theme's down colour to its up colour, and on the desktop a hover
757
819
  * tooltip, colour fades, a short pulse on a tile whose move jumps and a glide
758
- * when sizes change.
820
+ * when sizes change, none of them while the box is being resized.
759
821
  */
760
822
  export function HeatTreemapSurface<T>(props: HeatTreemapSurfaceProps<T>) {
761
823
  const { nativePaneChrome } = useUiCapabilities();
@@ -240,15 +240,17 @@ export interface HeatTileMotion {
240
240
  * tile from its old place (its new size applies at once), a colour change
241
241
  * fades the old colour out over the new one, and a jump in the move flashes
242
242
  * once. `glideCount` alternates two keyframe names, which restarts the slide.
243
- * Reduced motion keeps every change instant.
243
+ * Reduced motion keeps every change instant, and so does a resize: while the
244
+ * box is changing size nothing on the board moves on its own.
244
245
  */
245
- export function heatTileMotion({ reducedMotion, glide, glideCount, pulseCount }: {
246
+ export function heatTileMotion({ reducedMotion, resizing, glide, glideCount, pulseCount }: {
246
247
  reducedMotion: boolean;
248
+ resizing: boolean;
247
249
  glide: boolean;
248
250
  glideCount: number;
249
251
  pulseCount: number;
250
252
  }): HeatTileMotion {
251
- if (reducedMotion) return {};
253
+ if (reducedMotion || resizing) return {};
252
254
  return {
253
255
  ...(glide && glideCount > 0
254
256
  ? { glideAnimation: `gloom-heat-glide-${glideCount % 2 === 0 ? "b" : "a"} ${GLIDE_MS}ms ease` }
@@ -18,7 +18,7 @@ export {
18
18
  type MetricTreemapDirection,
19
19
  type MetricTreemapItem,
20
20
  } from "./layout";
21
- export { buildHeatTreemapScene, heatTreemapCanvas, HeatTreemapSurface } from "./heat-surface";
21
+ export { buildHeatTreemapScene, heatTreemapCanvas, HeatTreemapSurface, useHeatTreemapScene } from "./heat-surface";
22
22
  export { formatHeatTileMove, settleTreemapLayoutItems } from "./heat-tiles";
23
23
 
24
24
  type PreventableMouseEvent = { preventDefault(): void };
@@ -1,10 +1,10 @@
1
- import { forwardRef, useMemo } from "react";
2
- import { t } from "../../i18n";
1
+ import { forwardRef, useCallback, useMemo } from "react";
2
+ import { t, tf } from "../../i18n";
3
3
  import { hoverBg } from "../../theme/colors";
4
4
  import { useThemeColors } from "../../theme/theme-context";
5
- import { Box, Text, useUiCapabilities, type ScrollBoxRenderable } from "../../ui";
5
+ import { Box, Text, useRendererHost, useUiCapabilities, type ScrollBoxRenderable } from "../../ui";
6
6
  import { wrapTextLines } from "../../utils/text-wrap";
7
- import { MarkdownText } from "../markdown-text";
7
+ import { MarkdownText, markdownLinks, type MarkdownTextProps } from "../markdown-text";
8
8
  import { DetailScrollBody } from "../ui/detail-scroll-body";
9
9
  import { KeyValueRow } from "../ui/display";
10
10
  import { ExternalLink, usePaneLinkMenuEntry } from "../ui/external-link";
@@ -121,7 +121,7 @@ function NativeDetail({ meta, facts, body, bodyFormat, note }: DetailContentProp
121
121
  ) : null}
122
122
  {bodyFormat === "markdown" && body ? (
123
123
  <Box flexDirection="column" width="100%" style={MARKDOWN_STYLE}>
124
- <MarkdownText text={body} paragraphGap={PARAGRAPH_GAP} headingStyle={READER_HEADING_STYLE} />
124
+ <MarkdownBody text={body} paragraphGap={PARAGRAPH_GAP} headingStyle={READER_HEADING_STYLE} />
125
125
  </Box>
126
126
  ) : blocks.map((block, index) => (
127
127
  <Box key={index} flexDirection="column">
@@ -140,6 +140,28 @@ function NativeDetail({ meta, facts, body, bodyFormat, note }: DetailContentProp
140
140
  );
141
141
  }
142
142
 
143
+ /**
144
+ * Markdown whose `[label](url)` links open outside the app, as the reader's
145
+ * other links do. The terminal cannot press part of a line, so the pane menu
146
+ * lists each link there (inside a `PaneLinkMenu`).
147
+ */
148
+ function MarkdownBody({ text, ...style }: Pick<MarkdownTextProps, "text" | "lineWidth" | "paragraphGap" | "headingStyle">) {
149
+ const rendererHost = useRendererHost();
150
+ const links = useMemo(() => markdownLinks(text), [text]);
151
+ const open = useCallback((href: string) => { void rendererHost.openExternal(href); }, [rendererHost]);
152
+ return (
153
+ <>
154
+ <MarkdownText text={text} onOpenLink={open} {...style} />
155
+ {links.map((link, index) => <LinkMenuEntry key={index} {...link} onOpen={open} />)}
156
+ </>
157
+ );
158
+ }
159
+
160
+ function LinkMenuEntry({ label, href, onOpen }: { label: string; href: string; onOpen: (href: string) => void }) {
161
+ usePaneLinkMenuEntry(`link:${href}`, tf("Open {label}", { label }), () => onOpen(href));
162
+ return null;
163
+ }
164
+
143
165
  function FactValue({ fact }: { fact: DetailFact }) {
144
166
  const colors = useThemeColors();
145
167
  const { link } = fact;
@@ -167,7 +189,7 @@ function TerminalDetail({ width, meta, facts, body, bodyFormat, note }: DetailCo
167
189
  return (
168
190
  <Box flexDirection="column" width={lineWidth}>
169
191
  {metaLines.map((line, index) => <Text key={index} fg={colors.textMuted}>{line}</Text>)}
170
- <MarkdownText text={body ?? ""} lineWidth={lineWidth} />
192
+ <MarkdownBody text={body ?? ""} lineWidth={lineWidth} />
171
193
  </Box>
172
194
  );
173
195
  }
@@ -68,6 +68,8 @@ export function TickerBadge({
68
68
  // badges; the desktop leaves a hairline of row above and below.
69
69
  justifyContent={nativePaneChrome ? "center" : undefined}
70
70
  style={nativePaneChrome ? NATIVE_CHIP_STYLE : undefined}
71
+ role="button"
72
+ aria-label={symbol}
71
73
  data-gloom-context-menu-surface="true"
72
74
  onMouseOver={() => {
73
75
  onHoverStart?.();
@@ -26,6 +26,14 @@ export interface ButtonProps {
26
26
  onPress?: () => void;
27
27
  variant?: ButtonVariant;
28
28
  disabled?: boolean;
29
+ /**
30
+ * The action it started is still running (a retry waiting for its answer).
31
+ * It reads as disabled and ignores presses, but keeps its key, so it does not
32
+ * change size and the key does not fall through to something else.
33
+ */
34
+ pending?: boolean;
35
+ /** What it says while pending, such as "Retrying...". Without it the label stays. */
36
+ pendingLabel?: string;
29
37
  active?: boolean;
30
38
  shortcut?: string;
31
39
  /**
@@ -86,9 +94,11 @@ export function Button({
86
94
  displayLabel,
87
95
  children,
88
96
  expanded,
89
- onPress,
97
+ onPress: pressAction,
90
98
  variant = "secondary",
91
99
  disabled = false,
100
+ pending = false,
101
+ pendingLabel,
92
102
  active = false,
93
103
  shortcut,
94
104
  desktopShortcut,
@@ -100,17 +110,21 @@ export function Button({
100
110
  title,
101
111
  }: ButtonProps) {
102
112
  const colors = useThemeColors();
103
- const label = t(rawLabel);
113
+ const label = t(pending && pendingLabel ? pendingLabel : rawLabel);
114
+ if (pending && pendingLabel) displayLabel = undefined;
115
+ // A pending press is ignored, but the button stays its scope's primary action.
116
+ const onPress = pressAction && pending ? () => {} : pressAction;
104
117
  const scopedShortcut = useScopedButtonAction(label, onPress, disabled);
105
118
  shortcut = shortcut ?? scopedShortcut;
119
+ const inert = disabled || pending;
106
120
  const remoteScope = useRemoteUiScope();
107
121
  useRemoteUiNode({
108
122
  role: "button",
109
123
  label,
110
- disabled,
124
+ disabled: inert,
111
125
  actions: {
112
126
  press: () => {
113
- if (!disabled) onPress?.();
127
+ if (!inert) onPress?.();
114
128
  },
115
129
  },
116
130
  metadata: { variant, active, shortcut, expanded, ...(remoteScope ? { scope: remoteScope } : {}) },
@@ -124,7 +138,8 @@ export function Button({
124
138
  expanded={expanded}
125
139
  onPress={onPress}
126
140
  variant={variant}
127
- disabled={disabled}
141
+ disabled={inert}
142
+ pending={pending}
128
143
  active={active}
129
144
  shortcut={shortcut ?? desktopShortcut}
130
145
  title={title}
@@ -137,7 +152,7 @@ export function Button({
137
152
  );
138
153
  }
139
154
 
140
- const palette = resolveButtonColors(variant, active, disabled, colors);
155
+ const palette = resolveButtonColors(variant, active, inert, colors);
141
156
 
142
157
  return (
143
158
  <Box
@@ -145,20 +160,20 @@ export function Button({
145
160
  height={height ?? 1}
146
161
  flexDirection="row"
147
162
  backgroundColor={palette.bg}
148
- cursor={disabled ? "default" : "pointer"}
163
+ cursor={inert ? "default" : "pointer"}
149
164
  onMouseDown={(event: { preventDefault?: () => void; stopPropagation?: () => void }) => {
150
165
  if (stopPropagation) {
151
166
  event.preventDefault?.();
152
167
  event.stopPropagation?.();
153
168
  }
154
- if (!disabled) onPress?.();
169
+ if (!inert) onPress?.();
155
170
  }}
156
171
  >
157
172
  {children ?? <Text fg={palette.fg} attributes={active ? TextAttributes.BOLD : 0}>
158
173
  {compact ? displayLabel ?? label : ` ${displayLabel ?? label} `}
159
174
  </Text>}
160
175
  {shortcut && (
161
- <Text fg={disabled ? colors.textMuted : colors.textDim}>
176
+ <Text fg={inert ? colors.textMuted : colors.textDim}>
162
177
  {` ${shortcut}`}
163
178
  </Text>
164
179
  )}
@@ -226,7 +226,12 @@ export function Divider({ width, height, orientation = "horizontal" }: DividerPr
226
226
  flexShrink={0}
227
227
  border={vertical ? ["left"] : ["top"]}
228
228
  borderColor={colors.border}
229
- style={{ border: 0, [vertical ? "borderLeft" : "borderTop"]: `1px solid ${colors.border}` }}
229
+ style={{
230
+ border: 0,
231
+ [vertical ? "borderLeft" : "borderTop"]: `1px solid ${colors.border}`,
232
+ // A vertical rule given no height runs the height of the row it stands in.
233
+ ...(vertical && height === undefined ? { height: "auto", alignSelf: "stretch" } : {}),
234
+ }}
230
235
  role="separator"
231
236
  data-gloom-ui="divider"
232
237
  />
@@ -98,11 +98,18 @@ function handleOpen(
98
98
  onOpen(url);
99
99
  }
100
100
 
101
+ /** Desktop and web: a link inside a line of text runs and wraps with the line, rather than standing as a box of its own. */
102
+ const INLINE_LINK_STYLE = { display: "inline", whiteSpace: "pre-wrap", overflowWrap: "anywhere" } as const;
103
+
101
104
  export function ExternalLinkText(
102
- { url, label, color, onOpen = openUrl }: {
105
+ { url, label, color, attributes = 0, inline = false, onOpen = openUrl }: {
103
106
  url: string;
104
107
  label?: string;
105
108
  color?: string;
109
+ /** Marks drawn with the underline, such as the bold of a bold link. */
110
+ attributes?: number;
111
+ /** Flows inside a line of text on the desktop and the web. */
112
+ inline?: boolean;
106
113
  onOpen?: (url: string) => void;
107
114
  },
108
115
  ) {
@@ -128,8 +135,9 @@ export function ExternalLinkText(
128
135
  return (
129
136
  <Text
130
137
  fg={color ?? colors.textBright}
131
- attributes={TextAttributes.UNDERLINE}
138
+ attributes={TextAttributes.UNDERLINE | attributes}
132
139
  cursor="pointer"
140
+ style={inline ? INLINE_LINK_STYLE : undefined}
133
141
  role="link"
134
142
  tabIndex={0}
135
143
  wrapText