gloomberb 0.17.2 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/futures-curve.ts +16 -0
  3. package/src/api-client/index.ts +18 -8
  4. package/src/api-client/types.ts +3 -1
  5. package/src/cli/commands/market.ts +60 -9
  6. package/src/cli/commands/search.ts +43 -2
  7. package/src/cli/commands/ticker.ts +100 -21
  8. package/src/cli/desktop-pane-shot.ts +34 -9
  9. package/src/cli/fx-pair.ts +17 -0
  10. package/src/cli/glossary.ts +134 -6
  11. package/src/cli/history-rows.ts +16 -4
  12. package/src/cli/index.ts +44 -12
  13. package/src/cli/known-gaps.ts +21 -2
  14. package/src/cli/listing-arg.ts +19 -4
  15. package/src/cli/listing-suggestion.ts +33 -2
  16. package/src/cli/pane-functions/dom.ts +39 -6
  17. package/src/cli/pane-functions/freshness.ts +48 -5
  18. package/src/cli/pane-functions/function-help.ts +1 -1
  19. package/src/cli/pane-functions/headless.ts +85 -19
  20. package/src/cli/pane-functions/index.ts +60 -4
  21. package/src/cli/pane-functions/market-session.ts +69 -6
  22. package/src/cli/pane-functions/resolver.ts +4 -2
  23. package/src/cli/pane-functions/screenshot.ts +14 -1
  24. package/src/cli/scroll-truncation.ts +5 -1
  25. package/src/cli/symbol-args.ts +41 -3
  26. package/src/cli/symbol-list.ts +19 -7
  27. package/src/cli/unknown-command.ts +70 -0
  28. package/src/components/chart/native/surface/visibility.ts +37 -7
  29. package/src/components/layout/header.tsx +348 -102
  30. package/src/components/markdown-text.tsx +40 -66
  31. package/src/components/market-board/index.tsx +12 -6
  32. package/src/components/reader/detail-reader.tsx +28 -6
  33. package/src/components/ticker/badge/index.tsx +2 -0
  34. package/src/components/ui/display.tsx +6 -1
  35. package/src/components/ui/external-link.tsx +10 -2
  36. package/src/data/memory-resource-store.ts +1 -1
  37. package/src/data/resource-store.ts +13 -1
  38. package/src/docs/snapshot.json +13 -13
  39. package/src/i18n/es.ts +1 -0
  40. package/src/i18n/ja.ts +1 -0
  41. package/src/i18n/ko.ts +1 -0
  42. package/src/i18n/zh-cn.ts +1 -0
  43. package/src/i18n/zh-tw.ts +1 -0
  44. package/src/market-data/futures-contract.ts +10 -0
  45. package/src/market-data/market/format.ts +7 -2
  46. package/src/market-data/market/freshness.ts +25 -1
  47. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  48. package/src/market-data/quotes/resolution.ts +2 -0
  49. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  50. package/src/plugins/builtin/attention/headless.ts +19 -10
  51. package/src/plugins/builtin/attention/model.ts +22 -0
  52. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  53. package/src/plugins/builtin/changelog/index.tsx +11 -8
  54. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  55. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  56. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  57. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  58. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  59. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  60. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  61. package/src/plugins/builtin/chat/layout.ts +91 -31
  62. package/src/plugins/builtin/chat/markdown.ts +94 -0
  63. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  64. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  65. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  66. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  67. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  68. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  69. package/src/plugins/builtin/company-attention/model.ts +53 -2
  70. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  71. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  72. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  73. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  74. package/src/plugins/builtin/cot/client.ts +12 -4
  75. package/src/plugins/builtin/cot/pane.tsx +2 -2
  76. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  77. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  78. package/src/plugins/builtin/debug/index.tsx +15 -15
  79. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  80. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  81. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  82. package/src/plugins/builtin/docs/index.tsx +5 -3
  83. package/src/plugins/builtin/docs/pane.tsx +18 -1
  84. package/src/plugins/builtin/docs/targets.ts +5 -3
  85. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  86. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  87. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  88. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  89. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  90. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  91. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  92. package/src/plugins/builtin/futures/index.tsx +32 -4
  93. package/src/plugins/builtin/futures/model.ts +65 -3
  94. package/src/plugins/builtin/futures/table.tsx +31 -11
  95. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  96. package/src/plugins/builtin/holders/pane.tsx +6 -1
  97. package/src/plugins/builtin/mna/client.ts +12 -2
  98. package/src/plugins/builtin/mna/detail.tsx +2 -2
  99. package/src/plugins/builtin/mna/pane.tsx +2 -2
  100. package/src/plugins/builtin/notes/owner.tsx +5 -4
  101. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  102. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  103. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  104. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  105. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  106. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  107. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -9
  108. package/src/plugins/builtin/research/event-model.ts +14 -0
  109. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  110. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  111. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  112. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  113. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  114. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  115. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  116. package/src/plugins/builtin/research-search/data.ts +11 -0
  117. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  118. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  119. package/src/plugins/builtin/seasonality/client.ts +23 -0
  120. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  121. package/src/plugins/builtin/sectors/index.tsx +7 -3
  122. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  123. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  124. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  125. package/src/plugins/builtin/short-interest/model.ts +62 -3
  126. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  127. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  128. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  129. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  130. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  131. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  132. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  133. package/src/plugins/builtin/themes/headless.ts +1 -1
  134. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  135. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  136. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  137. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  138. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  139. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  140. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  141. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  142. package/src/renderers/dom/styles.css +6 -0
  143. package/src/sources/gloomberb-cloud/normalizers.ts +1 -0
  144. package/src/sources/provider-router/batches.ts +2 -1
  145. package/src/sources/provider-router/cache.ts +3 -0
  146. package/src/sync/baseline.ts +19 -14
  147. package/src/sync/controller.ts +140 -40
  148. package/src/sync/core-contributors.ts +256 -32
  149. package/src/sync/types.ts +44 -0
  150. package/src/time-series/resolve.ts +19 -2
  151. package/src/types/data-provider.ts +8 -0
  152. package/src/types/financials.ts +40 -4
  153. package/src/types/headless.ts +43 -0
  154. package/src/types/plugin.ts +7 -0
  155. package/src/utils/declared-dividends.ts +39 -0
  156. package/src/utils/forward-dividend.ts +34 -11
  157. package/src/utils/fundamentals.ts +13 -2
  158. package/src/utils/markdown.ts +180 -0
  159. package/src/utils/market-capitalization.ts +22 -0
@@ -10,7 +10,7 @@ import {
10
10
  useUiCapabilities,
11
11
  type InputRenderable,
12
12
  } from "../../ui";
13
- import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
13
+ import { useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
14
14
  import {
15
15
  blendHex,
16
16
  commandBarBg,
@@ -24,12 +24,12 @@ import {
24
24
  useCommandBarPromptBinding,
25
25
  type CommandBarPromptBinding,
26
26
  } from "../command-bar/panel/prompt-binding";
27
- import { useAppDispatch, useAppSelector } from "../../state/app/context";
27
+ import { useAppDispatch, useAppSelector, type AppState } from "../../state/app/context";
28
28
  import { useViewport } from "../../react/input";
29
29
  import { canRetryUpdate } from "../../app/global-shortcuts";
30
30
  import { t, tf } from "../../i18n";
31
31
  import { displayWidth, truncateToDisplayWidth } from "../../utils/format";
32
- import { ClippedText } from "../ui/display";
32
+ import { ClippedText, NATIVE_CLIP_STYLE } from "../ui/display";
33
33
  import { resolveMarketSummaryFit, useMarketSummary } from "./market-summary";
34
34
  import { resolveHeaderPromptGeometry } from "./shell/chrome";
35
35
  import { useWindowFullscreen } from "./window-fullscreen";
@@ -120,6 +120,28 @@ function nativePromptSurfaceStyle(colors: ReturnType<typeof useThemeColors>, ope
120
120
  };
121
121
  }
122
122
 
123
+ /**
124
+ * How fast the closed desktop prompt takes the bar's spare room next to the
125
+ * update slot, which grows by one millionth as much: only empty space, if any.
126
+ */
127
+ const NATIVE_PROMPT_GROW = 1_000_000;
128
+ /** How much faster the desktop update slot shrinks than an open prompt: it gives all of its room first. */
129
+ const NATIVE_UPDATE_SLOT_SHRINK = 1_000_000;
130
+ /**
131
+ * How the desktop prompt sits in the bar. Closed it draws no box, so it claims
132
+ * only its caret and placeholder, then grows toward `width` (the command
133
+ * sheet's) from the room the update status leaves, showing its key hint once it
134
+ * has room for it; Help and the market cluster never give any. Open it is the
135
+ * top of the sheet and keeps the sheet's width: the update status gives all of
136
+ * its room first (NATIVE_UPDATE_SLOT_SHRINK), and only a window too narrow for
137
+ * Help and the market cluster beside the open prompt takes from it.
138
+ */
139
+ function nativePromptFit(width: number, open: boolean) {
140
+ return open
141
+ ? { width, flexShrink: 1, minWidth: 0, overflow: "hidden" as const }
142
+ : { flexBasis: 0, flexGrow: NATIVE_PROMPT_GROW, flexShrink: 0, minWidth: "min-content", maxWidth: width };
143
+ }
144
+
123
145
  /**
124
146
  * The command bar's input while the bar is open. Gloomberb's panes are driven
125
147
  * by bare single keys, so the input only exists while the bar is open; the idle
@@ -228,7 +250,7 @@ function HeaderCommandPrompt({
228
250
 
229
251
  return (
230
252
  <Box
231
- width={width}
253
+ {...(nativePaneChrome ? nativePromptFit(width, open) : { width })}
232
254
  height={1}
233
255
  flexDirection="row"
234
256
  alignItems="center"
@@ -254,6 +276,21 @@ function HeaderCommandPrompt({
254
276
  <Text fg={caretColor} attributes={TextAttributes.BOLD}>{PROMPT_CARET}</Text>
255
277
  {open ? (
256
278
  binding ? <HeaderPromptInput binding={binding} nativePaneChrome={nativePaneChrome} width={inputWidth} /> : null
279
+ ) : nativePaneChrome ? (
280
+ // One line that wraps and hides its second: the key hint drops whole when the
281
+ // prompt is too narrow for it, and the placeholder never does.
282
+ <Box
283
+ flexDirection="row"
284
+ height={1}
285
+ flexGrow={1}
286
+ flexShrink={1}
287
+ minWidth={0}
288
+ overflow="hidden"
289
+ style={{ flexWrap: "wrap", alignContent: "flex-start" }}
290
+ >
291
+ <Text fg={mutedColor}>{placeholder}</Text>
292
+ {shortcut ? <Text fg={shortcutColor}>{` ${shortcut}`}</Text> : null}
293
+ </Box>
257
294
  ) : (
258
295
  <>
259
296
  <Text fg={mutedColor}>{placeholder}</Text>
@@ -266,128 +303,314 @@ function HeaderCommandPrompt({
266
303
  }
267
304
 
268
305
  /**
269
- * What the header says about an update. In the terminal it is cut to `columns`,
270
- * the room its slot is guaranteed (the desktop clips it in CSS where the slot
271
- * ends), so it never runs into the Help pill or the market cluster beside it.
306
+ * What the header says about an update, before either renderer lays it out:
307
+ * a spinner and a line while something runs, a line on its own, the
308
+ * downloaded update with its Restart button, or a failed update with the key
309
+ * that retries it.
272
310
  */
273
- function UpdateStatus({ columns, onRestart }: { columns: number; onRestart?: () => void }) {
274
- const colors = useThemeColors();
275
- const { nativePaneChrome = false } = useUiCapabilities();
276
- const dispatch = useAppDispatch();
277
- const updateAvailable = useAppSelector((state) => state.updateAvailable);
278
- const updateProgress = useAppSelector((state) => state.updateProgress);
279
- const updateCheckInProgress = useAppSelector((state) => state.updateCheckInProgress);
280
- const updateNotice = useAppSelector((state) => state.updateNotice);
281
- // One key: Restart for a downloaded update, retry for a failed one.
282
- const updateKey = useActionShortcut("install-update");
311
+ type UpdateStatusView =
312
+ | { kind: "busy"; text: string; parts: StatusPart[] }
313
+ | { kind: "text"; text: string }
314
+ | { kind: "restart"; text: string; version: string }
315
+ | { kind: "failed"; message: string; parts: StatusPart[]; retry: boolean };
283
316
 
284
- useEffect(() => {
285
- if (!updateNotice || updateAvailable || updateProgress || updateCheckInProgress) return;
286
- const timeout = setTimeout(() => {
287
- dispatch({ type: "SET_UPDATE_NOTICE", notice: null });
288
- }, UPDATE_NOTICE_DURATION_MS);
289
- return () => clearTimeout(timeout);
290
- }, [dispatch, updateAvailable, updateCheckInProgress, updateNotice, updateProgress]);
317
+ /**
318
+ * A line of the status in the pieces the desktop may treat apart: a `cut`
319
+ * piece ends in an ellipsis and gives way, the rest stays whole. The download
320
+ * keeps its percent and the failure the words that say so, while the version
321
+ * and the error give way.
322
+ */
323
+ interface StatusPart { text: string; cut: boolean }
324
+
325
+ /** Stands in for one value while a line is formatted, to find where that value sits in it. */
326
+ const SPLIT_MARK = "\u0000";
327
+
328
+ /** `marked`, formatted with SPLIT_MARK for `value`, as the pieces around the value: one whole line when it does not show. */
329
+ function partsAround(marked: string, value: string, valueIsCut: boolean): StatusPart[] {
330
+ const [before, after, ...rest] = marked.split(SPLIT_MARK);
331
+ if (after === undefined || rest.length > 0) return [{ text: marked.replaceAll(SPLIT_MARK, value), cut: true }];
332
+ const parts = valueIsCut
333
+ ? [{ text: before!, cut: false }, { text: `${value}${after}`, cut: true }]
334
+ : [{ text: before!, cut: true }, { text: `${value}${after}`, cut: false }];
335
+ return parts.filter((part) => part.text.length > 0);
336
+ }
291
337
 
338
+ function resolveUpdateStatusView({
339
+ canRestart,
340
+ updateAvailable,
341
+ updateCheckInProgress,
342
+ updateNotice,
343
+ updateProgress,
344
+ }: Pick<AppState, "updateAvailable" | "updateProgress" | "updateCheckInProgress" | "updateNotice"> & {
345
+ /** A Restart button can apply a downloaded update here. */
346
+ canRestart: boolean;
347
+ }): UpdateStatusView | null {
292
348
  if (updateProgress) {
293
349
  if (updateProgress.phase === "downloading") {
294
- return (
295
- <Box flexDirection="row" gap={1}>
296
- <SpinnerMark name="dots" color={colors.headerText} />
297
- <Text fg={colors.headerText}>
298
- {tf("Downloading v{version}: {percent}%", {
299
- version: updateAvailable?.version ?? "",
300
- percent: updateProgress.percent ?? 0,
301
- })}
302
- </Text>
303
- </Box>
304
- );
350
+ const version = updateAvailable?.version ?? "";
351
+ const percent = String(updateProgress.percent ?? 0);
352
+ return {
353
+ kind: "busy",
354
+ text: tf("Downloading v{version}: {percent}%", { version, percent }),
355
+ parts: partsAround(tf("Downloading v{version}: {percent}%", { version, percent: SPLIT_MARK }), percent, false),
356
+ };
305
357
  }
306
358
  if (updateProgress.phase === "replacing") {
307
- return (
308
- <Box flexDirection="row" gap={1}>
309
- <SpinnerMark name="dots" color={colors.headerText} />
310
- <Text fg={colors.headerText}>{t("Installing update...")}</Text>
311
- </Box>
312
- );
359
+ const text = t("Installing update...");
360
+ return { kind: "busy", text, parts: [{ text, cut: true }] };
313
361
  }
314
362
  if (updateProgress.phase === "ready") {
315
363
  // A desktop update waits for the user: the app never relaunches by itself.
316
364
  // The terminal binary is already swapped in and only needs a new launch.
317
- if (updateProgress.canRestart && onRestart) {
318
- return (
319
- <Box flexDirection="row" gap={1} alignItems="center">
320
- <Text fg={colors.headerText}>{t("Update ready")}</Text>
321
- <Button compact label="Restart" shortcut={updateKey || undefined} onPress={onRestart} stopPropagation />
322
- </Box>
323
- );
365
+ if (updateProgress.canRestart && canRestart) {
366
+ return { kind: "restart", text: t("Update ready"), version: updateAvailable?.version ?? "" };
324
367
  }
325
- return <Text fg={colors.headerText}>{t("Update ready, restart to apply")}</Text>;
368
+ return { kind: "text", text: t("Update ready, restart to apply") };
326
369
  }
327
370
  if (updateProgress.phase === "done") {
328
- return <Text fg={colors.headerText}>{t(updateProgress.message ?? "Update ready, restart to apply")}</Text>;
371
+ return { kind: "text", text: t(updateProgress.message ?? "Update ready, restart to apply") };
329
372
  }
330
373
  if (updateProgress.phase === "error") {
331
374
  // One line: an error that carries a stack or a command's output must not grow the header.
332
- const message = tf("Update failed: {error}", { error: (updateProgress.error ?? "Unknown error").replace(/\s+/g, " ").trim() });
333
- const retry = updateKey && canRetryUpdate({ updateAvailable, updateProgress, updateCheckInProgress });
334
- const hint = retry ? ` ${updateKey} ${t("retry")}` : "";
335
- const hintColumns = displayWidth(hint);
336
- // The message goes first, and keeps its room before the hint takes any.
337
- const room = columns - UPDATE_ERROR_GAP_COLUMNS;
338
- const showHint = hintColumns > 0 && (nativePaneChrome || room - hintColumns >= UPDATE_ERROR_MIN_COLUMNS);
339
- const messageColumns = room - (showHint ? hintColumns : 0);
340
- if (!nativePaneChrome && messageColumns < UPDATE_ERROR_MIN_COLUMNS) return null;
341
- return (
342
- <Box flexDirection="row" overflow="hidden" paddingRight={UPDATE_ERROR_GAP_COLUMNS}>
343
- <ClippedText fg={colors.headerText} text={message} width={messageColumns} />
344
- {showHint ? <Text fg={blendHex(colors.headerText, colors.header, 0.62)}>{hint}</Text> : null}
345
- </Box>
346
- );
375
+ const error = (updateProgress.error ?? "Unknown error").replace(/\s+/g, " ").trim();
376
+ return {
377
+ kind: "failed",
378
+ message: tf("Update failed: {error}", { error }),
379
+ parts: partsAround(tf("Update failed: {error}", { error: SPLIT_MARK }), error, true),
380
+ retry: canRetryUpdate({ updateAvailable, updateProgress, updateCheckInProgress }),
381
+ };
347
382
  }
348
383
  }
349
-
350
384
  if (updateCheckInProgress) {
351
- return (
352
- <Box flexDirection="row" gap={1}>
353
- <SpinnerMark name="dots" color={colors.headerText} />
354
- <Text fg={colors.headerText}>{t("Checking for updates...")}</Text>
355
- </Box>
356
- );
385
+ const text = t("Checking for updates...");
386
+ return { kind: "busy", text, parts: [{ text, cut: true }] };
357
387
  }
358
-
359
388
  if (updateAvailable) {
360
389
  if (updateAvailable.updateAction.kind === "manual") {
361
- return (
362
- <Text fg={colors.headerText}>
363
- {tf("v{version} available — run {command}", {
364
- version: updateAvailable.version,
365
- command: updateAvailable.updateAction.command,
366
- })}
367
- </Text>
368
- );
390
+ return {
391
+ kind: "text",
392
+ text: tf("v{version} available — run {command}", {
393
+ version: updateAvailable.version,
394
+ command: updateAvailable.updateAction.command,
395
+ }),
396
+ };
369
397
  }
370
398
  if (updateAvailable.updateAction.kind === "managed") {
371
- return (
372
- <Text fg={colors.headerText}>
373
- {tf("v{version} available, update through the package manager that installed Gloomberb", {
374
- version: updateAvailable.version,
375
- })}
376
- </Text>
377
- );
399
+ return {
400
+ kind: "text",
401
+ text: tf("v{version} available, update through the package manager that installed Gloomberb", {
402
+ version: updateAvailable.version,
403
+ }),
404
+ };
378
405
  }
406
+ return { kind: "text", text: tf("v{version} available — starting download...", { version: updateAvailable.version }) };
407
+ }
408
+ if (updateNotice) return { kind: "text", text: updateNotice };
409
+ return null;
410
+ }
411
+
412
+ /**
413
+ * What the header says about an update. The terminal cuts it to `columns`,
414
+ * the room its slot is guaranteed; the desktop and the web let the browser fit
415
+ * it to the room the bar leaves (NativeUpdateStatus). Either way it never runs
416
+ * into the Help pill or the market cluster beside it.
417
+ */
418
+ function UpdateStatus({ columns, onRestart, trailingGap = false }: {
419
+ columns: number;
420
+ onRestart?: () => void;
421
+ /** The desktop title bar keeps a gap after the status as well as before it. */
422
+ trailingGap?: boolean;
423
+ }) {
424
+ const { nativePaneChrome = false } = useUiCapabilities();
425
+ const dispatch = useAppDispatch();
426
+ const updateAvailable = useAppSelector((state) => state.updateAvailable);
427
+ const updateProgress = useAppSelector((state) => state.updateProgress);
428
+ const updateCheckInProgress = useAppSelector((state) => state.updateCheckInProgress);
429
+ const updateNotice = useAppSelector((state) => state.updateNotice);
430
+ // One key: Restart for a downloaded update, retry for a failed one.
431
+ const updateKey = useActionShortcut("install-update");
432
+
433
+ useEffect(() => {
434
+ if (!updateNotice || updateAvailable || updateProgress || updateCheckInProgress) return;
435
+ const timeout = setTimeout(() => {
436
+ dispatch({ type: "SET_UPDATE_NOTICE", notice: null });
437
+ }, UPDATE_NOTICE_DURATION_MS);
438
+ return () => clearTimeout(timeout);
439
+ }, [dispatch, updateAvailable, updateCheckInProgress, updateNotice, updateProgress]);
440
+
441
+ const view = resolveUpdateStatusView({
442
+ canRestart: !!onRestart,
443
+ updateAvailable,
444
+ updateCheckInProgress,
445
+ updateNotice,
446
+ updateProgress,
447
+ });
448
+ if (!view) return null;
449
+ return nativePaneChrome
450
+ ? <NativeUpdateStatus view={view} onRestart={onRestart} trailingGap={trailingGap} updateKey={updateKey} />
451
+ : <TerminalUpdateStatus view={view} columns={columns} onRestart={onRestart} updateKey={updateKey} />;
452
+ }
453
+
454
+ function TerminalUpdateStatus({ columns, onRestart, updateKey, view }: {
455
+ columns: number;
456
+ onRestart?: () => void;
457
+ updateKey: string;
458
+ view: UpdateStatusView;
459
+ }) {
460
+ const colors = useThemeColors();
461
+ if (view.kind === "busy") {
379
462
  return (
380
- <Text fg={colors.headerText}>
381
- {tf("v{version} available — starting download...", { version: updateAvailable.version })}
382
- </Text>
463
+ <Box flexDirection="row" gap={1}>
464
+ <SpinnerMark name="dots" color={colors.headerText} />
465
+ <Text fg={colors.headerText}>{view.text}</Text>
466
+ </Box>
383
467
  );
384
468
  }
385
-
386
- if (updateNotice) {
387
- return <Text fg={colors.headerText}>{updateNotice}</Text>;
469
+ if (view.kind === "restart") {
470
+ return (
471
+ <Box flexDirection="row" gap={1} alignItems="center">
472
+ <Text fg={colors.headerText}>{view.text}</Text>
473
+ <Button compact label="Restart" shortcut={updateKey || undefined} onPress={onRestart} stopPropagation />
474
+ </Box>
475
+ );
476
+ }
477
+ if (view.kind === "failed") {
478
+ const hint = updateKey && view.retry ? ` ${updateKey} ${t("retry")}` : "";
479
+ const hintColumns = displayWidth(hint);
480
+ // The message goes first, and keeps its room before the hint takes any.
481
+ const room = columns - UPDATE_ERROR_GAP_COLUMNS;
482
+ const showHint = hintColumns > 0 && room - hintColumns >= UPDATE_ERROR_MIN_COLUMNS;
483
+ const messageColumns = room - (showHint ? hintColumns : 0);
484
+ if (messageColumns < UPDATE_ERROR_MIN_COLUMNS) return null;
485
+ return (
486
+ <Box flexDirection="row" overflow="hidden" paddingRight={UPDATE_ERROR_GAP_COLUMNS}>
487
+ <ClippedText fg={colors.headerText} text={view.message} width={messageColumns} />
488
+ {showHint ? <Text fg={blendHex(colors.headerText, colors.header, 0.62)}>{hint}</Text> : null}
489
+ </Box>
490
+ );
388
491
  }
492
+ return <Text fg={colors.headerText}>{view.text}</Text>;
493
+ }
389
494
 
390
- return null;
495
+ /**
496
+ * Text in the desktop status that gives way: it ends in an ellipsis as its room
497
+ * shrinks, and goes rather than leave a stub. Alone on its line it goes below
498
+ * about this many characters; beside a piece that stays whole it takes what
499
+ * that piece leaves, down to NATIVE_STATUS_PIECE_MIN_CH.
500
+ */
501
+ const NATIVE_STATUS_TEXT_MIN_CH = 8;
502
+ const NATIVE_STATUS_PIECE_MIN_CH = 3;
503
+ function nativeCutTextStyle(alone: boolean) {
504
+ return {
505
+ ...NATIVE_CLIP_STYLE,
506
+ flexGrow: 1,
507
+ flexShrink: 1,
508
+ flexBasis: `${alone ? NATIVE_STATUS_TEXT_MIN_CH : NATIVE_STATUS_PIECE_MIN_CH}ch`,
509
+ maxWidth: "max-content",
510
+ } as const;
511
+ }
512
+ /** One cell between the parts of the desktop status, as `gap={1}` puts it in the terminal. */
513
+ const NATIVE_STATUS_GAP = "var(--cell-w)";
514
+
515
+ /**
516
+ * The desktop and web update status, fitted by the browser to whatever room
517
+ * the bar leaves it, never by counting cells. Its parts are given most
518
+ * important first, and the row is one line tall and wraps: a part that does
519
+ * not fit whole moves to a second line the row hides, and so does every part
520
+ * after it, so a button is never cut, a spinner never spins on its own, and
521
+ * nothing runs under Help. Text that may be cut gives way before anything
522
+ * moves, ending in an ellipsis (nativeCutTextStyle). `reverse` lays the parts
523
+ * out right to left, for a status whose most important part is on its right:
524
+ * Restart stays when "Update ready" goes.
525
+ */
526
+ function NativeStatusRow({ children, reverse = false, trailingGap }: {
527
+ children: ReactNode;
528
+ reverse?: boolean;
529
+ trailingGap: boolean;
530
+ }) {
531
+ return (
532
+ <Box
533
+ height={1}
534
+ flexGrow={1}
535
+ flexShrink={1}
536
+ minWidth={0}
537
+ overflow="hidden"
538
+ paddingLeft={UPDATE_SLOT_PADDING_COLUMNS}
539
+ paddingRight={trailingGap ? UPDATE_SLOT_PADDING_COLUMNS : 0}
540
+ style={{
541
+ flexDirection: reverse ? "row-reverse" : "row",
542
+ flexWrap: "wrap",
543
+ alignContent: "flex-start",
544
+ alignItems: "center",
545
+ justifyContent: reverse ? "flex-end" : "flex-start",
546
+ }}
547
+ >
548
+ {/* Takes no room and always holds the first line, so even the most important part can leave it. */}
549
+ <Box width={0} height={1} />
550
+ {children}
551
+ </Box>
552
+ );
553
+ }
554
+
555
+ /** One piece of a status line, with the whole line as its tooltip in case it is cut. */
556
+ function nativeStatusPiece(part: StatusPart, index: number, { alone, color, title }: { alone: boolean; color: string; title: string }) {
557
+ return part.cut
558
+ ? <Text key={index} fg={color} title={title} style={nativeCutTextStyle(alone)}>{part.text}</Text>
559
+ : <Text key={index} fg={color} title={title}>{part.text}</Text>;
560
+ }
561
+
562
+ function NativeUpdateStatus({ onRestart, trailingGap, updateKey, view }: {
563
+ onRestart?: () => void;
564
+ trailingGap: boolean;
565
+ updateKey: string;
566
+ view: UpdateStatusView;
567
+ }) {
568
+ const colors = useThemeColors();
569
+ const color = colors.headerText;
570
+ if (view.kind === "busy") {
571
+ // Right to left: the percent, then the words, then the spinner, which never shows without them.
572
+ const alone = view.parts.every((part) => part.cut);
573
+ return (
574
+ <NativeStatusRow reverse trailingGap={trailingGap}>
575
+ {view.parts.map((part, index) => nativeStatusPiece(part, index, { alone, color, title: view.text })).reverse()}
576
+ <Box flexShrink={0} style={{ marginRight: NATIVE_STATUS_GAP }}>
577
+ <SpinnerMark name="dots" color={color} />
578
+ </Box>
579
+ </NativeStatusRow>
580
+ );
581
+ }
582
+ if (view.kind === "restart") {
583
+ // Right to left: the button, then the words, which go whole before it does.
584
+ return (
585
+ <NativeStatusRow reverse trailingGap={trailingGap}>
586
+ <Button
587
+ compact
588
+ label="Restart"
589
+ shortcut={updateKey || undefined}
590
+ title={view.version ? tf("Restart to apply v{version}", { version: view.version }) : undefined}
591
+ onPress={onRestart}
592
+ stopPropagation
593
+ />
594
+ <Text fg={color} style={{ marginRight: NATIVE_STATUS_GAP }}>{view.text}</Text>
595
+ </NativeStatusRow>
596
+ );
597
+ }
598
+ if (view.kind === "failed") {
599
+ // The words that say it failed, then as much of the error as reads, then the retry key.
600
+ const hint = updateKey && view.retry ? ` ${updateKey} ${t("retry")}` : "";
601
+ const alone = view.parts.every((part) => part.cut);
602
+ return (
603
+ <NativeStatusRow trailingGap={trailingGap}>
604
+ {view.parts.map((part, index) => nativeStatusPiece(part, index, { alone, color, title: view.message }))}
605
+ {hint ? <Text fg={blendHex(color, colors.header, 0.62)}>{hint}</Text> : null}
606
+ </NativeStatusRow>
607
+ );
608
+ }
609
+ return (
610
+ <NativeStatusRow trailingGap={trailingGap}>
611
+ {nativeStatusPiece({ text: view.text, cut: true }, 0, { alone: true, color, title: view.text })}
612
+ </NativeStatusRow>
613
+ );
391
614
  }
392
615
 
393
616
  /**
@@ -434,7 +657,8 @@ function HeaderMarketSummary({ nativePaneChrome, width }: { nativePaneChrome: bo
434
657
  /**
435
658
  * Help, one click away on every renderer: the same pane the typed HELP and the
436
659
  * `?` key open. It takes its columns from the update status beside it, which
437
- * is transient, rather than from the prompt or the market cluster.
660
+ * is transient, rather than from the market cluster; on the desktop and the
661
+ * web the closed prompt's empty room makes way for it as well.
438
662
  */
439
663
  function HeaderHelpButton({ nativePaneChrome, onPress }: { nativePaneChrome: boolean; onPress: () => void }) {
440
664
  return (
@@ -508,6 +732,24 @@ export function Header({
508
732
  const marketSummary = prompt.marketColumns > 0
509
733
  ? <HeaderMarketSummary nativePaneChrome={nativePaneChrome} width={prompt.marketColumns} />
510
734
  : null;
735
+ // The desktop and the web fit the update status in CSS (NativeUpdateStatus):
736
+ // its slot takes the room the closed prompt leaves, clips at its own edge, and
737
+ // gives all of it up before an open prompt gives any. Help, the market
738
+ // cluster and the caption buttons keep their size.
739
+ const nativeUpdateSlot = nativePaneChrome ? (
740
+ <Box
741
+ flexDirection="row"
742
+ alignItems="center"
743
+ flexGrow={1}
744
+ flexShrink={NATIVE_UPDATE_SLOT_SHRINK}
745
+ minWidth={0}
746
+ overflow="hidden"
747
+ data-gloom-role="header-update-status"
748
+ style={{ alignSelf: "stretch" }}
749
+ >
750
+ <UpdateStatus columns={updateStatusColumns} onRestart={onRestartForUpdate} trailingGap={!!titleBarOverlay} />
751
+ </Box>
752
+ ) : null;
511
753
 
512
754
  if (titleBarOverlay) {
513
755
  return (
@@ -527,9 +769,11 @@ export function Header({
527
769
  >
528
770
  <Box width={prompt.left} />
529
771
  {commandPrompt}
530
- <Box flexGrow={1} paddingLeft={UPDATE_SLOT_PADDING_COLUMNS} paddingRight={UPDATE_SLOT_PADDING_COLUMNS} minWidth={0}>
531
- <UpdateStatus columns={updateStatusColumns} onRestart={onRestartForUpdate} />
532
- </Box>
772
+ {nativeUpdateSlot ?? (
773
+ <Box flexGrow={1} paddingLeft={UPDATE_SLOT_PADDING_COLUMNS} paddingRight={UPDATE_SLOT_PADDING_COLUMNS} minWidth={0}>
774
+ <UpdateStatus columns={updateStatusColumns} onRestart={onRestartForUpdate} />
775
+ </Box>
776
+ )}
533
777
  {onOpenHelp ? <HeaderHelpButton nativePaneChrome onPress={onOpenHelp} /> : null}
534
778
  {marketSummary}
535
779
  {showWindowControls ? <Box flexShrink={0} width={`${WINDOWS_CONTROL_GROUP_WIDTH_PX}px`} /> : null}
@@ -549,9 +793,11 @@ export function Header({
549
793
  >
550
794
  <Box width={prompt.left} />
551
795
  {commandPrompt}
552
- <Box flexGrow={1} minWidth={0} paddingLeft={UPDATE_SLOT_PADDING_COLUMNS}>
553
- <UpdateStatus columns={updateStatusColumns} onRestart={onRestartForUpdate} />
554
- </Box>
796
+ {nativeUpdateSlot ?? (
797
+ <Box flexGrow={1} minWidth={0} paddingLeft={UPDATE_SLOT_PADDING_COLUMNS}>
798
+ <UpdateStatus columns={updateStatusColumns} onRestart={onRestartForUpdate} />
799
+ </Box>
800
+ )}
555
801
  {onOpenHelp ? <HeaderHelpButton nativePaneChrome={nativePaneChrome} onPress={onOpenHelp} /> : null}
556
802
  {marketSummary}
557
803
  {showWindowControls ? <WindowControls /> : null}