gloomberb 0.17.3 → 0.17.5

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 (164) hide show
  1. package/package.json +2 -1
  2. package/patches/electrobun@1.18.1.patch +80 -0
  3. package/src/api-client/chat.ts +28 -0
  4. package/src/api-client/data.ts +4 -1
  5. package/src/api-client/errors.ts +11 -1
  6. package/src/api-client/index.ts +4 -1
  7. package/src/api-client/normalizers.ts +21 -0
  8. package/src/api-client/research-activity.ts +10 -3
  9. package/src/api-client/socket.ts +27 -0
  10. package/src/app/pane-runtime/ticker-open-runtime.ts +2 -1
  11. package/src/app/runtime/startup.ts +4 -0
  12. package/src/app/runtime/update.ts +50 -3
  13. package/src/app.tsx +2 -0
  14. package/src/ask-gloom/pane-context.ts +11 -1
  15. package/src/cli/commands/overview.ts +2 -2
  16. package/src/cli/commands/system.ts +19 -1
  17. package/src/cli/glossary.ts +34 -4
  18. package/src/cli/index.ts +6 -0
  19. package/src/cli/pane-functions/function-help.ts +2 -2
  20. package/src/cli/pane-functions/market-session.ts +18 -3
  21. package/src/components/chart/composite/format.ts +7 -0
  22. package/src/components/chart/composite/legend.tsx +8 -11
  23. package/src/components/command-bar/commands/direct/index.ts +47 -0
  24. package/src/components/command-bar/commands/registry.ts +8 -0
  25. package/src/components/command-bar/panel/prompt-binding.ts +8 -0
  26. package/src/components/command-bar/routes/root/command-items.ts +16 -1
  27. package/src/components/command-bar/surface/index.tsx +2 -1
  28. package/src/components/command-bar/workflow/ops.ts +4 -3
  29. package/src/components/form-modal/confirm.tsx +1 -0
  30. package/src/components/form-modal/request.ts +2 -0
  31. package/src/components/index.ts +1 -1
  32. package/src/components/layout/detached-pane-shell.tsx +6 -1
  33. package/src/components/layout/pane/sidebar.tsx +24 -11
  34. package/src/components/layout/shell/index.tsx +5 -1
  35. package/src/components/layout/shell/menu.ts +8 -0
  36. package/src/components/layout/shell/pane/capture.ts +29 -12
  37. package/src/components/metric-treemap/heat-surface.tsx +76 -14
  38. package/src/components/metric-treemap/heat-tiles.ts +5 -3
  39. package/src/components/metric-treemap/index.tsx +1 -1
  40. package/src/components/ui/button.tsx +24 -9
  41. package/src/components/ui/confirm-dialog.tsx +30 -4
  42. package/src/components/ui/fields.tsx +5 -0
  43. package/src/components/ui/index.ts +1 -1
  44. package/src/components/ui/status.tsx +148 -5
  45. package/src/data/config/store/normalize.ts +8 -0
  46. package/src/i18n/es.ts +2 -0
  47. package/src/i18n/ja.ts +2 -0
  48. package/src/i18n/ko.ts +2 -0
  49. package/src/i18n/zh-cn.ts +2 -0
  50. package/src/i18n/zh-tw.ts +2 -0
  51. package/src/layout-marketplace/gallery-desktop.tsx +2 -2
  52. package/src/market-data/coordinator/events.ts +5 -1
  53. package/src/market-data/coordinator/index.ts +4 -0
  54. package/src/market-data/coordinator/quotes.ts +4 -0
  55. package/src/market-data/futures-board.ts +44 -1
  56. package/src/market-data/hooks.tsx +168 -29
  57. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  58. package/src/plugins/builtin/alerts/cli.ts +72 -11
  59. package/src/plugins/builtin/awards/headless.ts +5 -1
  60. package/src/plugins/builtin/broker-manager/detail.tsx +2 -2
  61. package/src/plugins/builtin/broker-manager/model.ts +2 -2
  62. package/src/plugins/builtin/catalysts/headless.ts +5 -1
  63. package/src/plugins/builtin/chart-composer/headless.ts +2 -1
  64. package/src/plugins/builtin/chart-composer/pane.tsx +20 -2
  65. package/src/plugins/builtin/chat/content/footer.tsx +5 -2
  66. package/src/plugins/builtin/chat/controller/connections.ts +18 -0
  67. package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
  68. package/src/plugins/builtin/chat/controller/index.ts +69 -21
  69. package/src/plugins/builtin/chat/controller/messages.ts +14 -2
  70. package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
  71. package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
  72. package/src/plugins/builtin/chat/controller/state.ts +15 -0
  73. package/src/plugins/builtin/chat/controller/unread-people.ts +240 -0
  74. package/src/plugins/builtin/chat/controller/view.ts +2 -0
  75. package/src/plugins/builtin/chat/index.tsx +1 -1
  76. package/src/plugins/builtin/chat/share/flow.tsx +18 -9
  77. package/src/plugins/builtin/chat/unread-badge.ts +38 -7
  78. package/src/plugins/builtin/cloud/askg/pane-attachment.ts +2 -3
  79. package/src/plugins/builtin/cot/model.ts +20 -0
  80. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  81. package/src/plugins/builtin/docs/pane.tsx +1 -1
  82. package/src/plugins/builtin/futures/index.tsx +2 -3
  83. package/src/plugins/builtin/futures/model.ts +4 -2
  84. package/src/plugins/builtin/futures/series.ts +3 -3
  85. package/src/plugins/builtin/futures-curve/headless.ts +13 -6
  86. package/src/plugins/builtin/futures-curve/index.tsx +3 -3
  87. package/src/plugins/builtin/futures-curve/model.ts +64 -4
  88. package/src/plugins/builtin/futures-curve/pane.tsx +11 -4
  89. package/src/plugins/builtin/jobs/pane.tsx +12 -9
  90. package/src/plugins/builtin/kelly-sizer/headless.ts +1 -1
  91. package/src/plugins/builtin/kelly-sizer/history.ts +5 -3
  92. package/src/plugins/builtin/kelly-sizer/pane.tsx +1 -1
  93. package/src/plugins/builtin/macro-day/release-table.ts +3 -3
  94. package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
  95. package/src/plugins/builtin/mna/model.ts +2 -1
  96. package/src/plugins/builtin/news/headless.ts +4 -2
  97. package/src/plugins/builtin/options-positioning/model.ts +14 -0
  98. package/src/plugins/builtin/options-positioning/pane.tsx +17 -10
  99. package/src/plugins/builtin/perps/client.ts +77 -6
  100. package/src/plugins/builtin/perps/headless.ts +25 -14
  101. package/src/plugins/builtin/perps/index.tsx +1 -1
  102. package/src/plugins/builtin/perps/model.ts +32 -0
  103. package/src/plugins/builtin/perps/pane.tsx +5 -4
  104. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  105. package/src/plugins/builtin/rate-path/model.ts +1 -1
  106. package/src/plugins/builtin/rate-path/pane.tsx +2 -1
  107. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -3
  108. package/src/plugins/builtin/research/event-sources.ts +1 -1
  109. package/src/plugins/builtin/research/relative-valuation-model.ts +6 -0
  110. package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -3
  111. package/src/plugins/builtin/sectors/client.ts +46 -19
  112. package/src/plugins/builtin/sectors/sector-model.ts +22 -10
  113. package/src/plugins/builtin/thirteenf/api.ts +60 -43
  114. package/src/plugins/builtin/thirteenf/data.ts +76 -35
  115. package/src/plugins/builtin/thirteenf/headless.ts +2 -0
  116. package/src/plugins/builtin/thirteenf/pane.tsx +10 -2
  117. package/src/plugins/builtin/yield-curve/curves.ts +15 -1
  118. package/src/plugins/builtin/yield-curve/headless.ts +17 -7
  119. package/src/plugins/builtin/yield-curve/history.ts +2 -0
  120. package/src/plugins/builtin/yield-curve/index.tsx +23 -18
  121. package/src/plugins/builtin/yield-curve/series.ts +19 -9
  122. package/src/plugins/builtin/yield-curve/treasury-data.ts +2 -0
  123. package/src/plugins/builtin/yield-curve/world.ts +4 -0
  124. package/src/plugins/registry/host-actions.ts +3 -0
  125. package/src/renderers/browser/app-services.ts +7 -1
  126. package/src/renderers/browser/config-host.ts +55 -2
  127. package/src/renderers/browser/first-screen.ts +193 -0
  128. package/src/renderers/browser/main.tsx +59 -7
  129. package/src/renderers/dom/build-assets.ts +17 -4
  130. package/src/renderers/dom/desktop/controls.tsx +4 -0
  131. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -0
  132. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +3 -0
  133. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
  134. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
  135. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
  136. package/src/renderers/electrobun/bun/desktop/update.ts +115 -12
  137. package/src/renderers/electrobun/shared/protocol.ts +3 -2
  138. package/src/renderers/electrobun/view/update-host.ts +2 -2
  139. package/src/shares/bug-report-dialog.tsx +162 -0
  140. package/src/shares/bug-report-flow.tsx +84 -0
  141. package/src/shares/bug-report.ts +42 -0
  142. package/src/shares/capture-flow.ts +19 -0
  143. package/src/shares/capture-preview.tsx +129 -0
  144. package/src/shares/chat.ts +10 -1
  145. package/src/shares/dialog.tsx +16 -91
  146. package/src/shares/flow.tsx +12 -16
  147. package/src/shares/personal-pane.ts +35 -1
  148. package/src/sources/gloomberb-cloud/normalizers.ts +12 -4
  149. package/src/telemetry/usage-counts.ts +16 -0
  150. package/src/tickers/open-target.ts +61 -11
  151. package/src/time-series/resolve.ts +26 -9
  152. package/src/time-series/spec.ts +8 -0
  153. package/src/types/config.ts +14 -0
  154. package/src/ui/resize-settle.ts +128 -0
  155. package/src/updater/channel.ts +51 -0
  156. package/src/updater/github-releases.ts +85 -16
  157. package/src/updater/index.ts +125 -16
  158. package/src/utils/calendar-date.ts +56 -0
  159. package/src/utils/datetime-format.ts +7 -1
  160. package/src/utils/decimal-comma.ts +31 -0
  161. package/src/utils/futures-generic.ts +25 -4
  162. package/src/utils/plugin-proxy-hosts.json +5 -0
  163. package/src/utils/semver.ts +47 -2
  164. package/src/utils/utc-time.ts +14 -3
@@ -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 };
@@ -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
  )}
@@ -36,6 +36,12 @@ export interface ConfirmDialogProps extends PromptContext<boolean> {
36
36
  * opens on its own while the person may be typing. Esc and `n` always answer.
37
37
  */
38
38
  keyGraceMs?: number;
39
+ /**
40
+ * What Enter answers. `cancel` is for an opt-in a stray Enter must not
41
+ * accept: Enter takes the cancel button, which is drawn as the primary one,
42
+ * and only `y` or the confirm button confirms.
43
+ */
44
+ defaultAction?: "confirm" | "cancel";
39
45
  }
40
46
 
41
47
  export function ConfirmDialog({
@@ -52,7 +58,9 @@ export function ConfirmDialog({
52
58
  onClose,
53
59
  formButtons = false,
54
60
  keyGraceMs = 0,
61
+ defaultAction = "confirm",
55
62
  }: ConfirmDialogProps) {
63
+ const enterCancels = defaultAction === "cancel";
56
64
  const desktop = useUiHost().kind === "desktop-web";
57
65
  const openedAt = useRef(Date.now());
58
66
  const confirm = useCallback(() => resolve(true), [resolve]);
@@ -71,7 +79,13 @@ export function ConfirmDialog({
71
79
  event.preventDefault();
72
80
  return;
73
81
  }
74
- if (event.name === "enter" || event.name === "return" || isPlainKey(event, "y")) {
82
+ const enter = event.name === "enter" || event.name === "return";
83
+ if (enter && enterCancels) {
84
+ event.preventDefault();
85
+ cancel();
86
+ return;
87
+ }
88
+ if (enter || isPlainKey(event, "y")) {
75
89
  event.preventDefault();
76
90
  if (Date.now() - openedAt.current < keyGraceMs) return;
77
91
  confirm();
@@ -88,13 +102,25 @@ export function ConfirmDialog({
88
102
 
89
103
  // The desktop shows the keys on the buttons instead of a footer line; a
90
104
  // confirm with no key line keeps its buttons plain there too.
105
+ // One primary button, the one Enter answers.
91
106
  const keys = footer !== "";
92
- const confirmButton = <Button label={confirmLabel} variant={confirmVariant} disabled={busy} desktopShortcut={keys ? "Enter" : undefined} onPress={confirm} />;
93
- const cancelButton = <Button label={cancelLabel} variant="secondary" disabled={busy} desktopShortcut={keys ? "Esc" : undefined} onPress={cancel} />;
107
+ const confirmButton = (
108
+ <Button
109
+ label={confirmLabel}
110
+ variant={enterCancels && confirmVariant === "primary" ? "secondary" : confirmVariant}
111
+ disabled={busy}
112
+ desktopShortcut={keys ? (enterCancels ? "Y" : "Enter") : undefined}
113
+ onPress={confirm}
114
+ />
115
+ );
116
+ const cancelButton = <Button label={cancelLabel} variant={enterCancels ? "primary" : "secondary"} disabled={busy} desktopShortcut={keys ? (enterCancels ? "Enter" : "Esc") : undefined} onPress={cancel} />;
117
+ const keyLine = enterCancels
118
+ ? tf("y {action} · Enter or Esc {cancel}", { action: t(confirmLabel).toLowerCase(), cancel: t(cancelLabel).toLowerCase() })
119
+ : tf("Enter {action} · Esc cancel", { action: t(confirmLabel).toLowerCase() });
94
120
  return (
95
121
  <DialogFrame
96
122
  title={title}
97
- footer={footer ?? tf("Enter {action} · Esc cancel", { action: t(confirmLabel).toLowerCase() })}
123
+ footer={footer ?? keyLine}
98
124
  desktopFooter={null}
99
125
  onClose={onClose}
100
126
  >
@@ -48,6 +48,8 @@ export interface TextFieldProps {
48
48
  labelWidth?: number;
49
49
  value?: string;
50
50
  placeholder?: string;
51
+ /** The most characters it takes; typing and pasting stop there. */
52
+ maxLength?: number;
51
53
  focused?: boolean;
52
54
  width?: number;
53
55
  inputRef?: RefObject<InputRenderable | null>;
@@ -119,6 +121,7 @@ function TextFieldControl({
119
121
  active,
120
122
  value,
121
123
  placeholder,
124
+ maxLength,
122
125
  focused,
123
126
  width,
124
127
  inputRef,
@@ -163,6 +166,7 @@ function TextFieldControl({
163
166
  label={label}
164
167
  value={value}
165
168
  placeholder={placeholder}
169
+ maxLength={maxLength}
166
170
  focused={focused}
167
171
  width={width}
168
172
  inputRef={inputRef}
@@ -226,6 +230,7 @@ function TextFieldControl({
226
230
  value={value}
227
231
  selectable={!isPassword}
228
232
  placeholder={isPassword ? "" : placeholder}
233
+ {...(maxLength === undefined ? {} : { maxLength })}
229
234
  focused={focused}
230
235
  textColor={isPassword ? backgroundColor : textColor}
231
236
  placeholderColor={placeholderColor}
@@ -60,7 +60,7 @@ export type { TabsProps } from "./tabs";
60
60
  export { SegmentedControl } from "./toggle";
61
61
  export type { SegmentedControlProps } from "./toggle";
62
62
  export { EmptyState, PaneStatusBody, Notice, loadingText, unavailableText } from "./status";
63
- export type { EmptyStateProps, PaneStatusBodyProps, NoticeProps } from "./status";
63
+ export type { EmptyStateProps, PaneStatusBodyProps, NoticeProps, StatusRetry } from "./status";
64
64
  export { Badge, BulletList, Divider, FigureList, KeyValueRow, Prose, READING_WIDTH, Section, SectionHeading } from "./display";
65
65
  export type {
66
66
  BadgeProps, BulletListProps, DividerProps, FigureListItem, FigureListProps, KeyValueRowProps, ProseProps, SectionProps,
@@ -1,21 +1,153 @@
1
- import type { ReactNode } from "react";
1
+ import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
2
2
  import { t, tf } from "../../i18n";
3
3
  import { useThemeColors } from "../../theme/theme-context";
4
4
  import { Box, Text, useUiCapabilities } from "../../ui";
5
+ import { formatRelativeAge } from "../../utils/datetime-format";
5
6
  import { Spinner } from "./loading";
6
7
  import { ButtonActionScope } from "./action-scope";
8
+ import { Button } from "./button";
9
+
10
+ /**
11
+ * Asking again from an empty or failed state. The state draws the button
12
+ * (first, so it answers Enter), keeps it busy while the check runs, and when
13
+ * the answer comes back the same, says when it checked: otherwise a press that
14
+ * changes nothing on screen looks like a press that did nothing.
15
+ */
16
+ export interface StatusRetry {
17
+ /** Asks again. A returned promise keeps the button busy until it settles. */
18
+ onRetry: () => unknown;
19
+ /** "Retry", unless the check has a better verb, such as "Look again". */
20
+ label?: string;
21
+ /** The label while it runs: "Retrying..." for the default label. */
22
+ pendingLabel?: string;
23
+ /**
24
+ * The same check running from anywhere (the button, `r`, a timer), such as
25
+ * a resource's `loading`. Needed when the old answer stays on screen while
26
+ * it runs, as an uncovered company does.
27
+ */
28
+ pending?: boolean;
29
+ /** Said after "Checked just now." when an empty state comes back the same, such as "Still no careers system." */
30
+ unchanged?: string;
31
+ }
7
32
 
8
33
  export interface EmptyStateProps {
9
34
  title: string;
10
35
  message?: string;
11
36
  hint?: string;
12
37
  actions?: ReactNode;
38
+ retry?: StatusRetry;
13
39
  status?: "empty" | "error";
14
40
  }
15
41
 
16
- export function EmptyState({ title, message, hint, actions, status = "empty" }: EmptyStateProps) {
42
+ type ShownStatus = "empty" | "error" | null;
43
+
44
+ interface RetryFeedback {
45
+ pending: boolean;
46
+ press: () => void;
47
+ /** When a check ended on the state it started from; null once anything else is shown. */
48
+ checkedAt: number | null;
49
+ }
50
+
51
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
52
+ return !!value && typeof (value as PromiseLike<unknown>).then === "function";
53
+ }
54
+
55
+ /**
56
+ * A check is the button's own promise, `retry.pending`, or the body showing
57
+ * its spinner (`shown === "loading"`) once a retry has been offered. When one
58
+ * ends on the empty or failed state it started from, the answer did not change.
59
+ */
60
+ function useRetryFeedback(retry: StatusRetry | undefined, shown: ShownStatus | "loading"): RetryFeedback {
61
+ const [, rerender] = useReducer((count: number) => count + 1, 0);
62
+ const [checkedAt, setCheckedAt] = useState<number | null>(null);
63
+ const inFlight = useRef(0);
64
+ const mounted = useRef(true);
65
+ const track = useRef<{ offered: boolean; checking: boolean; from: ShownStatus; last: ShownStatus }>({
66
+ offered: false, checking: false, from: null, last: null,
67
+ });
68
+ if (retry) track.current.offered = true;
69
+ const pending = inFlight.current > 0 || retry?.pending === true;
70
+ const checking = pending || shown === "loading";
71
+ const begin = () => {
72
+ const state = track.current;
73
+ if (state.checking) return;
74
+ state.checking = true;
75
+ state.from = state.last;
76
+ };
77
+
78
+ useEffect(() => {
79
+ mounted.current = true;
80
+ return () => { mounted.current = false; };
81
+ }, []);
82
+
83
+ // After every commit: a check that just ended either confirms the state on
84
+ // screen or clears the line, and anything else shown clears it. A body that
85
+ // never offered a retry skips all of it.
86
+ useEffect(() => {
87
+ const state = track.current;
88
+ if (!state.offered) return;
89
+ if (checking) {
90
+ begin();
91
+ return;
92
+ }
93
+ // `shown` is not "loading" here: that counts as checking.
94
+ if (state.checking) {
95
+ state.checking = false;
96
+ setCheckedAt(shown !== null && shown === state.from ? Date.now() : null);
97
+ } else if (shown !== state.last) {
98
+ setCheckedAt(null);
99
+ }
100
+ state.last = shown;
101
+ });
102
+
103
+ // "just now" turns into "2m ago" while the line stays up.
104
+ const shownCheckedAt = checking ? null : checkedAt;
105
+ useEffect(() => {
106
+ if (shownCheckedAt === null) return;
107
+ const timer = setInterval(rerender, 30_000);
108
+ return () => clearInterval(timer);
109
+ }, [shownCheckedAt]);
110
+
111
+ const press = () => {
112
+ if (!retry || pending) return;
113
+ const result = retry.onRetry();
114
+ if (!isThenable(result)) return;
115
+ begin();
116
+ inFlight.current += 1;
117
+ rerender();
118
+ const done = () => {
119
+ inFlight.current -= 1;
120
+ if (mounted.current) rerender();
121
+ };
122
+ result.then(done, done);
123
+ };
124
+
125
+ return { pending, press, checkedAt: shownCheckedAt };
126
+ }
127
+
128
+ function retryNote(retry: StatusRetry, status: "empty" | "error", checkedAt: number): string {
129
+ const age = formatRelativeAge(checkedAt, Math.max(checkedAt, Date.now()));
130
+ if (status === "error") return tf("Tried again {age}.", { age });
131
+ const checked = tf("Checked {age}.", { age });
132
+ return retry.unchanged ? `${checked} ${t(retry.unchanged)}` : checked;
133
+ }
134
+
135
+ export function EmptyState(props: EmptyStateProps) {
136
+ const feedback = useRetryFeedback(props.retry, props.retry ? props.status ?? "empty" : null);
137
+ return <EmptyStateBody {...props} feedback={feedback} />;
138
+ }
139
+
140
+ function EmptyStateBody({ title, message, hint, actions, retry, status = "empty", feedback }: EmptyStateProps & { feedback: RetryFeedback }) {
17
141
  const colors = useThemeColors();
18
142
  const { nativePaneChrome } = useUiCapabilities();
143
+ const retryButton = retry ? (
144
+ <Button
145
+ label={retry.label ?? "Retry"}
146
+ pendingLabel={retry.pendingLabel ?? (retry.label ? undefined : "Retrying...")}
147
+ pending={feedback.pending}
148
+ onPress={feedback.press}
149
+ />
150
+ ) : null;
19
151
  // Provider messages must wrap at narrow pane widths rather than lose their tail.
20
152
  return (
21
153
  <Box flexDirection="column" data-gloom-status={status} data-gloom-ui="empty-state">
@@ -23,11 +155,14 @@ export function EmptyState({ title, message, hint, actions, status = "empty" }:
23
155
  {message && <Box><Text fg={colors.textMuted} wrapText>{t(message)}</Text></Box>}
24
156
  {hint && <Box><Text fg={colors.textMuted} wrapText>{t(hint)}</Text></Box>}
25
157
  {/* Wraps in a narrow pane rather than clipping the second action; a terminal wraps without a blank row. */}
26
- {actions && (
158
+ {(retryButton || actions) && (
27
159
  <Box flexDirection="row" flexWrap="wrap" gap={1} rowGap={nativePaneChrome ? undefined : 0} marginTop={1}>
28
- <ButtonActionScope>{actions}</ButtonActionScope>
160
+ <ButtonActionScope>{retryButton}{actions}</ButtonActionScope>
29
161
  </Box>
30
162
  )}
163
+ {retry && feedback.checkedAt !== null && (
164
+ <Box><Text fg={colors.textMuted} wrapText>{retryNote(retry, status, feedback.checkedAt)}</Text></Box>
165
+ )}
31
166
  </Box>
32
167
  );
33
168
  }
@@ -68,6 +203,8 @@ export interface PaneStatusBodyProps {
68
203
  emptyTitle?: string;
69
204
  emptyMessage?: string;
70
205
  actions?: ReactNode;
206
+ /** Asking again from the error or empty state; see StatusRetry. */
207
+ retry?: StatusRetry;
71
208
  align?: "start" | "center";
72
209
  width?: number;
73
210
  height?: number;
@@ -85,12 +222,16 @@ export function PaneStatusBody({
85
222
  emptyTitle,
86
223
  emptyMessage,
87
224
  actions,
225
+ retry,
88
226
  align = "start",
89
227
  width,
90
228
  height,
91
229
  children,
92
230
  }: PaneStatusBodyProps) {
93
231
  const status = error ? "error" : loading ? "loading" : empty ? "empty" : null;
232
+ // Here rather than in the state, so a retry that shows the spinner while it
233
+ // runs still says it tried once the same failure is back.
234
+ const feedback = useRetryFeedback(retry, status);
94
235
  if (!status) return <>{children}</>;
95
236
  // Keyed so content that starts with a bare Box never reuses this padded node.
96
237
  return (
@@ -107,11 +248,13 @@ export function PaneStatusBody({
107
248
  data-gloom-ui="pane-status"
108
249
  >
109
250
  {status === "loading" ? <Spinner label={loadingLabel ?? loadingText(subject)} /> : (
110
- <EmptyState
251
+ <EmptyStateBody
111
252
  status={status}
112
253
  title={status === "error" ? errorTitle ?? (subject ? unavailableText(subject) : error!) : emptyTitle ?? t("Nothing to show yet.")}
113
254
  message={status === "error" ? (errorTitle || subject ? error! : undefined) : emptyMessage}
114
255
  actions={actions}
256
+ retry={retry}
257
+ feedback={feedback}
115
258
  />
116
259
  )}
117
260
  </Box>
@@ -27,6 +27,7 @@ import { isRecord } from "../../../utils/guards";
27
27
  import { debugLog } from "../../../utils/debug-log";
28
28
  import { DEFAULT_THEME, themes } from "../../../theme/themes";
29
29
  import { sanitizeStarPrompt } from "../../../app/star-prompt/model";
30
+ import { sanitizeUpdatesConfig } from "../../../updater/channel";
30
31
 
31
32
  const configLog = debugLog.createLogger("config");
32
33
  const reportedUnknownThemes = new Set<string>();
@@ -106,6 +107,7 @@ export function normalizeLoadedConfig(
106
107
  ...withKeybindings(sanitizeKeybindings(candidate.keybindings)),
107
108
  ...withTelemetry(sanitizeTelemetry(candidate.telemetry)),
108
109
  ...withStarPrompt(sanitizeStarPrompt(candidate.starPrompt)),
110
+ ...withUpdates(sanitizeUpdatesConfig(candidate.updates)),
109
111
  };
110
112
 
111
113
  const needsSave =
@@ -169,6 +171,8 @@ export function normalizeConfigForSave(config: AppConfig): AppConfig {
169
171
  Object.assign(persisted, withTelemetry(sanitizeTelemetry(config.telemetry)));
170
172
  delete persisted.starPrompt;
171
173
  Object.assign(persisted, withStarPrompt(sanitizeStarPrompt(config.starPrompt)));
174
+ delete persisted.updates;
175
+ Object.assign(persisted, withUpdates(sanitizeUpdatesConfig(config.updates)));
172
176
 
173
177
  return persisted;
174
178
  }
@@ -185,6 +189,10 @@ function withStarPrompt(starPrompt: AppConfig["starPrompt"]): Pick<AppConfig, "s
185
189
  return starPrompt ? { starPrompt } : {};
186
190
  }
187
191
 
192
+ function withUpdates(updates: AppConfig["updates"]): Pick<AppConfig, "updates"> {
193
+ return updates ? { updates } : {};
194
+ }
195
+
188
196
  /** Only the switches that are set survive; an empty object is the same as none. */
189
197
  function sanitizeTelemetry(value: unknown): TelemetryConfig | undefined {
190
198
  if (!isRecord(value)) return undefined;
package/src/i18n/es.ts CHANGED
@@ -70,6 +70,7 @@ export const es: Record<string, string> = {
70
70
  "Could not save this value.": "No se pudo guardar este valor.",
71
71
  "Could not apply that setting.": "No se pudo aplicar ese ajuste.",
72
72
  "Enter {action} · Esc cancel": "Enter {action} · Esc cancelar",
73
+ "y {action} · Enter or Esc {cancel}": "y {action} · Enter o Esc {cancel}",
73
74
  "Published {date}": "Publicado {date}",
74
75
  "Language": "Idioma",
75
76
  "Unsupported language": "Idioma no compatible",
@@ -151,6 +152,7 @@ export const es: Record<string, string> = {
151
152
  "Toggle Status Bar": "Alternar barra de estado",
152
153
  "Toggle Value Flashing": "Alternar parpadeo de valores",
153
154
  "Check for Updates": "Buscar actualizaciones",
155
+ "Release Channel": "Canal de versiones",
154
156
  "Crash Reports": "Informes de fallos",
155
157
  "Usage Counts": "Recuento de uso",
156
158
  "Change Theme": "Cambiar tema",