gloomberb 0.16.0 → 0.16.1

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 (167) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/data.ts +18 -3
  3. package/src/api-client/index.ts +3 -0
  4. package/src/api-client/members.ts +26 -0
  5. package/src/api-client/perps.ts +4 -0
  6. package/src/api-client/types.ts +7 -1
  7. package/src/app/global-shortcuts.ts +14 -6
  8. package/src/app/keybindings/actions.ts +1 -1
  9. package/src/app/runtime/update.ts +38 -2
  10. package/src/app.tsx +10 -6
  11. package/src/cli/pane-functions/function-help.ts +9 -2
  12. package/src/components/command-bar/commands/collection.ts +5 -1
  13. package/src/components/command-bar/routes/root/command-items.ts +9 -2
  14. package/src/components/command-bar/routes/root/plugin-install.ts +2 -2
  15. package/src/components/command-bar/routes/root/provider-search.ts +19 -5
  16. package/src/components/command-bar/routes/root/runtime.ts +14 -5
  17. package/src/components/command-bar/routes/ticker-search/results.ts +31 -5
  18. package/src/components/command-bar/routes/ticker-search/route.ts +2 -2
  19. package/src/components/command-bar/routing/actions.ts +2 -0
  20. package/src/components/command-bar/selection-runtime.ts +38 -0
  21. package/src/components/command-bar/surface/index.tsx +7 -2
  22. package/src/components/index.ts +2 -2
  23. package/src/components/layout/floating-pane.tsx +6 -0
  24. package/src/components/layout/header.tsx +24 -7
  25. package/src/components/layout/missing-pane.tsx +2 -2
  26. package/src/components/layout/pane/header.tsx +63 -30
  27. package/src/components/layout/pane/index.tsx +6 -0
  28. package/src/components/layout/pane/title.ts +31 -7
  29. package/src/components/layout/shell/drag/index.ts +6 -5
  30. package/src/components/layout/shell/drag/runtime.ts +3 -0
  31. package/src/components/layout/shell/index.tsx +6 -0
  32. package/src/components/layout/shell/pane/layers.tsx +15 -7
  33. package/src/components/layout/shell/terminal-pointer-runtime.ts +24 -3
  34. package/src/components/layout/shell/ticker-link-menu.ts +23 -9
  35. package/src/components/metric-treemap/heat-surface.tsx +718 -0
  36. package/src/components/metric-treemap/heat-tiles.ts +210 -0
  37. package/src/components/metric-treemap/index.tsx +2 -0
  38. package/src/components/metric-treemap/layout.ts +6 -0
  39. package/src/components/metric-treemap/scene.ts +432 -0
  40. package/src/components/pane-settings-dialog/field-dialogs.tsx +1 -0
  41. package/src/components/toggle-list.tsx +38 -12
  42. package/src/components/ui/country-flag-art.tsx +449 -0
  43. package/src/components/ui/country-flag.tsx +41 -0
  44. package/src/components/ui/data-table/types.ts +13 -0
  45. package/src/components/ui/icon.tsx +2 -0
  46. package/src/components/ui/index.ts +4 -0
  47. package/src/components/ui/multi-select/dialog.tsx +108 -22
  48. package/src/components/ui/range-track.tsx +46 -0
  49. package/src/components/ui/tabs.tsx +4 -0
  50. package/src/core/state/app/types.ts +10 -1
  51. package/src/i18n/es.ts +5 -3
  52. package/src/i18n/ja.ts +5 -3
  53. package/src/i18n/ko.ts +5 -3
  54. package/src/i18n/zh-cn.ts +5 -3
  55. package/src/i18n/zh-tw.ts +5 -3
  56. package/src/layout/ticker-navigation.ts +28 -2
  57. package/src/market-data/quotes/contributions.ts +6 -0
  58. package/src/market-data/quotes/freshness.ts +36 -0
  59. package/src/market-data/quotes/resolution.ts +22 -8
  60. package/src/market-data/quotes/time.ts +16 -0
  61. package/src/plugins/builtin/analytics/risk-client.ts +185 -59
  62. package/src/plugins/builtin/analytics/risk-headless.ts +55 -49
  63. package/src/plugins/builtin/analytics/risk-math.ts +33 -13
  64. package/src/plugins/builtin/analytics/risk-model.ts +250 -60
  65. package/src/plugins/builtin/analytics/risk-pane.tsx +61 -30
  66. package/src/plugins/builtin/analytics/risk-test-data.ts +182 -0
  67. package/src/plugins/builtin/buildout/cells.tsx +15 -12
  68. package/src/plugins/builtin/buildout/detail/company.tsx +3 -3
  69. package/src/plugins/builtin/buildout/detail/index.tsx +0 -1
  70. package/src/plugins/builtin/buildout/detail/intel.tsx +6 -6
  71. package/src/plugins/builtin/buildout/detail/site.tsx +4 -7
  72. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -19
  73. package/src/plugins/builtin/catalysts/index.tsx +2 -2
  74. package/src/plugins/builtin/catalysts/pane.tsx +3 -1
  75. package/src/plugins/builtin/cds/index.tsx +1 -0
  76. package/src/plugins/builtin/debt-maturities/wall-bar.tsx +14 -41
  77. package/src/plugins/builtin/dividend-yield/client.ts +4 -2
  78. package/src/plugins/builtin/earnings/client.ts +2 -2
  79. package/src/plugins/builtin/earnings-calls/index.tsx +1 -0
  80. package/src/plugins/builtin/fx-matrix/direction.ts +81 -0
  81. package/src/plugins/builtin/fx-matrix/index.tsx +71 -33
  82. package/src/plugins/builtin/fx-matrix/live-legs.ts +17 -0
  83. package/src/plugins/builtin/fx-matrix/pairs.ts +68 -14
  84. package/src/plugins/builtin/fx-matrix/settings.ts +62 -0
  85. package/src/plugins/builtin/jobs/index.tsx +1 -0
  86. package/src/plugins/builtin/jobs/share-bars.tsx +5 -28
  87. package/src/plugins/builtin/market-heatmap/data.ts +28 -2
  88. package/src/plugins/builtin/market-heatmap/index.tsx +250 -79
  89. package/src/plugins/builtin/market-heatmap/live.ts +88 -0
  90. package/src/plugins/builtin/market-heatmap/portfolio.ts +27 -7
  91. package/src/plugins/builtin/market-movers/client.ts +3 -2
  92. package/src/plugins/builtin/market-movers/headless.ts +8 -1
  93. package/src/plugins/builtin/market-movers/screener.ts +18 -5
  94. package/src/plugins/builtin/members/client.ts +36 -0
  95. package/src/plugins/builtin/members/headless.ts +33 -0
  96. package/src/plugins/builtin/members/index.tsx +20 -0
  97. package/src/plugins/builtin/members/model.ts +60 -0
  98. package/src/plugins/builtin/members/pane.tsx +168 -0
  99. package/src/plugins/builtin/mna/index.tsx +2 -1
  100. package/src/plugins/builtin/mna/pane.tsx +5 -2
  101. package/src/plugins/builtin/options/index.tsx +2 -1
  102. package/src/plugins/builtin/pe-band/client.ts +29 -2
  103. package/src/plugins/builtin/pe-band/headless.ts +2 -2
  104. package/src/plugins/builtin/pe-band/model.ts +11 -7
  105. package/src/plugins/builtin/pe-band/pane.tsx +2 -1
  106. package/src/plugins/builtin/pe-band/report-dates.ts +78 -0
  107. package/src/plugins/builtin/perps/client.ts +17 -3
  108. package/src/plugins/builtin/perps/equity-identity.ts +16 -0
  109. package/src/plugins/builtin/perps/equity-row.tsx +6 -4
  110. package/src/plugins/builtin/plugin-marketplace/activation.ts +19 -0
  111. package/src/plugins/builtin/plugin-marketplace/pane.tsx +2 -2
  112. package/src/plugins/builtin/plugin-marketplace/store.ts +2 -0
  113. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +43 -2
  114. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +48 -5
  115. package/src/plugins/builtin/scanner/hilo-bars.tsx +12 -70
  116. package/src/plugins/builtin/scanner/hilo-model.ts +0 -18
  117. package/src/plugins/builtin/sectors/move-bar.tsx +10 -32
  118. package/src/plugins/builtin/social-mentions/index.tsx +1 -1
  119. package/src/plugins/builtin/thirteenf/index.tsx +2 -1
  120. package/src/plugins/builtin/ticker-detail/overview/components.tsx +5 -60
  121. package/src/plugins/builtin/ticker-detail/overview/pane.tsx +1 -1
  122. package/src/plugins/builtin/ticker-detail/pane.tsx +1 -1
  123. package/src/plugins/builtin/ticker-detail/quote-footer.ts +7 -1
  124. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +20 -4
  125. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +2 -2
  126. package/src/plugins/builtin/vol-surface/index.tsx +1 -1
  127. package/src/plugins/catalog-browser.ts +2 -0
  128. package/src/plugins/catalog-manifest.ts +1 -0
  129. package/src/plugins/catalog-ui.ts +3 -1
  130. package/src/plugins/catalog.ts +1 -1
  131. package/src/plugins/host-link.ts +33 -1
  132. package/src/plugins/installer.ts +48 -12
  133. package/src/plugins/loader.ts +18 -3
  134. package/src/plugins/manager-node.ts +15 -5
  135. package/src/plugins/registry/index.ts +1 -1
  136. package/src/plugins/registry/pane-settings.ts +25 -7
  137. package/src/public/__snapshots__/public-api.test.ts.snap +2 -0
  138. package/src/renderers/dom/data-table/row.tsx +50 -16
  139. package/src/renderers/dom/desktop/icons.tsx +1 -0
  140. package/src/renderers/dom/host/box.tsx +69 -26
  141. package/src/renderers/dom/host/tabs.tsx +37 -1
  142. package/src/renderers/dom/styles.css +40 -5
  143. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +11 -6
  144. package/src/renderers/electrobun/bun/desktop/backend.ts +1 -0
  145. package/src/renderers/electrobun/bun/desktop/update.ts +181 -87
  146. package/src/renderers/electrobun/bun/index.ts +73 -6
  147. package/src/renderers/electrobun/bun/window/remembered-frame.ts +122 -0
  148. package/src/renderers/electrobun/shared/protocol.ts +3 -0
  149. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +2 -1
  150. package/src/renderers/electrobun/view/main.tsx +7 -1
  151. package/src/renderers/electrobun/view/renderer-window-error.ts +18 -0
  152. package/src/renderers/electrobun/view/update-host.ts +45 -21
  153. package/src/sources/gloomberb-cloud/normalizers.ts +9 -2
  154. package/src/sources/provider-router/financials.ts +3 -1
  155. package/src/telemetry/benign-window-error.ts +7 -0
  156. package/src/telemetry/crash-reports-dom.ts +2 -0
  157. package/src/test-support/daily-nav.ts +25 -0
  158. package/src/theme/heat-colors.ts +174 -1
  159. package/src/tickers/instrument-kind.ts +34 -7
  160. package/src/tickers/search/asset-classes.ts +24 -9
  161. package/src/tickers/search/index.ts +44 -11
  162. package/src/tickers/search/ranking.ts +23 -3
  163. package/src/types/financials.ts +2 -0
  164. package/src/types/plugin.ts +18 -5
  165. package/src/ui/context-menu.tsx +1 -0
  166. package/src/ui/host.tsx +2 -0
  167. package/src/updater/index.ts +29 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/public/index.ts",
@@ -181,6 +181,18 @@ export class CloudDataApi {
181
181
  return this.request<T>(`/cloud/equity-screener/${path}`, init);
182
182
  }
183
183
 
184
+ getMemberFunds() {
185
+ return this.request<import("./members").MemberFundsPayload>("/cloud/members");
186
+ }
187
+
188
+ getFundMembers(fund: string) {
189
+ return this.request<import("./members").FundMembersPayload>(`/cloud/members/${encodeURIComponent(fund)}`);
190
+ }
191
+
192
+ getFundChanges(fund: string) {
193
+ return this.request<import("./members").FundChangesPayload>(`/cloud/members/${encodeURIComponent(fund)}/changes`);
194
+ }
195
+
184
196
  getCloudThemes() {
185
197
  return this.request<import("./themes").ThemesPayload>("/cloud/themes");
186
198
  }
@@ -220,8 +232,8 @@ export class CloudDataApi {
220
232
  return this.request<EarningsCalendarPayload>(`/cloud/earnings/calendar?${params}`, { signal: AbortSignal.timeout(30_000) });
221
233
  }
222
234
 
223
- async getCloudEarningsHistory(symbol: string): Promise<EarningsHistoryPayload> {
224
- const params = new URLSearchParams({ symbol, limit: "13" });
235
+ async getCloudEarningsHistory(symbol: string, limit = 13): Promise<EarningsHistoryPayload> {
236
+ const params = new URLSearchParams({ symbol, limit: String(limit) });
225
237
  return this.request<EarningsHistoryPayload>(`/cloud/earnings/history?${params}`, { signal: AbortSignal.timeout(30_000) });
226
238
  }
227
239
 
@@ -478,7 +490,10 @@ export class CloudDataApi {
478
490
  }
479
491
  async getCloudPerpsRankings(): Promise<PerpRankingsPayload> { return this.request("/cloud/perps/rankings"); }
480
492
  async getCloudPerpsCompare(baseAsset: string): Promise<PerpBoardPayload> { return this.request(`/cloud/perps/compare?${new URLSearchParams({ baseAsset })}`); }
481
- async getCloudPerpsEquity(symbol: string): Promise<PerpBoardPayload> { return this.request(`/cloud/perps/equity/${encodeURIComponent(symbol)}`); }
493
+ async getCloudPerpsEquity(symbol: string, exchange?: string): Promise<PerpBoardPayload> {
494
+ const query = exchange === undefined ? "" : `?${new URLSearchParams({ identity: "listing", exchange })}`;
495
+ return this.request(`/cloud/perps/equity/${encodeURIComponent(symbol)}${query}`);
496
+ }
482
497
  async getCloudPerpsMarket(marketId: string): Promise<PerpMarketPayload> { return this.request(`/cloud/perps/market?${new URLSearchParams({ marketId })}`); }
483
498
 
484
499
  async analyzeCloudExposure(request: ExposureRequest): Promise<ExposurePayload> {
@@ -742,6 +742,9 @@ class GloomApiClient {
742
742
  getCloudEstimateRevisions = this.data.getCloudEstimateRevisions.bind(this.data);
743
743
  getCloudMoneyMarkets = this.data.getCloudMoneyMarkets.bind(this.data);
744
744
  equityScreener = this.data.equityScreener.bind(this.data);
745
+ getMemberFunds = this.data.getMemberFunds.bind(this.data);
746
+ getFundMembers = this.data.getFundMembers.bind(this.data);
747
+ getFundChanges = this.data.getFundChanges.bind(this.data);
745
748
  getCloudThemes = this.data.getCloudThemes.bind(this.data);
746
749
  getCloudThemeMembers = this.data.getCloudThemeMembers.bind(this.data);
747
750
  impliedVolatility = this.data.impliedVolatility.bind(this.data);
@@ -0,0 +1,26 @@
1
+ interface MemberFund { ticker: string; name: string; aliases: string[]; changes: "sp500" | "announcements" | "none" }
2
+ export interface FundMember {
3
+ id: string; symbol: string | null; name: string; sector: string | null;
4
+ weight: number | null; shares: number; holdingPrice: number; marketValue: number;
5
+ price: number | null; priceAsOf: string | null; changePercent: number | null; dailyAsOf: string | null;
6
+ return1WPercent: number | null; return1MPercent: number | null; returnYtdPercent: number | null;
7
+ contribution: number | null; stale: boolean;
8
+ }
9
+ export interface FundMembersPayload {
10
+ fund: MemberFund; asOf: string; fetchedAt: string; excludedDerivatives: number; stale: boolean;
11
+ quotesUnavailable: boolean; snapshotAsOf: string | null; fundDailyAsOf: string | null; quoteCap: number;
12
+ members: FundMember[];
13
+ aggregate: { sum: number | null; fundReturn: number | null; residual: number | null; tolerancePp: number;
14
+ withinTolerance: boolean | null; covered: number; total: number; coveredWeight: number; holdingsWeight: number; fresh1D: number };
15
+ }
16
+ export interface FundChange {
17
+ id: string; fund: string; effectiveDate: string | null; announcedAt: string | null;
18
+ added: string | null; removed: string | null; reason: string; headline: string | null; url: string;
19
+ kind: "history" | "announcement"; daysToGo: number | null;
20
+ estimates: { symbol: string; usd: number; advUsd: number; days: number; asOf: string; advAsOf: string | null }[];
21
+ }
22
+ export interface FundChangesPayload {
23
+ fund: MemberFund; changes: FundChange[]; asOf: string | null; stale: boolean; available: boolean;
24
+ estimateLabel: string; attribution: { text: string; url: string } | null;
25
+ }
26
+ export interface MemberFundsPayload { funds: (MemberFund & { asOf: string | null })[] }
@@ -71,6 +71,7 @@ interface PerpCandle {
71
71
 
72
72
  interface UnderlyingReference {
73
73
  symbol: string
74
+ listingExchange?: string
74
75
  price: number
75
76
  currency: string
76
77
  asOf: string
@@ -79,6 +80,7 @@ interface UnderlyingReference {
79
80
  }
80
81
 
81
82
  export interface PerpBoardRow extends PerpMarket {
83
+ equityMatch?: { listing: PerpEquityListing; underlying: PerpEquityListing; basis: "listing" | "issuer" }
82
84
  stale: boolean
83
85
  oiChange1h: number | null
84
86
  oiChange24h: number | null
@@ -105,9 +107,11 @@ type PerpHistoryPoint = {
105
107
  }
106
108
 
107
109
  export interface PerpBoardPayload {
110
+ listing?: PerpEquityListing | null;
108
111
  status: string; asOf: string | null; rows: PerpBoardRow[]; total: number; locked: number;
109
112
  access: "pro" | "preview"; sources?: unknown[];
110
113
  }
114
+ export interface PerpEquityListing { symbol: string; exchange: string }
111
115
  export interface PerpHistoryPayload {
112
116
  status: string; marketId: string; rows: PerpHistoryPoint[]; funding: PerpFunding[]; candles: PerpCandle[];
113
117
  locked: boolean; access: "pro" | "preview"; asOf: string | null;
@@ -295,7 +295,9 @@ export interface CloudSyncPushResponse {
295
295
  settings: SyncSettings;
296
296
  }
297
297
 
298
- export interface CloudQuotePayload extends Omit<Quote, "change" | "changePercent"> {
298
+ export interface CloudQuotePayload extends Omit<Quote, "change" | "changePercent" | "priceBasis" | "priceObservation"> {
299
+ /** The service labels daily NAV here; the app separates observation kind from price units. */
300
+ priceBasis?: Quote["priceBasis"] | "nav";
299
301
  // JSON encodes the provider's unavailable numeric values as null.
300
302
  change?: number | null;
301
303
  changePercent?: number | null;
@@ -1375,6 +1377,10 @@ export interface CloudMarketScreenerItem {
1375
1377
  change: number;
1376
1378
  changePercent: number;
1377
1379
  volume: number;
1380
+ /** Available average volume for this listing, independent of public ranking membership. */
1381
+ avgVolume?: number;
1382
+ /** Observation time of the optional retained metadata. */
1383
+ metadataAsOf?: string;
1378
1384
  tradeCount?: number;
1379
1385
  currency: string;
1380
1386
  high52w?: number;
@@ -8,7 +8,7 @@ import type { AppAction, AppState } from "../state/app/context";
8
8
  import type { TickerRecord } from "../types/ticker";
9
9
  import { findPaneInstance } from "../types/config";
10
10
  import type { ReleaseInfo } from "../updater";
11
- import { canSelfUpdate } from "../updater";
11
+ import { canRestartToApply, canSelfUpdate } from "../updater";
12
12
  import { getVisiblePaneCycleOrder } from "../components/layout/pane/cycle-order";
13
13
  import { requestFunctionHelp, useFunctionHelpHost } from "../plugins/builtin/help/function-card";
14
14
  import { buildRegistryHelpIndex, helpFunctionForPane } from "../plugins/builtin/help/function-index";
@@ -34,9 +34,10 @@ import {
34
34
  const KEYBINDING_NOTICE_DURATION_MS = 15_000;
35
35
 
36
36
  /**
37
- * Whether the install-update key has something to do: a release this build
37
+ * Whether the install-update key has something to retry: a release this build
38
38
  * can install itself whose last attempt failed. A fresh one downloads on its
39
- * own, and a manual or managed one names its own command.
39
+ * own, and a manual or managed one names its own command. (A downloaded
40
+ * desktop update has its own use of the key: restarting into it.)
40
41
  */
41
42
  export function canRetryUpdate(
42
43
  state: Pick<AppState, "updateAvailable" | "updateProgress" | "updateCheckInProgress">,
@@ -91,6 +92,7 @@ export function useAppGlobalShortcuts({
91
92
  keybindings = getDefaultKeybindings(),
92
93
  pluginRegistry,
93
94
  refreshTicker,
95
+ restartToApplyUpdate,
94
96
  startUpdate,
95
97
  state,
96
98
  }: {
@@ -101,6 +103,8 @@ export function useAppGlobalShortcuts({
101
103
  keybindings?: ResolvedKeybindings;
102
104
  pluginRegistry: PluginRegistry;
103
105
  refreshTicker: (symbol: string, exchange?: string, tickerOverride?: TickerRecord | null, priority?: number) => void;
106
+ /** Applies a downloaded desktop update and relaunches. Runs only on the user's key press. */
107
+ restartToApplyUpdate: () => void;
104
108
  startUpdate: (release: ReleaseInfo) => void;
105
109
  state: AppState;
106
110
  }) {
@@ -249,9 +253,13 @@ export function useAppGlobalShortcuts({
249
253
  refreshTicker(ticker.metadata.ticker, ticker.metadata.exchange, ticker, 1);
250
254
  }
251
255
  } else if (action === "install-update") {
252
- // A release that can install itself starts on its own, so the key is
253
- // for the one that failed: it tries again.
254
- if (canRetryUpdate(state)) {
256
+ // A release that can install itself downloads on its own, so the key is
257
+ // for the one that waits for a restart and for the one that failed.
258
+ if (!isDetachedWindow && canRestartToApply(state.updateProgress)) {
259
+ event.preventDefault();
260
+ event.stopPropagation();
261
+ restartToApplyUpdate();
262
+ } else if (canRetryUpdate(state)) {
255
263
  event.preventDefault();
256
264
  event.stopPropagation();
257
265
  startUpdate(state.updateAvailable!);
@@ -129,7 +129,7 @@ export const KEYBINDING_ACTIONS: readonly KeybindingActionDef[] = [
129
129
  {
130
130
  id: "install-update",
131
131
  category: "Global Keys",
132
- description: "Install an available app update when one is shown.",
132
+ description: "Restart into a downloaded app update, or retry one that failed.",
133
133
  defaults: ["U"],
134
134
  },
135
135
  {
@@ -1,8 +1,10 @@
1
- import { useCallback, useEffect, type Dispatch } from "react";
1
+ import { useCallback, useEffect, useRef, type Dispatch } from "react";
2
2
  import type { PluginRegistry } from "../../plugins/registry";
3
3
  import { saveConfigImmediately } from "../../state/config-save-scheduler";
4
4
  import type { AppAction, AppState } from "../../state/app/context";
5
5
  import {
6
+ applyUpdate,
7
+ canRestartToApply,
6
8
  canSelfUpdate,
7
9
  checkForUpdateDetailed,
8
10
  performUpdate,
@@ -15,6 +17,11 @@ import { runAutomated } from "../../telemetry/usage-counts";
15
17
 
16
18
  const UPDATE_CHECK_INTERVAL_MS = 60 * 60_000; // hourly
17
19
 
20
+ /** An update is being downloaded, applied, or is downloaded and waiting for a restart. */
21
+ function updateInFlight(progress: AppState["updateProgress"]): boolean {
22
+ return progress?.phase === "downloading" || progress?.phase === "replacing" || progress?.phase === "ready";
23
+ }
24
+
18
25
  export function useAppUpdateRuntime({
19
26
  enabled = true,
20
27
  dispatch,
@@ -36,7 +43,13 @@ export function useAppUpdateRuntime({
36
43
  }): {
37
44
  runUpdateCheck: (manual?: boolean) => Promise<void>;
38
45
  startUpdate: (release: ReleaseInfo) => void;
46
+ /** Installs the downloaded desktop update and relaunches. Does nothing unless one is ready. */
47
+ restartToApplyUpdate: () => void;
48
+ /** The command bar's Check for Updates: restarts into a downloaded update, otherwise checks. */
49
+ runUpdateCommand: () => void;
39
50
  } {
51
+ const restartingRef = useRef(false);
52
+
40
53
  const startUpdate = useCallback((release: ReleaseInfo) => {
41
54
  dispatch({ type: "SET_UPDATE_PROGRESS", progress: { phase: "downloading", percent: 0 } });
42
55
  void performUpdate(release, (progress) => {
@@ -44,7 +57,23 @@ export function useAppUpdateRuntime({
44
57
  });
45
58
  }, [dispatch]);
46
59
 
60
+ const restartToApplyUpdate = useCallback(() => {
61
+ if (!enabled || isDetachedWindow) return;
62
+ // The app relaunches here, so this must only ever follow the user's own
63
+ // request, and a second one while the first is working changes nothing.
64
+ if (restartingRef.current || !canRestartToApply(stateRef.current.updateProgress)) return;
65
+ restartingRef.current = true;
66
+ dispatch({ type: "SET_UPDATE_PROGRESS", progress: { phase: "replacing" } });
67
+ void applyUpdate((progress) => {
68
+ if (progress.phase === "error") restartingRef.current = false;
69
+ dispatch({ type: "SET_UPDATE_PROGRESS", progress });
70
+ });
71
+ }, [dispatch, enabled, isDetachedWindow, stateRef]);
72
+
47
73
  const runUpdateCheck = useCallback(async (manual = false) => {
74
+ // A staged update is the answer until it is applied: another check would
75
+ // swap the release under the "ready" state, and the host has nothing to add.
76
+ if (updateInFlight(stateRef.current.updateProgress)) return;
48
77
  if (manual) {
49
78
  dispatch({ type: "SET_UPDATE_CHECK_IN_PROGRESS", checking: true });
50
79
  dispatch({ type: "SET_UPDATE_NOTICE", notice: null });
@@ -88,7 +117,12 @@ export function useAppUpdateRuntime({
88
117
  }
89
118
 
90
119
  dispatch({ type: "SET_UPDATE_NOTICE", notice: `Update check failed: ${result.error}` });
91
- }, [dispatch]);
120
+ }, [dispatch, stateRef]);
121
+
122
+ const runUpdateCommand = useCallback(() => {
123
+ if (canRestartToApply(stateRef.current.updateProgress)) restartToApplyUpdate();
124
+ else void runUpdateCheck(true);
125
+ }, [restartToApplyUpdate, runUpdateCheck, stateRef]);
92
126
 
93
127
  useEffect(() => {
94
128
  if (!enabled || isDetachedWindow) return;
@@ -121,7 +155,9 @@ export function useAppUpdateRuntime({
121
155
  }, [enabled, isDetachedWindow, startUpdate, updateAvailable, updateCheckInProgress, updateProgress]);
122
156
 
123
157
  return {
158
+ restartToApplyUpdate,
124
159
  runUpdateCheck,
160
+ runUpdateCommand,
125
161
  startUpdate,
126
162
  };
127
163
  }
package/src/app.tsx CHANGED
@@ -268,7 +268,7 @@ function AppInner({
268
268
  tickerRepository,
269
269
  });
270
270
 
271
- const { runUpdateCheck, startUpdate } = useAppUpdateRuntime({
271
+ const { restartToApplyUpdate, runUpdateCheck, runUpdateCommand, startUpdate } = useAppUpdateRuntime({
272
272
  enabled: updatesEnabled,
273
273
  dispatch,
274
274
  isDetachedWindow,
@@ -409,6 +409,7 @@ function AppInner({
409
409
  keybindings,
410
410
  pluginRegistry,
411
411
  refreshTicker,
412
+ restartToApplyUpdate,
412
413
  startUpdate,
413
414
  state,
414
415
  });
@@ -450,10 +451,13 @@ function AppInner({
450
451
  desktopWindowBridge={desktopWindowBridge}
451
452
  >
452
453
  <ThemedAppRoot>
453
- <Header onOpenHelp={() => {
454
- recordFunctionOpen({ shortcut: "HELP", externalPluginId: null });
455
- pluginRegistry.showPane("help");
456
- }} />
454
+ <Header
455
+ onOpenHelp={() => {
456
+ recordFunctionOpen({ shortcut: "HELP", externalPluginId: null });
457
+ pluginRegistry.showPane("help");
458
+ }}
459
+ onRestartForUpdate={updatesEnabled && !isDetachedWindow ? restartToApplyUpdate : undefined}
460
+ />
457
461
  <TransientLayoutProvider>
458
462
  <Shell
459
463
  pluginRegistry={pluginRegistry}
@@ -486,7 +490,7 @@ function AppInner({
486
490
  tickerRepository={tickerRepository}
487
491
  pluginRegistry={pluginRegistry}
488
492
  quitApp={() => rendererHost.requestExit()}
489
- onCheckForUpdates={updatesEnabled ? () => runUpdateCheck(true) : undefined}
493
+ onCheckForUpdates={updatesEnabled ? runUpdateCommand : undefined}
490
494
  onNativeOccluderChange={setCommandBarNativeOccluder}
491
495
  />
492
496
  )}
@@ -660,6 +660,13 @@ export const FUNCTION_HELP: Readonly<Record<string, FunctionHelp>> = {
660
660
  data: QUOTES,
661
661
  bloomberg: ["IMAP"],
662
662
  },
663
+ MEMB: {
664
+ summary: "ETF holdings with weights, shares, member returns, daily contributions and index changes. SPX and SPY use IVV holdings.",
665
+ usage: ["MEMB", "MEMB SPY", "MEMB IWM"],
666
+ keys: [TABS, OPEN],
667
+ data: same("Dated fund holdings and partial delayed member returns. Nasdaq-100 is not covered."),
668
+ bloomberg: ["MEMB", "MRR", "IMOV"],
669
+ },
663
670
  THEM: {
664
671
  summary: "Curated thematic baskets with equal-weight returns and breadth. Open a theme to see its members, leaders and laggards. The Themes tab of BI.",
665
672
  usage: ["THEM", "THEM nuclear"],
@@ -675,14 +682,14 @@ export const FUNCTION_HELP: Readonly<Record<string, FunctionHelp>> = {
675
682
  bloomberg: ["RRG"],
676
683
  },
677
684
  HM: {
678
- summary: "The largest US stocks and ETFs as a treemap sized by market cap or assets and colored by the day's move.",
685
+ summary: "The largest US stocks and ETFs as a treemap sized by the square root of market cap or assets, or by plain market cap in its settings, and colored by the day's move.",
679
686
  usage: ["HM"],
680
687
  keys: [OPEN],
681
688
  data: QUOTES,
682
689
  bloomberg: ["IMAP"],
683
690
  },
684
691
  FXC: {
685
- summary: "A cross-rate matrix for the major currencies.",
692
+ summary: "A cross-rate matrix for the major currencies, or for any of 45 chosen in its settings.",
686
693
  usage: ["FXC"],
687
694
  keys: [],
688
695
  data: FX,
@@ -34,6 +34,8 @@ export async function executeCollectionCommandAction(options: {
34
34
  buildWorkflowDeps: () => WorkflowDeps;
35
35
  openModeRoute: (screen: "ticker-search", initialQuery?: string, payload?: Record<string, unknown>) => void;
36
36
  openAddToPortfolioWorkflow: (ticker: TickerRecord, preferredPortfolioId?: string | null) => void;
37
+ /** Join the named collection without the position form. */
38
+ directMembership?: boolean;
37
39
  pushRoute: (route: CommandBarRoute) => void;
38
40
  notify: (body: string, options?: { type?: "info" | "success" | "error" }) => void;
39
41
  closeAll: (options?: { revertThemePreview?: boolean }) => void;
@@ -73,7 +75,9 @@ export async function executeCollectionCommandAction(options: {
73
75
  };
74
76
  if (!selectSavedOwner(options.explicitTargetId ?? options.activeCollectionId)) return;
75
77
 
76
- if (kind === "portfolio" && action === "add") {
78
+ // A venue picked from the add row joins that collection directly. The AP
79
+ // command still asks for the position.
80
+ if (kind === "portfolio" && action === "add" && !options.directMembership) {
77
81
  const manualPortfolios = stateForCommand.config.portfolios.filter(isManualPortfolio);
78
82
  if (manualPortfolios.length === 0) {
79
83
  options.notify("Create a manual portfolio first.", { type: "info" });
@@ -3,6 +3,7 @@ import type { TickerRecord } from "../../../../types/ticker";
3
3
  import { isManualPortfolio } from "../../../../plugins/builtin/portfolio-list/mutations";
4
4
  import type { Command } from "../../commands/registry";
5
5
  import type { ResultItem } from "../../list/model";
6
+ import { canRestartToApply } from "../../../../updater";
6
7
 
7
8
  interface RootCommandItemBuilderOptions {
8
9
  activeCollectionId: string | null;
@@ -84,6 +85,9 @@ export function createRootCommandItemBuilder({
84
85
  return activeTickerSymbol ? `Remove ${activeTickerSymbol} from Portfolio` : command.label;
85
86
  case "set-portfolio-position":
86
87
  return activeTickerSymbol ? `Set Position for ${activeTickerSymbol}` : command.label;
88
+ // The row only shows its label, so a downloaded update is announced there.
89
+ case "check-for-updates":
90
+ return state.updateProgress?.phase === "ready" ? "Update ready, restart to apply" : command.label;
87
91
  default:
88
92
  return command.label;
89
93
  }
@@ -128,8 +132,9 @@ export function createRootCommandItemBuilder({
128
132
  return `Downloading v${state.updateAvailable?.version}: ${state.updateProgress.percent ?? 0}%`;
129
133
  }
130
134
  if (state.updateProgress?.phase === "replacing") return "Installing update";
135
+ if (state.updateProgress?.phase === "ready") return "Update ready, restart to apply";
131
136
  if (state.updateProgress?.phase === "done") {
132
- return state.updateProgress.message ?? "Update installed - restart to apply";
137
+ return state.updateProgress.message ?? "Update ready, restart to apply";
133
138
  }
134
139
  if (state.updateProgress?.phase === "error") {
135
140
  return `Update failed: ${state.updateProgress.error}`;
@@ -178,6 +183,8 @@ export function createRootCommandItemBuilder({
178
183
  switch (command.id) {
179
184
  case "set-portfolio-position":
180
185
  return "edit position update position modify position manual position portfolio position";
186
+ case "check-for-updates":
187
+ return state.updateProgress?.phase === "ready" ? "check for updates restart relaunch" : "";
181
188
  default:
182
189
  return "";
183
190
  }
@@ -196,7 +203,7 @@ export function createRootCommandItemBuilder({
196
203
  searchText: smartSearchText(command),
197
204
  disabled:
198
205
  command.id === "check-for-updates" &&
199
- (state.updateCheckInProgress || !!state.updateProgress),
206
+ (state.updateCheckInProgress || (!!state.updateProgress && !canRestartToApply(state.updateProgress))),
200
207
  action: () => runDirectCommand(command, arg),
201
208
  };
202
209
  };
@@ -3,7 +3,7 @@ import type { PluginRegistry } from "../../../../plugins/registry";
3
3
  import { findAbsorbedPlugin } from "../../../../plugins/absorbed";
4
4
  import { isOfficialPluginRepo } from "../../../../plugins/auto-update";
5
5
  import { getCurrentPluginTarget, runsExternalPlugins } from "../../../../plugins/current-target";
6
- import { activateInstalledPlugin } from "../../../../plugins/builtin/plugin-marketplace/activation";
6
+ import { activateInstall } from "../../../../plugins/builtin/plugin-marketplace/activation";
7
7
  import { loadRegistry, registryPluginUrl } from "../../../../plugins/builtin/plugin-marketplace/feed";
8
8
  import {
9
9
  installConsent,
@@ -217,7 +217,7 @@ function buildPluginInstallItem(
217
217
  onConfirm: async () => {
218
218
  const result = await manager.install(repo, pin);
219
219
  if (!result.ok) throw new Error(`Could not install ${plugin.name}: ${result.error}`);
220
- const activated = await activateInstalledPlugin(result.directory, host, manager);
220
+ const activated = await activateInstall(result, host, manager);
221
221
  if (!activated.ok) throw new Error(`${plugin.name} installed but did not load: ${activated.error}`);
222
222
  if (activated.restart) {
223
223
  context.notify(`Restart to finish installing ${plugin.name}.`, "info");
@@ -8,6 +8,7 @@ import {
8
8
  mergePlainRootTickerResults,
9
9
  mergeTickerSearchResultItems,
10
10
  QUICK_LOOK_TICKER_SEARCH_OPTIONS,
11
+ venueDropdownResults,
11
12
  } from "../ticker-search/results";
12
13
  import { orderListResults, type ResultItem } from "../../list/model";
13
14
  import type { CommandBarCategoryPriorities, CommandBarSectionOrder } from "../../view-model";
@@ -27,6 +28,8 @@ export function useRootProviderSearch(options: {
27
28
  brokerInstanceId?: string | null,
28
29
  ) => TickerSearchCandidate[] | null;
29
30
  rootPlainTickerSearchArg: string | null;
31
+ /** Set when the query is `NET:` or `NET:N`. Empty string lists every venue. */
32
+ listingSuffix: string | null;
30
33
  rootResultItems: ResultItem[];
31
34
  rootTickerSearchArg: string | null;
32
35
  tickers: AppState["tickers"];
@@ -52,6 +55,7 @@ export function useRootProviderSearch(options: {
52
55
  portfolios,
53
56
  readTickerSearchCache,
54
57
  rootPlainTickerSearchArg,
58
+ listingSuffix,
55
59
  rootResultItems,
56
60
  rootTickerSearchArg,
57
61
  tickers,
@@ -181,6 +185,13 @@ export function useRootProviderSearch(options: {
181
185
  }, []);
182
186
 
183
187
  const rootResults = useMemo(() => {
188
+ if (listingSuffix != null) {
189
+ if (rootTickerSearchArg && rootProviderResultsQuery === rootTickerSearchArg && rootProviderResults) {
190
+ return venueDropdownResults(rootTickerSearchArg, rootProviderResults, listingSuffix);
191
+ }
192
+ // The venue list replaces commands. Nothing else shows while it loads.
193
+ return [];
194
+ }
184
195
  if (rootTickerSearchArg && rootProviderResultsQuery === rootTickerSearchArg && rootProviderResults) {
185
196
  if (rootPlainTickerSearchArg) {
186
197
  return mergePlainRootTickerResults(rootPlainTickerSearchArg, rootProviderResults, rootResultItems);
@@ -189,17 +200,20 @@ export function useRootProviderSearch(options: {
189
200
  }
190
201
  return rootResultItems;
191
202
  }, [
203
+ listingSuffix,
192
204
  rootPlainTickerSearchArg,
193
205
  rootProviderResults,
194
206
  rootProviderResultsQuery,
195
207
  rootResultItems,
196
208
  rootTickerSearchArg,
197
209
  ]);
198
- const rootSectionOrder: CommandBarSectionOrder = rootPlainTickerSearchArg
199
- ? "app-first"
200
- : rootTickerSearchArg
201
- ? "ranked"
202
- : "default";
210
+ const rootSectionOrder: CommandBarSectionOrder = listingSuffix != null
211
+ ? "ranked"
212
+ : rootPlainTickerSearchArg
213
+ ? "app-first"
214
+ : rootTickerSearchArg
215
+ ? "ranked"
216
+ : "default";
203
217
  const orderedRootResults = useMemo(
204
218
  () => orderListResults(rootResults, { sectionOrder: rootSectionOrder, categoryPriorities }),
205
219
  [categoryPriorities, rootResults, rootSectionOrder],
@@ -4,7 +4,7 @@ import type { AppState } from "../../../../state/app/context";
4
4
  import type { DataProvider } from "../../../../types/data-provider";
5
5
  import type { CommandDef, PaneTemplateCreateOptions, PaneTemplateDef } from "../../../../types/plugin";
6
6
  import type { TickerRecord } from "../../../../types/ticker";
7
- import type { TickerSearchCandidate } from "../../../../tickers/search";
7
+ import { listingChoiceQuery, symbolSearchQuery, type TickerSearchCandidate } from "../../../../tickers/search";
8
8
  import type { AssistRowHandlers } from "../../assist/model";
9
9
  import { matchPrefix, type Command } from "../../commands/registry";
10
10
  import type { ResultItem } from "../../list/model";
@@ -246,11 +246,19 @@ export function useCommandBarRootRuntime({
246
246
  // Free text that no prefix claims also goes to symbol search, so "nvidia"
247
247
  // finds NVDA without the backtick. Skipped when a local row already carries
248
248
  // that exact name, since an "Exact Match" symbol would otherwise outrank it.
249
+ // `NET:` is that symbol's venue dropdown. The search itself is the bare symbol.
250
+ const rootListingChoice = useMemo(() => {
251
+ if (currentRoute || activeMatch || rootShortcutIntent.kind !== "none") return null;
252
+ return listingChoiceQuery(rootQuery);
253
+ }, [activeMatch, currentRoute, rootQuery, rootShortcutIntent.kind]);
249
254
  const rootPlainTickerSearchArg = useMemo(() => {
250
255
  if (currentRoute || activeMatch || rootShortcutIntent.kind !== "none") return null;
256
+ // An explicit venue choice also searches one-letter symbols and names shared with local actions.
257
+ if (rootListingChoice) return rootListingChoice.symbol;
251
258
  const trimmed = rootQuery.trim();
252
- if (trimmed.length < 2) return null;
253
- const normalizedQuery = normalizeCommandTickerSearchText(trimmed);
259
+ const symbolQuery = symbolSearchQuery(trimmed);
260
+ if (symbolQuery.length < 2) return null;
261
+ const normalizedQuery = normalizeCommandTickerSearchText(symbolQuery);
254
262
  // The install row for "TV" is labelled TV, but it leads on purpose and
255
263
  // must not keep Grupo Televisa out of the list.
256
264
  const hasExactLocalRow = rootResultModel.items.some((item) => (
@@ -259,8 +267,8 @@ export function useCommandBarRootRuntime({
259
267
  && item.kind !== "search"
260
268
  && normalizeCommandTickerSearchText(item.label) === normalizedQuery
261
269
  ));
262
- return hasExactLocalRow ? null : trimmed;
263
- }, [activeMatch, currentRoute, pluginInstallItem, rootQuery, rootResultModel.items, rootShortcutIntent.kind]);
270
+ return hasExactLocalRow ? null : symbolQuery;
271
+ }, [activeMatch, currentRoute, pluginInstallItem, rootListingChoice, rootQuery, rootResultModel.items, rootShortcutIntent.kind]);
264
272
  const rootTickerSearchArg = rootSecurityDescriptionArg ?? rootPlainTickerSearchArg;
265
273
 
266
274
  const {
@@ -278,6 +286,7 @@ export function useCommandBarRootRuntime({
278
286
  portfolios: state.config.portfolios,
279
287
  readTickerSearchCache,
280
288
  rootPlainTickerSearchArg,
289
+ listingSuffix: rootPlainTickerSearchArg && rootListingChoice ? rootListingChoice.suffix : null,
281
290
  rootResultItems: rootResultModel.items,
282
291
  rootTickerSearchArg,
283
292
  tickers: state.tickers,
@@ -10,7 +10,7 @@ import {
10
10
  parseAssetClassQuery,
11
11
  } from "../../../../tickers/search/asset-classes";
12
12
  import type { ResultItem } from "../../list/model";
13
- import { canonicalExchange, parsePublicTickerKey } from "../../../../utils/exchanges";
13
+ import { canonicalExchange, parsePublicTickerKey, publicExchange } from "../../../../utils/exchanges";
14
14
  import { compactSearchText, getIssuerGroupKey, isExplicitMarketSymbol } from "../../../../tickers/search/ranking";
15
15
 
16
16
  export const QUICK_LOOK_TICKER_SEARCH_OPTIONS = { includeOptionContracts: false } as const;
@@ -43,10 +43,10 @@ function rawInstrumentType(candidate: Pick<TickerSearchCandidate, "result" | "ti
43
43
  }
44
44
 
45
45
  /**
46
- * Class tag for the badge column: the class code a query can end with (EQ,
47
- * CUR, OPT, FUT, IDX, ETF), else FUND or DERIV. An unclassified instrument
48
- * gets none: the row lifts its exchange code there instead when the code is
49
- * short enough.
46
+ * Class tag for the badge column. A query can end with the same code: EQ,
47
+ * CUR, CRYP, OPT, FUT, IDX, ETF, FUND. A fund or derivative whose type string
48
+ * did not classify still badges FUND or DERIV. Anything else gets none, and
49
+ * the row shows its exchange code there when the code is short enough.
50
50
  */
51
51
  export function formatInstrumentBadge(
52
52
  candidate: Pick<TickerSearchCandidate, "instrumentClass" | "result" | "ticker">
@@ -167,6 +167,32 @@ export function mergePlainRootTickerResults(
167
167
  ];
168
168
  }
169
169
 
170
+ function venueMatchesSuffix(item: ResultItem, suffix: string): boolean {
171
+ if (!suffix) return true;
172
+ const displayed = (item.right ?? "").trim().toUpperCase();
173
+ const fromLabel = parsePublicTickerKey(item.label.trim().toUpperCase()).exchange ?? "";
174
+ const canonicalDisplayed = canonicalExchange(displayed);
175
+ const targets = [displayed, canonicalDisplayed, fromLabel, publicExchange(canonicalDisplayed || displayed)];
176
+ if (targets.some((value) => value.startsWith(suffix))) return true;
177
+ const aliased = canonicalExchange(suffix);
178
+ return aliased !== suffix && targets.some((value) => canonicalExchange(value) === aliased);
179
+ }
180
+
181
+ /**
182
+ * The colon query's dropdown: one row per exact venue, in ranked order. A
183
+ * typed exchange prefix keeps the rows it matches.
184
+ */
185
+ export function venueDropdownResults(symbol: string, items: ResultItem[], suffix: string): ResultItem[] {
186
+ const rows: ResultItem[] = [];
187
+ for (const item of items) {
188
+ if (item.kind !== "ticker" && item.kind !== "search") continue;
189
+ if (!isExactTickerResultMatch(item, symbol)) continue;
190
+ if (!venueMatchesSuffix(item, suffix)) continue;
191
+ rows.push(item.category === "Exact Match" ? item : { ...item, category: "Exact Match" });
192
+ }
193
+ return rows;
194
+ }
195
+
170
196
  /**
171
197
  * Up to the cap, in this order: the first exact row of each security, the
172
198
  * exact symbol's other exchanges, then looser hits. The rows keep their
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import type { AppState } from "../../../../state/app/context";
3
3
  import type { DataProvider } from "../../../../types/data-provider";
4
4
  import type { TickerSearchCandidate } from "../../../../tickers/search";
5
- import { searchTickerCandidates } from "../../../../tickers/search";
5
+ import { searchTickerCandidates, symbolSearchQuery } from "../../../../tickers/search";
6
6
  import {
7
7
  COMMAND_BAR_TICKER_SEARCH_LIMIT,
8
8
  mergeTickerSearchResultItems,
@@ -64,7 +64,7 @@ export function useTickerSearchRouteResults(options: {
64
64
  return;
65
65
  }
66
66
 
67
- const searchQuery = routeQuery.trim();
67
+ const searchQuery = symbolSearchQuery(routeQuery);
68
68
  if (!searchQuery) {
69
69
  searchRequestIdRef.current += 1;
70
70
  setTickerSearchPending(false);