@gloom-sh/terminal 0.19.1 → 0.19.2

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 (138) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/errors.ts +38 -0
  3. package/src/app/runtime/startup.ts +3 -1
  4. package/src/capabilities/chart-series.ts +2 -1
  5. package/src/cli/commands/market.ts +9 -4
  6. package/src/cli/commands/overview.ts +5 -6
  7. package/src/cli/commands/search.ts +13 -13
  8. package/src/cli/context.ts +1 -0
  9. package/src/cli/glossary.ts +2 -1
  10. package/src/cli/history-rows.ts +35 -7
  11. package/src/cli/known-gaps.ts +11 -0
  12. package/src/cli/listing-arg.ts +17 -1
  13. package/src/cli/quote-answer.ts +29 -6
  14. package/src/components/command-bar/routes/root/runtime.ts +27 -5
  15. package/src/components/command-bar/selection-runtime.ts +12 -29
  16. package/src/components/layout/detached-pane-shell.tsx +2 -2
  17. package/src/components/layout/header.tsx +2 -2
  18. package/src/components/layout/pane/footer/index.tsx +1 -0
  19. package/src/components/layout/pane/footer/model.ts +33 -1
  20. package/src/components/layout/pane/header.tsx +2 -2
  21. package/src/components/layout/pane/status-footer.ts +2 -1
  22. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  23. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  24. package/src/components/ui/display.tsx +16 -5
  25. package/src/components/ui/icon.tsx +8 -2
  26. package/src/core/app-services.ts +11 -2
  27. package/src/data/app-persistence.ts +9 -7
  28. package/src/data/plugin-cache.ts +15 -0
  29. package/src/data/plugin-state-store.ts +81 -24
  30. package/src/data/resource-store.ts +130 -58
  31. package/src/data/session-store.ts +31 -8
  32. package/src/data/sqlite/access.ts +313 -0
  33. package/src/data/sqlite/checkpointer.ts +198 -0
  34. package/src/data/sqlite/database.ts +47 -4
  35. package/src/data/sqlite/retry.ts +50 -9
  36. package/src/data/ticker-store.ts +57 -23
  37. package/src/market-data/realized-volatility.ts +4 -2
  38. package/src/plugins/auto-update-node.ts +10 -6
  39. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  40. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  41. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  42. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  43. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  44. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  45. package/src/plugins/builtin/cot/client.ts +28 -2
  46. package/src/plugins/builtin/cot/headless.ts +4 -1
  47. package/src/plugins/builtin/cot/index.tsx +4 -2
  48. package/src/plugins/builtin/cot/model.ts +58 -7
  49. package/src/plugins/builtin/cot/pane.tsx +6 -2
  50. package/src/plugins/builtin/cpi/client.ts +6 -5
  51. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  52. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  53. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  54. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  55. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  56. package/src/plugins/builtin/doe/client.ts +6 -5
  57. package/src/plugins/builtin/doe/prices.ts +2 -3
  58. package/src/plugins/builtin/doe/series.ts +4 -4
  59. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  60. package/src/plugins/builtin/earnings-calls/data.ts +19 -11
  61. package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
  62. package/src/plugins/builtin/econ/detail-view.tsx +16 -2
  63. package/src/plugins/builtin/executives/data.ts +6 -1
  64. package/src/plugins/builtin/executives/pane.tsx +9 -21
  65. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  66. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  67. package/src/plugins/builtin/gpu/client.ts +4 -1
  68. package/src/plugins/builtin/gpu/series.ts +2 -3
  69. package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
  70. package/src/plugins/builtin/jodi/client.ts +4 -1
  71. package/src/plugins/builtin/market-movers/client.ts +1 -0
  72. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  73. package/src/plugins/builtin/market-movers/screener.ts +23 -6
  74. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  75. package/src/plugins/builtin/money-markets/series.ts +3 -5
  76. package/src/plugins/builtin/power/gas-client.ts +2 -1
  77. package/src/plugins/builtin/power/prices-client.ts +3 -2
  78. package/src/plugins/builtin/risk-factors/data.ts +8 -4
  79. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  80. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  81. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  82. package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
  83. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  84. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  85. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  86. package/src/plugins/builtin/short-volume/series.ts +2 -3
  87. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  88. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  89. package/src/plugins/builtin/thirteenf/api.ts +11 -16
  90. package/src/plugins/builtin/thirteenf/data.ts +34 -6
  91. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  92. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  93. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  94. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  95. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  96. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  97. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  98. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  99. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  100. package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
  101. package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
  102. package/src/plugins/builtin/volatility/client.ts +43 -16
  103. package/src/plugins/builtin/volatility/headless.ts +5 -2
  104. package/src/plugins/builtin/volatility/model.ts +17 -6
  105. package/src/plugins/builtin/volatility/pane.tsx +6 -2
  106. package/src/plugins/builtin/volatility/series.ts +2 -1
  107. package/src/plugins/builtin/yield-curve/client.ts +4 -6
  108. package/src/plugins/installer.ts +50 -39
  109. package/src/renderers/dom/desktop/icons.tsx +3 -2
  110. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  111. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  112. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  113. package/src/renderers/electrobun/bun/window/create.ts +2 -0
  114. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  115. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  116. package/src/renderers/electrobun/view/ui-host.tsx +9 -2
  117. package/src/series/resolve.ts +2 -1
  118. package/src/series/types.ts +5 -0
  119. package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
  120. package/src/telemetry/stall-reports-node.ts +13 -2
  121. package/src/telemetry/stall-reports.ts +189 -13
  122. package/src/test-support/outage.ts +8 -0
  123. package/src/tickers/company-name-listing.ts +59 -29
  124. package/src/tickers/company-name.ts +65 -1
  125. package/src/tickers/search/home-listing.ts +93 -8
  126. package/src/tickers/search/index.ts +1 -1
  127. package/src/time-series/market.ts +5 -3
  128. package/src/time-series/price-comparison.ts +20 -2
  129. package/src/time-series/resolve.ts +20 -1
  130. package/src/time-series/types.ts +7 -0
  131. package/src/types/financials.ts +18 -3
  132. package/src/ui/host.tsx +6 -1
  133. package/src/utils/financial-statements.ts +73 -16
  134. package/src/utils/fundamentals.ts +17 -5
  135. package/src/utils/lease-liabilities.ts +54 -0
  136. package/src/utils/main-thread-stall.ts +47 -2
  137. package/src/utils/perf-marks.ts +23 -11
  138. package/src/utils/price-history-integrity.ts +47 -1
@@ -30,6 +30,11 @@ export interface VolatilityHistoryInput {
30
30
  stale?: boolean;
31
31
  error?: string | null;
32
32
  fetchedAt?: number | null;
33
+ /**
34
+ * When the copy shown was saved, when a failed refresh left it answering:
35
+ * the pane's footer says "saved copy from …" instead of the failure.
36
+ */
37
+ savedAt?: number | null;
33
38
  }
34
39
  export interface VolatilitySeriesInput {
35
40
  observations: CloudFredObservationPayload[];
@@ -37,6 +42,8 @@ export interface VolatilitySeriesInput {
37
42
  stale?: boolean;
38
43
  error?: string | null;
39
44
  fetchedAt?: number | null;
45
+ /** As `VolatilityHistoryInput.savedAt`. */
46
+ savedAt?: number | null;
40
47
  }
41
48
  export interface VolatilityInputs {
42
49
  history?: Partial<Record<VolatilityIndexId, VolatilityHistoryInput>>;
@@ -131,6 +138,8 @@ export interface VolatilityBoardRow {
131
138
  status: "available" | "limited" | "unavailable";
132
139
  stale: boolean;
133
140
  error: string | null;
141
+ /** When the copy shown was saved, when a failed refresh left it answering. */
142
+ savedAt?: number;
134
143
  warnings: string[];
135
144
  }
136
145
  export interface VolatilityData {
@@ -233,8 +242,9 @@ function fredHistory(inputs: VolatilityInputs["fred"]): FredVolatilityHistory {
233
242
  const input = inputs?.[definition.seriesId];
234
243
  const normalized = normalizeHistory(input?.observations ?? [], true);
235
244
  warnings.push(...normalized.warnings.map((warning) => `${definition.seriesId}: ${warning}`));
236
- if (input?.error) warnings.push(`${definition.seriesId}: ${input.error}`);
237
- if (input?.stale) warnings.push(`${definition.seriesId}: cached source is stale`);
245
+ // A saved copy is dated in the footer; its failed refresh is not repeated here.
246
+ if (input?.error && input.savedAt == null) warnings.push(`${definition.seriesId}: ${input.error}`);
247
+ if (input?.stale && input.savedAt == null) warnings.push(`${definition.seriesId}: cached source is stale`);
238
248
  const latest = normalized.history.at(-1);
239
249
  return { ...definition, title: input?.info?.title ?? definition.label, value: latest?.value ?? null,
240
250
  date: latest?.date ?? null, history: normalized.history,
@@ -300,15 +310,16 @@ function boardRow(definition: typeof VOLATILITY_INDICES[number], input: Volatili
300
310
  if (latest && latest.date !== normalized.rows.at(-1)?.date) warnings.push("Latest supplied close unavailable; showing last valid observation");
301
311
  if (latest && !adjacent) warnings.push("1D change unavailable: previous daily close missing or too far apart");
302
312
  if (latest && !broadCoverage) warnings.push(`1Y percentile unavailable: ${history.length} observations across ${coverageDays} days`);
303
- if (input?.stale) warnings.push("Cached daily history is stale");
304
- if (input?.error) warnings.push(input.error);
313
+ // A saved copy is dated in the footer; its failed refresh is not repeated here.
314
+ if (input?.stale && input.savedAt == null) warnings.push("Cached daily history is stale");
315
+ if (input?.error && input.savedAt == null) warnings.push(input.error);
305
316
  return { ...definition, unit: "index points", value: latest?.value ?? null, date: latest?.date ?? null,
306
317
  source: input?.source ?? null, previousDate: previous?.date ?? null, change1d,
307
318
  change1dPercent: change1d != null ? change1d / previous!.value! * 100 : null,
308
319
  percentile1y, sampleSize: history.length, coverageDays, history,
309
320
  missingDates: normalized.rows.filter((row) => row.value == null && Date.parse(row.date) > cutoff).map((row) => row.date),
310
321
  status: !latest ? "unavailable" : warnings.length > 0 ? "limited" : "available",
311
- stale: input?.stale ?? false, error: input?.error ?? null, warnings };
322
+ stale: input?.stale ?? false, error: input?.error ?? null, ...(input?.savedAt != null ? { savedAt: input.savedAt } : {}), warnings };
312
323
  }
313
324
  function alignedCurve(board: readonly VolatilityBoardRow[], fred: FredVolatilityHistory, term: VolatilityTermHistory): VolatilityCurve {
314
325
  const rows = VOLATILITY_CURVE_INDICES.map((definition) => board.find((row) => row.id === definition.id)!);
@@ -337,7 +348,7 @@ function alignedCurve(board: readonly VolatilityBoardRow[], fred: FredVolatility
337
348
  const spot = points[1]!.value, threeMonth = points[2]!.value;
338
349
  const warnings = points.flatMap((point, index) => [
339
350
  ...(point.value == null ? [`${point.label}: no close aligned to ${date ?? "a common date"}`] : []),
340
- ...(point.value != null && rows[index]!.stale ? [`${point.label}: aligned close is from stale cached history`] : []),
351
+ ...(point.value != null && rows[index]!.stale && rows[index]!.savedAt == null ? [`${point.label}: aligned close is from stale cached history`] : []),
341
352
  ]);
342
353
  const ratio = spot != null && threeMonth != null ? threeMonth / spot : null;
343
354
  const context = ratioPercentile(term, ratio, date);
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { ChartTableHeader, CurveSurface, curveGhostColors, DataTableView, formatPercentAxis, PaneStatusBody, useChartTableSelection,
3
3
  usePaneFooter, usePaneNoticeFooter, usePaneTabs, type DataTableColumn, type StatItem } from "../../../components";
4
4
  import { curveStrip, curveSurfaceMinRows } from "../../../components/chart/curve";
5
+ import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
6
+ import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
5
7
  import { dropColumnsToFit } from "../../../components/ui/table-layout";
6
8
  import { useAsyncResource } from "../../../react/async-resource";
7
9
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
@@ -170,13 +172,15 @@ export function VolatilityPane({ focused, width, height }: PaneProps) {
170
172
  const observationBasis = intraday ? "intraday" : sparse ? "observation" : settled ? "close" : DAY_SO_FAR_TEXT;
171
173
  usePaneFooter("volatility", () => ({ info: [
172
174
  ...(resource.loading ? [{ id: "loading", parts: [{ text: "loading volatility", tone: "muted" as const }] }] : []),
173
- ...(result?.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "warning" as const }] }] : []),
175
+ // A saved copy answering a failed refresh says when it was saved; other stale closes say only that.
176
+ ...savedCopyFooterInfo([result?.savedAt]),
177
+ ...(result?.stale && result.savedAt == null ? [STALE_SEGMENT] : []),
174
178
  // One fact, "close 2026-09-25": the basis and its date read as one segment.
175
179
  ...(data || asOf ? [{ id: "basis", parts: [
176
180
  ...(data ? [{ text: observationBasis, tone: "muted" as const }] : []),
177
181
  ...(asOf ? [{ text: observationTime ? `${observationTime.slice(0, 16).replace("T", " ")} UTC` : asOf, tone: "muted" as const }] : []),
178
182
  ] }] : []),
179
- ], hints: [{ id: "view", key: "v", label: "iew", onPress: cycleTab }] }), [resource.loading, result?.stale, data, asOf, observationTime, observationBasis, cycleTab]);
183
+ ], hints: [{ id: "view", key: "v", label: "iew", onPress: cycleTab }] }), [resource.loading, result?.stale, result?.savedAt, data, asOf, observationTime, observationBasis, cycleTab]);
180
184
  const { strip: tabStrip, rows: tabRows } = usePaneTabs({ tabs: TABS, activeValue: tab, onSelect: setTab, focused, dense: true });
181
185
  const contentHeight = Math.max(5, height - tabRows);
182
186
  // The board needs only its rows; the selected index history takes the rest.
@@ -105,11 +105,12 @@ export const VOLATILITY_SERIES: readonly SeriesDefinition[] = [
105
105
  tags: ["term structure", "contango", "backwardation", "VIX"],
106
106
  description: "VIX 3M over VIX on each date both closed, from Cboe's daily closes and FRED's before them; above 1 is contango.",
107
107
  load: async ({ start, signal }) => {
108
- const { term, warning } = await loadVolatilityTermHistory({ ...(start ? { start } : {}), ...(signal ? { signal } : {}) });
108
+ const { term, warning, savedAt } = await loadVolatilityTermHistory({ ...(start ? { start } : {}), ...(signal ? { signal } : {}) });
109
109
  const attribution = termAttribution(term);
110
110
  return {
111
111
  points: term.ratioHistory.map((point) => ({ date: point.date, value: point.value })),
112
112
  ...(warning ? { warning } : {}),
113
+ ...(savedAt != null ? { savedAt } : {}),
113
114
  ...(attribution ? { attribution } : {}),
114
115
  };
115
116
  },
@@ -1,7 +1,6 @@
1
1
  import { apiClient } from "../../../api-client";
2
2
  import type { SwapCurvePayload, SwapTenor } from "../../../api-client/swap-curve";
3
- import { isPermanentClientError } from "../../../api-client/errors";
4
- import { createPluginCache, type PluginCacheResult } from "../../../data/plugin-cache";
3
+ import { createPluginCache, savedCopyForTransientFailure, type PluginCacheResult } from "../../../data/plugin-cache";
5
4
  import { cachedCloudResource, loadCloudResource, unavailableOnServer } from "../shared/cloud-resource";
6
5
  import { validateSwapCurve } from "./swaps-model";
7
6
  import { loadCurveData, type CurveData, type CurveId } from "./curves";
@@ -52,12 +51,11 @@ export interface SavedCopyAnswer<T> {
52
51
  const curveKey = (curve: CurveId, requestedDate: string) => `${curve}:${requestedDate || "latest"}`;
53
52
  /**
54
53
  * A copy is the saved one by where it came from, whatever its failure said. A
55
- * refusal asking again will not change (access denied, an unknown curve) is
56
- * said rather than covered by the copy, as `loadCloudResource` does.
54
+ * refusal (access denied, an unknown curve) or a reply that fails validation
55
+ * is said rather than covered by the copy, as `loadCloudResource` does.
57
56
  */
58
57
  function answer<T>(result: PluginCacheResult<T>): SavedCopyAnswer<T> {
59
- const saved = result.source === "stale-fallback";
60
- if (saved && isPermanentClientError(result.error)) throw result.error;
58
+ const saved = savedCopyForTransientFailure(result).source === "stale-fallback";
61
59
  return { data: result.data, savedAt: saved ? result.fetchedAt : null };
62
60
  }
63
61
 
@@ -141,7 +141,13 @@ function run(
141
141
  /** Whether a `git` command is on the PATH; plugins from GitHub need one to install or update. */
142
142
  export function gitInstalled(): boolean {
143
143
  if (typeof Bun !== "undefined") return Bun.which("git") !== null;
144
- return gitSucceeds(["--version"], process.cwd());
144
+ try {
145
+ execFileSync("git", ["--version"], { stdio: "ignore" });
146
+ return true;
147
+ } catch {
148
+ // Intentional ignore: a git that does not start is the answer this asks for.
149
+ return false;
150
+ }
145
151
  }
146
152
 
147
153
  /** What a plugin install says when there is no git to run: what to install, then to try again. */
@@ -171,14 +177,9 @@ async function git(args: string[], cwd: string, quiet: boolean, failure: string)
171
177
  return result.stdout.trim();
172
178
  }
173
179
 
174
- function isOnBranch(dir: string): boolean {
175
- try {
176
- execFileSync("git", ["symbolic-ref", "-q", "HEAD"], { cwd: dir, stdio: "ignore" });
177
- return true;
178
- } catch {
179
- // Intentional ignore: Detached HEAD is expected after a pinned install.
180
- return false;
181
- }
180
+ /** False on a detached HEAD, which a pinned install leaves. */
181
+ function isOnBranch(dir: string): Promise<boolean> {
182
+ return gitSucceedsAsync(["symbolic-ref", "-q", "HEAD"], dir);
182
183
  }
183
184
 
184
185
  function pinMatches(commit: string | null, expected: string | undefined): boolean {
@@ -208,27 +209,27 @@ async function checkoutCommit(targetDir: string, commit: string, quiet: boolean)
208
209
  await git(["checkout", "--detach", "--force", commit], targetDir, quiet, `Could not check out ${commit.slice(0, 7)}`);
209
210
  }
210
211
 
211
- function gitSucceeds(args: string[], cwd: string): boolean {
212
- try {
213
- execFileSync("git", args, { cwd, stdio: "ignore" });
214
- return true;
215
- } catch {
216
- // Intentional ignore: Non-ancestor and shallow histories use package versions and commit dates instead.
217
- return false;
218
- }
212
+
213
+ /*
214
+ * The update path runs in the app (the terminal, the desktop's Bun process),
215
+ * where a sync git call holds the thread for the whole process start: tens of
216
+ * milliseconds each on Windows, several per plugin. These never block it.
217
+ */
218
+ async function gitSucceedsAsync(args: string[], cwd: string): Promise<boolean> {
219
+ const result = await run("git", args, cwd, { stdout: "pipe", stderr: "pipe" })
220
+ .catch(logFailure("plugin-installer", "Could not run git", null));
221
+ return result?.code === 0;
219
222
  }
220
223
 
221
- function gitOutput(args: string[], cwd: string): string | null {
222
- try {
223
- return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString("utf-8").trim();
224
- } catch {
225
- // Intentional ignore: Shallow or manifest-free checkouts may lack historical metadata.
226
- return null;
227
- }
224
+ /** Null when git fails: shallow or manifest-free checkouts may lack historical metadata. */
225
+ async function gitOutputAsync(args: string[], cwd: string): Promise<string | null> {
226
+ const result = await run("git", args, cwd, { stdout: "pipe", stderr: "pipe" })
227
+ .catch(logFailure("plugin-installer", "Could not run git", null));
228
+ return result?.code === 0 ? result.stdout.trim() : null;
228
229
  }
229
230
 
230
- function versionAt(dir: string, commit: string): string | null {
231
- const text = gitOutput(["show", `${commit}:package.json`], dir);
231
+ async function versionAt(dir: string, commit: string): Promise<string | null> {
232
+ const text = await gitOutputAsync(["show", `${commit}:package.json`], dir);
232
233
  if (!text) return null;
233
234
  try {
234
235
  const version = JSON.parse(text).version;
@@ -247,15 +248,15 @@ function versionAt(dir: string, commit: string): string | null {
247
248
  * it; otherwise the versions the two commits declare decide, then, when
248
249
  * those match, their commit dates.
249
250
  */
250
- export function describeOlderCommit(dir: string, installed: string, candidate: string, label = candidate.slice(0, 7)): string | null {
251
- if (installed === candidate || gitSucceeds(["merge-base", "--is-ancestor", installed, candidate], dir)) return null;
252
- const installedVersion = versionAt(dir, installed);
253
- const byVersion = compareSemver(versionAt(dir, candidate), installedVersion);
251
+ export async function describeOlderCommit(dir: string, installed: string, candidate: string, label = candidate.slice(0, 7)): Promise<string | null> {
252
+ if (installed === candidate || await gitSucceedsAsync(["merge-base", "--is-ancestor", installed, candidate], dir)) return null;
253
+ const installedVersion = await versionAt(dir, installed);
254
+ const byVersion = compareSemver(await versionAt(dir, candidate), installedVersion);
254
255
  const older = () => `the registry's ${label} is older than the installed ${byVersion ? installedVersion : installed.slice(0, 7)}`;
255
- if (gitSucceeds(["merge-base", "--is-ancestor", candidate, installed], dir)) return older();
256
+ if (await gitSucceedsAsync(["merge-base", "--is-ancestor", candidate, installed], dir)) return older();
256
257
  if (byVersion !== null && byVersion !== 0) return byVersion < 0 ? older() : null;
257
- const candidateTime = Number(gitOutput(["show", "-s", "--format=%ct", candidate], dir));
258
- const installedTime = Number(gitOutput(["show", "-s", "--format=%ct", installed], dir));
258
+ const candidateTime = Number(await gitOutputAsync(["show", "-s", "--format=%ct", candidate], dir));
259
+ const installedTime = Number(await gitOutputAsync(["show", "-s", "--format=%ct", installed], dir));
259
260
  return candidateTime > 0 && installedTime > 0 && candidateTime < installedTime ? older() : null;
260
261
  }
261
262
 
@@ -403,19 +404,29 @@ export interface PluginUpdateResult extends PluginDirectoryInfo {
403
404
  kept?: string;
404
405
  }
405
406
 
407
+ function githubRepoFromUrl(url: string): string | null {
408
+ const match = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(url.trim());
409
+ return match ? `${match[1]}/${match[2]}` : null;
410
+ }
411
+
406
412
  /** `owner/repo` from the checkout's origin remote, or null for anything that is not a GitHub clone. */
407
413
  export function readPluginRemote(pluginDir: string): string | null {
408
414
  try {
409
- const url = execFileSync("git", ["config", "--get", "remote.origin.url"], { cwd: pluginDir, stdio: ["ignore", "pipe", "ignore"] })
410
- .toString("utf-8").trim();
411
- const match = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(url);
412
- return match ? `${match[1]}/${match[2]}` : null;
415
+ return githubRepoFromUrl(execFileSync("git", ["config", "--get", "remote.origin.url"], { cwd: pluginDir, stdio: ["ignore", "pipe", "ignore"] })
416
+ .toString("utf-8"));
413
417
  } catch {
414
418
  // Intentional ignore: Hand-copied plugins and local repositories may have no origin remote.
415
419
  return null;
416
420
  }
417
421
  }
418
422
 
423
+ /** `readPluginRemote` without holding the thread, for the app's own update check. */
424
+ export async function readPluginRemoteAsync(pluginDir: string): Promise<string | null> {
425
+ // No origin remote (a hand-copied plugin, a local repository) is a failed git, and no repo.
426
+ const url = await gitOutputAsync(["config", "--get", "remote.origin.url"], pluginDir);
427
+ return url ? githubRepoFromUrl(url) : null;
428
+ }
429
+
419
430
  /** The sha from `git ls-remote origin HEAD`; null when the answer is not one. */
420
431
  export function parseRemoteHead(output: string): string | null {
421
432
  const sha = output.trim().split(/\s+/)[0] ?? "";
@@ -500,7 +511,7 @@ export async function updatePlugin(name: string, options: PluginInstallOptions =
500
511
  const commit = await fetchPin(targetDir, pin, quiet);
501
512
  // Updates only move forward: a checkout installed from the default
502
513
  // branch can be ahead of the tag the registry reviewed.
503
- const kept = before ? describeOlderCommit(targetDir, before, commit, pin.ref ?? commit.slice(0, 7)) : null;
514
+ const kept = before ? await describeOlderCommit(targetDir, before, commit, pin.ref ?? commit.slice(0, 7)) : null;
504
515
  if (kept) {
505
516
  if (!quiet) console.log(cliStyles.muted(`Kept ${name}: ${kept}.`));
506
517
  return { ...describe(targetDir, name, null), before, changed: false, kept };
@@ -510,7 +521,7 @@ export async function updatePlugin(name: string, options: PluginInstallOptions =
510
521
  // Not in the registry, so there is nothing reviewed to land on: follow the
511
522
  // remote's default branch, whether the checkout is on a branch or detached
512
523
  // from an earlier pin.
513
- if (isOnBranch(targetDir)) {
524
+ if (await isOnBranch(targetDir)) {
514
525
  await git(["pull", "--ff-only"], targetDir, quiet, `Failed to update ${name}`);
515
526
  } else {
516
527
  await git(["fetch", "--depth", "1", "origin", "HEAD"], targetDir, quiet, `Failed to fetch ${name}`);
@@ -180,7 +180,7 @@ const ICONS: Record<IconName, ReactNode> = {
180
180
  ),
181
181
  };
182
182
 
183
- export function WebIcon({ name, size = 12, color }: IconProps) {
183
+ export function WebIcon({ name, size = 12, color, fullHeight }: IconProps) {
184
184
  return (
185
185
  <svg
186
186
  viewBox="0 0 12 12"
@@ -189,7 +189,8 @@ export function WebIcon({ name, size = 12, color }: IconProps) {
189
189
  fill="none"
190
190
  aria-hidden="true"
191
191
  data-gloom-icon={name}
192
- style={{ flex: "none", display: "block", color }}
192
+ // The viewBox keeps its size and sits in the middle of the taller box (preserveAspectRatio's default).
193
+ style={{ flex: "none", display: "block", color, ...(fullHeight ? { height: "100%" } : {}) }}
193
194
  >
194
195
  {ICONS[name]}
195
196
  </svg>
@@ -18,6 +18,8 @@ import type { DesktopBackend } from "./backend";
18
18
 
19
19
  const INITIAL_DOCK_SUPPRESSION_MS = 800;
20
20
  const WINDOW_CONTROL_DOCK_SUPPRESSION_MS = 5_000;
21
+ /** How long after the main window has its data the popped-out windows of the last session open. */
22
+ const LAUNCH_WINDOWS_DELAY_MS = 300;
21
23
 
22
24
  export class DesktopDetachedWindowManager {
23
25
  private readonly windows = new Map<string, BrowserWindow>();
@@ -27,9 +29,29 @@ export class DesktopDetachedWindowManager {
27
29
  private readonly pendingMoveFlush = new Set<string>();
28
30
  private readonly suppressDockUntil = new Map<string, number>();
29
31
  private readonly windowControlDockSuppressed = new Set<string>();
32
+ /**
33
+ * At launch, popped-out windows wait for the main window. Opening a window
34
+ * holds this process until the system has made it (on Windows, its
35
+ * browser too), and the main window's `init` used to wait behind each one.
36
+ */
37
+ private windowsAllowed = false;
38
+ private windowsTimer: ReturnType<typeof setTimeout> | null = null;
30
39
 
31
40
  constructor(private readonly backend: DesktopBackend) {}
32
41
 
42
+ /**
43
+ * The main window has its data: the popped-out windows open shortly
44
+ * after, once it has had a moment to draw and ask for what it shows.
45
+ */
46
+ openAfterMainWindow(): void {
47
+ if (this.windowsAllowed || this.windowsTimer) return;
48
+ this.windowsTimer = setTimeout(() => {
49
+ this.windowsTimer = null;
50
+ this.windowsAllowed = true;
51
+ this.reconcile();
52
+ }, LAUNCH_WINDOWS_DELAY_MS);
53
+ }
54
+
33
55
  focusDetachedPane(instanceId: string): boolean {
34
56
  return this.focusWindowForRpcKey(detachedRpcKey(instanceId));
35
57
  }
@@ -88,7 +110,7 @@ export class DesktopDetachedWindowManager {
88
110
  for (const [instanceId, entry] of desiredEntries) {
89
111
  const existingWindow = this.windows.get(instanceId);
90
112
  if (!existingWindow) {
91
- this.createWindow(instanceId, entry);
113
+ if (this.windowsAllowed) this.createWindow(instanceId, entry);
92
114
  continue;
93
115
  }
94
116
 
@@ -25,6 +25,7 @@ import { telemetryOptedOut } from "../../../../telemetry/crash-reports";
25
25
  import { describeNodeOs, readOrCreateInstallId } from "../../../../telemetry/crash-reports-node";
26
26
  import type { DesktopBackend, DesktopRpc } from "./backend";
27
27
  import { readDesktopViewIds } from "./view-ids";
28
+ import { markStartupComplete } from "../../../../utils/main-thread-stall";
28
29
 
29
30
  interface DesktopWindowTarget {
30
31
  kind: "main" | "detached";
@@ -194,5 +195,12 @@ export async function initializeDesktopBackend(
194
195
  backend.workspace = createDesktopWorkspace(config, backend.getSessionSnapshot());
195
196
  backend.detachedWindows.reconcile();
196
197
  }
197
- return buildInitializationPayload(backend, config, services, windowTarget);
198
+ const init = buildInitializationPayload(backend, config, services, windowTarget);
199
+ // The main window has what it needs to draw: this process is up, and the
200
+ // popped-out windows open next.
201
+ if (windowTarget.kind === "main") {
202
+ markStartupComplete();
203
+ backend.detachedWindows.openAfterMainWindow();
204
+ }
205
+ return init;
198
206
  }
@@ -0,0 +1,54 @@
1
+ import { setStallAttentionProbe, type StallAttention } from "../../../../telemetry/stall-reports";
2
+
3
+ /** What this needs of a window: its id, its minimized state and its events. */
4
+ interface AttentionWindow {
5
+ id: number;
6
+ isMinimized(): boolean;
7
+ on?(name: string, handler: (event: unknown) => void): void;
8
+ }
9
+
10
+ const openWindows = new Map<number, AttentionWindow>();
11
+ let focusedWindowId: number | null = null;
12
+ let sawFocusChange = false;
13
+
14
+ /**
15
+ * Whether anyone was looking when the desktop app's Bun process froze. The
16
+ * system slows a hidden app's timers, which reads like a freeze, so a stall
17
+ * report from this process says whether one of the app's windows had the
18
+ * keyboard (the app was in front) and whether one was open and not
19
+ * minimized. Read only for a stall about to be reported.
20
+ */
21
+ function desktopAttention(): StallAttention {
22
+ let visible: boolean | null = false;
23
+ try {
24
+ for (const window of openWindows.values()) {
25
+ if (!window.isMinimized()) {
26
+ visible = true;
27
+ break;
28
+ }
29
+ }
30
+ } catch {
31
+ visible = null;
32
+ }
33
+ return { active: sawFocusChange ? focusedWindowId !== null : null, visible };
34
+ }
35
+
36
+ /** Follows a window the app opened until it closes; the first one installs the probe. */
37
+ export function trackWindowAttention(window: AttentionWindow): void {
38
+ if (openWindows.size === 0) setStallAttentionProbe(desktopAttention);
39
+ openWindows.set(window.id, window);
40
+ window.on?.("focus", () => {
41
+ focusedWindowId = window.id;
42
+ sawFocusChange = true;
43
+ });
44
+ window.on?.("blur", () => {
45
+ // Focus moving between two of the app's windows can report the new
46
+ // window's focus before the old one's blur.
47
+ if (focusedWindowId === window.id) focusedWindowId = null;
48
+ sawFocusChange = true;
49
+ });
50
+ window.on?.("close", () => {
51
+ openWindows.delete(window.id);
52
+ if (focusedWindowId === window.id) focusedWindowId = null;
53
+ });
54
+ }
@@ -1,4 +1,5 @@
1
1
  import { BrowserWindow } from "electrobun/bun";
2
+ import { trackWindowAttention } from "./attention";
2
3
  import {
3
4
  applyNativeWindowFrame,
4
5
  applyWindowMoveEvent,
@@ -105,6 +106,7 @@ export function createAppWindow({ title, frame, rpc, minSize, onFrameChange, pre
105
106
  });
106
107
  }
107
108
  updateWindowFrameCache(window, frame, minSize);
109
+ trackWindowAttention(window);
108
110
  const readsNativeFrame = process.platform === "win32";
109
111
  (window as any).on?.("move", (event: WindowMoveEvent) => {
110
112
  if (readsNativeFrame) {
@@ -0,0 +1,13 @@
1
+ // Runs first in Electrobun's launcher script (Resources/main.js) on Windows,
2
+ // before it starts the app's worker and the browser engine. The Windows build
3
+ // step puts it there (scripts/install-electrobun-tui-shim.ts).
4
+ import { leaveEmulationFaultsToWindows } from "./windows-emulation";
5
+
6
+ if (process.platform === "win32") {
7
+ try {
8
+ const result = leaveEmulationFaultsToWindows();
9
+ if (result !== "not emulated") console.log(`[launcher] x64 emulation: ${result}`);
10
+ } catch (error) {
11
+ console.error("[launcher] could not check for x64 emulation", error);
12
+ }
13
+ }
@@ -0,0 +1,50 @@
1
+ import { dlopen, ptr, read, type Pointer } from "bun:ffi";
2
+
3
+ // Windows on Arm runs the x64 app under emulation. When a thread exits there,
4
+ // Windows' emulation layer can fault on memory it no longer reaches
5
+ // (ntdll!RtlEcContextToNativeContext) and recovers from that fault itself.
6
+ // Bun 1.3's crash handler is a vectored exception handler, so it sees the
7
+ // fault before Windows does and takes it for a crash. On the exiting thread,
8
+ // which has no thread-local storage left, the handler then faults inside
9
+ // itself until the thread's stack is gone, and the app closes some seconds
10
+ // after it opens. Bun 1.4 only claims faults inside its own code. Until the
11
+ // app runs on it, remove Bun's handler under emulation and leave faults to
12
+ // Windows, which still reports a real crash.
13
+
14
+ // The Bun build Electrobun ships, by link timestamp, and where that build
15
+ // keeps the handle of its vectored exception handler (from its symbols).
16
+ const BUN_HANDLER_HANDLES = new Map<number, number>([
17
+ [0x69e4847f, 0x68e3d40], // Bun 1.3.13, bun-windows-x64-baseline
18
+ ]);
19
+
20
+ const IMAGE_FILE_MACHINE_ARM64 = 0xaa64;
21
+ const CURRENT_PROCESS = -1;
22
+
23
+ export type EmulationFaultsResult = "not emulated" | "other bun build" | "handler already gone" | "handler removed" | "handler not found";
24
+
25
+ export function leaveEmulationFaultsToWindows(): EmulationFaultsResult {
26
+ const kernel32 = dlopen("kernel32.dll", {
27
+ GetModuleHandleW: { args: ["ptr"], returns: "ptr" },
28
+ IsWow64Process2: { args: ["i64", "ptr", "ptr"], returns: "i32" },
29
+ RemoveVectoredExceptionHandler: { args: ["ptr"], returns: "u32" },
30
+ });
31
+ try {
32
+ const processMachine = new Uint16Array(1);
33
+ const nativeMachine = new Uint16Array(1);
34
+ if (!kernel32.symbols.IsWow64Process2(CURRENT_PROCESS, ptr(processMachine), ptr(nativeMachine))) return "not emulated";
35
+ if (nativeMachine[0] !== IMAGE_FILE_MACHINE_ARM64) return "not emulated";
36
+
37
+ const image = kernel32.symbols.GetModuleHandleW(null);
38
+ if (!image) return "other bun build";
39
+ const timestamp = read.u32(image, read.u32(image, 0x3c) + 8);
40
+ const handleOffset = BUN_HANDLER_HANDLES.get(timestamp);
41
+ if (handleOffset === undefined) return "other bun build";
42
+
43
+ const handle = read.ptr(image, handleOffset) as Pointer;
44
+ if (!handle) return "handler already gone";
45
+ // Windows only removes a handle it has registered, so a wrong value does nothing.
46
+ return kernel32.symbols.RemoveVectoredExceptionHandler(handle) ? "handler removed" : "handler not found";
47
+ } finally {
48
+ kernel32.close();
49
+ }
50
+ }
@@ -25,9 +25,16 @@ export const webRendererHost: RendererHost = {
25
25
  void backendRequest("host.exit").catch(() => window.close());
26
26
  });
27
27
  },
28
- startWindowDrag() {
29
- // Windows moves the window itself, as it does from a native title bar.
28
+ startWindowDrag(press) {
29
+ // Windows moves the window itself, as it does from a native title bar, and
30
+ // a double-click there maximizes or restores it.
30
31
  if (detectPlatform() === "win32") {
32
+ if (press?.detail === 2) {
33
+ void backendRequest("host.windowControl", { action: "toggle-maximize" }).catch((error: unknown) => {
34
+ console.error("[desktop] could not maximize the window", error instanceof Error ? error.message : String(error));
35
+ });
36
+ return;
37
+ }
31
38
  void backendRequest("host.startWindowDrag").catch((error: unknown) => {
32
39
  console.error("[desktop] could not start moving the window", error instanceof Error ? error.message : String(error));
33
40
  });
@@ -195,7 +195,7 @@ export async function resolveLoadedSeries(
195
195
  ): Promise<ResolvedSeries> {
196
196
  const ticker = request.ticker?.trim().toUpperCase() || undefined;
197
197
  const line = definition.lines?.length ? request.line ?? definition.lines[0] : undefined;
198
- const { points, edge, warning, attribution } = loadResult(await loadSeries(definition, {
198
+ const { points, edge, warning, savedAt, attribution } = loadResult(await loadSeries(definition, {
199
199
  ...seriesLoadWindow(request.viewport, now),
200
200
  ...(ticker ? { ticker } : {}),
201
201
  ...viewportBars(request.viewport, ticker),
@@ -221,6 +221,7 @@ export async function resolveLoadedSeries(
221
221
  panelId: "main",
222
222
  interpolation: style === "step" ? "step-after" : "none",
223
223
  ...(warning ? { warning } : {}),
224
+ ...(savedAt != null && Number.isFinite(savedAt) ? { savedAt } : {}),
224
225
  ...(attribution ? { attribution: { ...attribution } } : {}),
225
226
  points: normalizeSeriesPoints(points, edge),
226
227
  };
@@ -92,6 +92,11 @@ export interface SeriesLoadResult {
92
92
  points: readonly SeriesPoint[];
93
93
  /** A note the chart shows above the plot, such as "cached data is stale". */
94
94
  warning?: string;
95
+ /**
96
+ * When the copy the points came from was saved (ms), when its refresh failed
97
+ * and that copy answered: the chart's footer says "saved copy from …".
98
+ */
99
+ savedAt?: number;
95
100
  /** What a report of this series cites in place of Gloom Cloud. */
96
101
  attribution?: SeriesAttribution;
97
102
  }
@@ -1,4 +1,4 @@
1
- import { createPluginCache } from "../../data/plugin-cache";
1
+ import { createPluginCache, savedCopyForTransientFailure } from "../../data/plugin-cache";
2
2
  import { getPublishedUsEquityCalendarDay } from "../../market-data/published-us-sessions";
3
3
  import { zonedDateKey } from "../../utils/zoned-date-time";
4
4
  import type {
@@ -198,7 +198,9 @@ export async function loadCachedFredSeries(
198
198
  const cached = stored ? withFredSourceFreshness(stored) : null;
199
199
  if (!options?.force && !pending && cached && !cached.stale) return { ...cached, source: "cache" };
200
200
  const result = await cache.load(cacheKey(request), loader, pending ? { ...options, force: true } : options);
201
- // An early re-read is opportunistic: a failure keeps the still-fresh copy as it was.
201
+ // An early re-read is opportunistic: a failure keeps the still-fresh copy as it was, as not asking would have.
202
202
  if (pending && result.source === "stale-fallback" && cached && !cached.stale && !options?.force) return { ...cached, source: "cache" };
203
- return withFredSourceFreshness(result);
203
+ // A refusal (a series no longer served, a signed-out session) or a reply that fails validation is thrown,
204
+ // whatever copy is saved: a saved copy answers only for a failure asking again may get past.
205
+ return withFredSourceFreshness(savedCopyForTransientFailure(result));
204
206
  }
@@ -31,7 +31,18 @@ function parseStallReportState(text: string): StallReportState {
31
31
  if (typeof at === "number" && Number.isFinite(at)) sent[key] = at;
32
32
  }
33
33
  }
34
- return { sent, pending: readCounts(record.pending) };
34
+ return { sent, pending: readCounts(record.pending), recent: readTimes(record.recent) };
35
+ }
36
+
37
+ function readTimes(value: unknown): NonNullable<StallReportState["recent"]> {
38
+ const recent: NonNullable<StallReportState["recent"]> = {};
39
+ if (!value || typeof value !== "object") return recent;
40
+ for (const [process, times] of Object.entries(value as Record<string, unknown>)) {
41
+ if (!Array.isArray(times)) continue;
42
+ const kept = times.filter((at): at is number => typeof at === "number" && Number.isFinite(at));
43
+ if (kept.length > 0) recent[process] = kept;
44
+ }
45
+ return recent;
35
46
  }
36
47
 
37
48
  function trimLog(path: string): void {
@@ -62,7 +73,7 @@ export function createNodeStallReporterHost(options: {
62
73
  },
63
74
  load: () => {
64
75
  const path = pathFor(STATE_FILE);
65
- if (!path || !existsSync(path)) return { sent: {}, pending: {} };
76
+ if (!path || !existsSync(path)) return { sent: {}, pending: {}, recent: {} };
66
77
  return parseStallReportState(readFileSync(path, "utf-8"));
67
78
  },
68
79
  save: (state) => {