gloomberb 0.9.8 → 0.9.9

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 (177) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/index.test.ts +3 -3
  3. package/src/api-client/index.ts +0 -6
  4. package/src/api-client/request.test.ts +63 -0
  5. package/src/api-client/request.ts +53 -5
  6. package/src/api-client/types.ts +0 -2
  7. package/src/app/pane-runtime/layout-placement.ts +0 -1
  8. package/src/brokers/account-cache.ts +2 -10
  9. package/src/brokers/broker-portfolio-sync.ts +0 -1
  10. package/src/brokers/broker-ticker-sync.ts +20 -16
  11. package/src/brokers/remote-broker-adapter.ts +3 -0
  12. package/src/capabilities/registry.test.ts +25 -0
  13. package/src/capabilities/registry.ts +22 -5
  14. package/src/cli/commands/automation.ts +1 -3
  15. package/src/cli/commands/command-utils.ts +0 -7
  16. package/src/cli/commands/overview.ts +2 -2
  17. package/src/cli/commands/plugins.ts +34 -17
  18. package/src/cli/index.ts +2 -2
  19. package/src/cli.test.ts +0 -157
  20. package/src/components/chart/comparison/renderer.ts +0 -1
  21. package/src/components/chart/comparison-stock-chart/render-output.ts +3 -8
  22. package/src/components/chart/comparison-stock-chart/view.tsx +0 -2
  23. package/src/components/chart/core/index.test.ts +0 -24
  24. package/src/components/chart/core/renderer.ts +0 -4
  25. package/src/components/chart/core/resolution.test.ts +0 -17
  26. package/src/components/chart/core/resolution.ts +0 -26
  27. package/src/components/chart/core/scroll.test.ts +0 -6
  28. package/src/components/chart/core/scroll.ts +0 -22
  29. package/src/components/chart/core/viewport.test.ts +2 -23
  30. package/src/components/chart/core/viewport.ts +0 -27
  31. package/src/components/chart/market-session.ts +1 -1
  32. package/src/components/chart/native/surface/hooks.ts +10 -3
  33. package/src/components/chart/stock-chart/data-runtime.ts +0 -1
  34. package/src/components/chart/stock-chart/viewport/effects.ts +0 -3
  35. package/src/components/command-bar/helpers.test.ts +1 -80
  36. package/src/components/command-bar/helpers.ts +1 -36
  37. package/src/components/command-bar/pane-settings.ts +1 -1
  38. package/src/components/command-bar/panel/runtime.ts +0 -2
  39. package/src/components/command-bar/routes/ticker-search/results.ts +0 -11
  40. package/src/components/command-bar/surface/index.test.tsx +0 -16
  41. package/src/components/command-bar/surface/index.tsx +0 -1
  42. package/src/components/feed-data-table/stack-view.test.tsx +36 -130
  43. package/src/components/feed-data-table/stack-view.tsx +0 -1
  44. package/src/components/layout/shell/index.test.tsx +2 -113
  45. package/src/components/layout/shell/index.tsx +0 -1
  46. package/src/components/layout/shell/menu.ts +0 -1
  47. package/src/components/layout/shell/terminal-pointer-runtime.ts +0 -1
  48. package/src/components/onboarding/onboarding-wizard.tsx +3 -3
  49. package/src/components/onboarding/wizard-keyboard.ts +2 -23
  50. package/src/components/price-selector-dialog.tsx +3 -3
  51. package/src/components/ticker/badge/text.tsx +0 -1
  52. package/src/components/ui/page-stack-view.tsx +1 -1
  53. package/src/components/ui/ui.test.tsx +0 -78
  54. package/src/core/state/app/layout-reducer.ts +0 -1
  55. package/src/data/config/layout.ts +3 -3
  56. package/src/data/resource-store.ts +6 -0
  57. package/src/data/sqlite/cache.test.ts +5 -0
  58. package/src/data/ticker-store.ts +0 -7
  59. package/src/market-data/coordinator/auxiliary.ts +1 -44
  60. package/src/market-data/coordinator/entries.ts +0 -1
  61. package/src/market-data/coordinator/index.test.ts +0 -10
  62. package/src/market-data/coordinator/index.ts +1 -18
  63. package/src/market-data/coordinator/quotes.ts +0 -6
  64. package/src/market-data/coordinator/subscriptions.test.ts +1 -0
  65. package/src/market-data/market/status.ts +19 -1
  66. package/src/market-data/query-store.ts +0 -4
  67. package/src/market-data/quotes/resolution.ts +2 -2
  68. package/src/market-data/request-types.ts +0 -5
  69. package/src/market-data/result-types.ts +0 -11
  70. package/src/market-data/selectors.ts +1 -5
  71. package/src/news/aggregator.ts +5 -1
  72. package/src/pane-settings.test.ts +0 -18
  73. package/src/plugins/builtin/account-management/form-components.tsx +0 -120
  74. package/src/plugins/builtin/account-management/model.ts +3 -27
  75. package/src/plugins/builtin/broker-manager/index.test.tsx +0 -32
  76. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  77. package/src/plugins/builtin/buildout/detail/satellite-images.tsx +1 -1
  78. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -1
  79. package/src/plugins/builtin/buildout/format.ts +2 -11
  80. package/src/plugins/builtin/chat/chat.test.tsx +2 -1
  81. package/src/plugins/builtin/chat/content/index.tsx +1 -2
  82. package/src/plugins/builtin/chat/content/transcript.tsx +0 -1
  83. package/src/plugins/builtin/chat/controller/index.ts +0 -10
  84. package/src/plugins/builtin/chat/controller.test.ts +5 -5
  85. package/src/plugins/builtin/chat/sidebar.test.tsx +15 -5
  86. package/src/plugins/builtin/chat/test-harness.tsx +2 -2
  87. package/src/plugins/builtin/cloud/plugin.tsx +1 -1
  88. package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -1
  89. package/src/plugins/builtin/congress-trades/model.ts +2 -7
  90. package/src/plugins/builtin/congress-trades/pane.tsx +0 -1
  91. package/src/plugins/builtin/earnings/index.tsx +0 -9
  92. package/src/plugins/builtin/econ/index.tsx +1 -5
  93. package/src/plugins/builtin/help/index.tsx +2 -2
  94. package/src/plugins/builtin/holders/pane.tsx +0 -5
  95. package/src/plugins/builtin/insider/index.tsx +1 -8
  96. package/src/plugins/builtin/kelly-sizer/index.test.tsx +0 -26
  97. package/src/plugins/builtin/kelly-sizer/index.tsx +0 -2
  98. package/src/plugins/builtin/market-movers/index.tsx +0 -2
  99. package/src/plugins/builtin/news/index.tsx +0 -1
  100. package/src/plugins/builtin/news/wire/news/table.tsx +0 -11
  101. package/src/plugins/builtin/options/view.test.tsx +1 -38
  102. package/src/plugins/builtin/plugin-groups.ts +17 -3
  103. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -8
  104. package/src/plugins/builtin/portfolio-list/index.test.ts +1 -24
  105. package/src/plugins/builtin/portfolio-list/metrics.test.ts +44 -7
  106. package/src/plugins/builtin/sec/filing-documents.ts +0 -11
  107. package/src/plugins/builtin/sec/index.tsx +0 -2
  108. package/src/plugins/builtin/substack/article-detail.tsx +1 -1
  109. package/src/plugins/builtin/thirteenf/api.ts +0 -15
  110. package/src/plugins/builtin/thirteenf/pane.tsx +1 -3
  111. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +1 -1
  112. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +0 -7
  113. package/src/plugins/builtin/ticker-detail/quote-monitor/index.test.tsx +5 -34
  114. package/src/plugins/builtin/world-indices/index.tsx +0 -4
  115. package/src/plugins/builtin/world-indices/model.ts +0 -7
  116. package/src/plugins/ibkr/account-cache.ts +1 -1
  117. package/src/plugins/ibkr/portfolio-performance.ts +1 -1
  118. package/src/plugins/ibkr/trade/utils.ts +1 -7
  119. package/src/plugins/loader.ts +2 -1
  120. package/src/plugins/prediction-markets/detail.test.tsx +0 -34
  121. package/src/plugins/registry/contributions.ts +22 -11
  122. package/src/plugins/registry/index.test.ts +44 -0
  123. package/src/plugins/registry/index.ts +44 -26
  124. package/src/remote/controller.ts +1 -7
  125. package/src/remote/json-patch.ts +15 -1
  126. package/src/remote/server.ts +16 -6
  127. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
  128. package/src/renderers/electrobun/bun/core-capabilities.ts +2 -0
  129. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +14 -0
  130. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +8 -0
  131. package/src/renderers/electrobun/bun/desktop/initialization.ts +2 -1
  132. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  133. package/src/renderers/electrobun/view/data-table/dom.ts +0 -1
  134. package/src/renderers/electrobun/view/host/mouse.ts +0 -11
  135. package/src/renderers/electrobun/view/http-fetch.ts +15 -2
  136. package/src/renderers/electrobun/view/remote/asset-data-client.ts +9 -18
  137. package/src/renderers/electrobun/view/remote/capability-invoker.test.ts +50 -0
  138. package/src/renderers/electrobun/view/remote/capability-invoker.ts +36 -0
  139. package/src/renderers/opentui/host.tsx +11 -3
  140. package/src/renderers/opentui/input-host.tsx +5 -1
  141. package/src/sources/gloomberb-cloud/index.test.ts +0 -72
  142. package/src/sources/gloomberb-cloud/index.ts +1 -30
  143. package/src/sources/gloomberb-cloud/news.ts +0 -12
  144. package/src/sources/provider-router/financial-routes.ts +25 -3
  145. package/src/sources/provider-router/index.test.ts +167 -0
  146. package/src/sources/provider-router/search.ts +5 -0
  147. package/src/sources/provider-router/supplemental.ts +4 -3
  148. package/src/sources/yahoo-finance/history.ts +5 -1
  149. package/src/sources/yahoo-finance/options.ts +59 -14
  150. package/src/sources/yahoo-finance/quote-summary.ts +4 -1
  151. package/src/sources/yahoo-finance.test.ts +40 -21
  152. package/src/sources/yahoo-finance.ts +13 -8
  153. package/src/state/app/context/index.test.tsx +0 -81
  154. package/src/state/app/context/selector.test.tsx +6 -2
  155. package/src/sync/controller.test.ts +31 -0
  156. package/src/sync/controller.ts +21 -17
  157. package/src/test-support/data-provider.ts +0 -1
  158. package/src/ui/host.tsx +2 -1
  159. package/src/ui/index.tsx +1 -0
  160. package/src/updater.test.ts +0 -21
  161. package/src/updater.ts +20 -16
  162. package/src/utils/async-deadline.ts +26 -0
  163. package/src/utils/selection-clipboard.ts +2 -2
  164. package/src/version.ts +1 -1
  165. package/src/components/layout/pane/header.test.tsx +0 -39
  166. package/src/plugins/builtin/chat/channels.test.ts +0 -28
  167. package/src/plugins/builtin/chat/index.tsx +0 -4
  168. package/src/plugins/builtin/cloud-tweets/index.ts +0 -1
  169. package/src/plugins/builtin/earnings/index.test.ts +0 -12
  170. package/src/plugins/builtin/help/index.test.tsx +0 -242
  171. package/src/plugins/builtin/substack/article-detail.test.tsx +0 -10
  172. package/src/renderers/electrobun/bun/application-menu/index.test.ts +0 -21
  173. package/src/renderers/electrobun/bun/window/focus.test.ts +0 -35
  174. package/src/renderers/electrobun/view/host/mouse.test.ts +0 -37
  175. package/src/renderers/electrobun/view/host/text.test.ts +0 -22
  176. package/src/renderers/opentui/dialog-host.test.tsx +0 -44
  177. /package/src/{plugins/ibkr → utils}/hash.ts +0 -0
@@ -1,5 +1,5 @@
1
1
  import type { MarketDataRequestContext } from "../../types/data-provider";
2
- import type { OptionContract, OptionsChain, Quote } from "../../types/financials";
2
+ import type { MarketState, OptionContract, OptionsChain, Quote } from "../../types/financials";
3
3
  import { parseOptionSymbol } from "../../utils/options";
4
4
  import { getYahooSymbolsToTry } from "./symbols";
5
5
 
@@ -14,16 +14,40 @@ interface LoadYahooOptionsChainOptions {
14
14
 
15
15
  interface GetYahooOptionQuoteOptions {
16
16
  context?: MarketDataRequestContext;
17
- getOptionsChain: (
17
+ getOptionsChainResult: (
18
18
  ticker: string,
19
19
  exchange?: string,
20
20
  expirationDate?: number,
21
21
  context?: MarketDataRequestContext,
22
- ) => Promise<OptionsChain>;
22
+ ) => Promise<YahooOptionsChainResult>;
23
23
  providerId: string;
24
24
  ticker: string;
25
25
  }
26
26
 
27
+ export interface YahooOptionsChainResult {
28
+ chain: OptionsChain;
29
+ underlyingMarketState?: MarketState;
30
+ }
31
+
32
+ const MARKET_STATES = new Set<MarketState>([
33
+ "PRE",
34
+ "REGULAR",
35
+ "POST",
36
+ "PREPRE",
37
+ "POSTPOST",
38
+ "CLOSED",
39
+ ]);
40
+
41
+ function normalizeYahooMarketState(value: unknown): MarketState | undefined {
42
+ const normalized = typeof value === "string" ? value.toUpperCase() as MarketState : undefined;
43
+ return normalized && MARKET_STATES.has(normalized) ? normalized : undefined;
44
+ }
45
+
46
+ function deriveOptionMarketState(underlyingMarketState?: MarketState): MarketState | undefined {
47
+ if (!underlyingMarketState) return undefined;
48
+ return underlyingMarketState === "REGULAR" ? "REGULAR" : "CLOSED";
49
+ }
50
+
27
51
  function mapYahooOptionContract(raw: Record<string, any>): OptionContract {
28
52
  return {
29
53
  contractSymbol: raw.contractSymbol ?? "",
@@ -43,12 +67,12 @@ function mapYahooOptionContract(raw: Record<string, any>): OptionContract {
43
67
  };
44
68
  }
45
69
 
46
- export async function loadYahooOptionsChain({
70
+ export async function loadYahooOptionsChainResult({
47
71
  exchange,
48
72
  expirationDate,
49
73
  fetchJsonWithCrumb,
50
74
  ticker,
51
- }: LoadYahooOptionsChainOptions): Promise<OptionsChain> {
75
+ }: LoadYahooOptionsChainOptions): Promise<YahooOptionsChainResult> {
52
76
  const symbolsToTry = getYahooSymbolsToTry(ticker, exchange);
53
77
  let lastError: any;
54
78
 
@@ -62,6 +86,7 @@ export async function loadYahooOptionsChain({
62
86
  result?: Array<{
63
87
  underlyingSymbol?: string;
64
88
  expirationDates?: number[];
89
+ quote?: { marketState?: unknown };
65
90
  options?: Array<{
66
91
  calls?: Array<Record<string, any>>;
67
92
  puts?: Array<Record<string, any>>;
@@ -75,10 +100,13 @@ export async function loadYahooOptionsChain({
75
100
 
76
101
  const opts = result.options?.[0];
77
102
  return {
78
- underlyingSymbol: result.underlyingSymbol ?? symbol,
79
- expirationDates: result.expirationDates ?? [],
80
- calls: (opts?.calls ?? []).map((contract) => mapYahooOptionContract(contract)),
81
- puts: (opts?.puts ?? []).map((contract) => mapYahooOptionContract(contract)),
103
+ chain: {
104
+ underlyingSymbol: result.underlyingSymbol ?? symbol,
105
+ expirationDates: result.expirationDates ?? [],
106
+ calls: (opts?.calls ?? []).map((contract) => mapYahooOptionContract(contract)),
107
+ puts: (opts?.puts ?? []).map((contract) => mapYahooOptionContract(contract)),
108
+ },
109
+ underlyingMarketState: normalizeYahooMarketState(result.quote?.marketState),
82
110
  };
83
111
  } catch (err) {
84
112
  lastError = err;
@@ -87,15 +115,26 @@ export async function loadYahooOptionsChain({
87
115
  throw lastError || new Error(`No options chain for ${ticker}`);
88
116
  }
89
117
 
118
+ export async function loadYahooOptionsChain(
119
+ options: LoadYahooOptionsChainOptions,
120
+ ): Promise<OptionsChain> {
121
+ return (await loadYahooOptionsChainResult(options)).chain;
122
+ }
123
+
90
124
  export async function getYahooOptionQuote({
91
125
  context,
92
- getOptionsChain,
126
+ getOptionsChainResult,
93
127
  providerId,
94
128
  ticker,
95
129
  }: GetYahooOptionQuoteOptions): Promise<Quote> {
96
130
  const parsed = parseOptionSymbol(ticker);
97
131
  if (!parsed) throw new Error(`Unsupported option symbol ${ticker}`);
98
- const chain = await getOptionsChain(parsed.underlying, "", parsed.expTs, context);
132
+ const { chain, underlyingMarketState } = await getOptionsChainResult(
133
+ parsed.underlying,
134
+ "",
135
+ parsed.expTs,
136
+ context,
137
+ );
99
138
  const contracts = parsed.side === "C" ? chain.calls : chain.puts;
100
139
  const contract = contracts.find((candidate) =>
101
140
  Math.abs(candidate.strike - parsed.strike) < 0.001 &&
@@ -110,6 +149,12 @@ export async function getYahooOptionQuote({
110
149
  : contract.ask > 0
111
150
  ? contract.ask
112
151
  : undefined;
152
+ const marketState = deriveOptionMarketState(underlyingMarketState);
153
+ const lastUpdated = mark != null
154
+ ? Date.now()
155
+ : contract.lastTradeDate > 0
156
+ ? contract.lastTradeDate * 1000
157
+ : Date.now();
113
158
  return {
114
159
  symbol: ticker,
115
160
  providerId,
@@ -122,13 +167,13 @@ export async function getYahooOptionQuote({
122
167
  ask: contract.ask,
123
168
  mark,
124
169
  name: contract.contractSymbol,
125
- lastUpdated: contract.lastTradeDate > 0
126
- ? contract.lastTradeDate * 1000
127
- : Date.now(),
170
+ lastUpdated,
128
171
  exchangeName: "OPTIONS",
129
172
  fullExchangeName: "OPTIONS",
130
173
  listingExchangeName: "OPTIONS",
131
174
  listingExchangeFullName: "OPTIONS",
175
+ marketState,
176
+ sessionConfidence: marketState ? "derived" : "unknown",
132
177
  dataSource: "delayed",
133
178
  };
134
179
  }
@@ -29,7 +29,10 @@ interface YahooQuoteSummaryOptions {
29
29
  }
30
30
 
31
31
  interface YahooCorporateActionsOptions extends YahooQuoteSummaryOptions {
32
- fetchChart: (symbol: string, range: string, interval?: string) => Promise<ChartResult>;
32
+ fetchChart: (symbol: string, range: string, interval?: string) => Promise<{
33
+ meta: NonNullable<ChartResult["meta"]>;
34
+ events?: ChartResult["events"];
35
+ }>;
33
36
  }
34
37
 
35
38
  export async function loadYahooHolders({
@@ -135,28 +135,38 @@ describe("YahooFinanceClient exchange aliases", () => {
135
135
 
136
136
  test("maps IBKR option symbols to Yahoo option quote marks", async () => {
137
137
  const provider = new YahooFinanceClient() as any;
138
- provider.getOptionsChain = async (ticker: string, _exchange: string, expirationDate: number) => ({
139
- underlyingSymbol: ticker,
140
- expirationDates: [expirationDate],
141
- calls: [{
142
- contractSymbol: "AMD270917C00230000",
143
- strike: 230,
144
- currency: "USD",
145
- lastPrice: 251,
146
- change: 1.5,
147
- percentChange: 0.6,
148
- volume: 10,
149
- openInterest: 20,
150
- bid: 250.25,
151
- ask: 254.5,
152
- impliedVolatility: 0.4,
153
- inTheMoney: false,
154
- expiration: expirationDate,
155
- lastTradeDate: 1_800_000_000,
156
- }],
157
- puts: [],
138
+ const expiration = 1_821_139_200;
139
+ let underlyingMarketState = "POST";
140
+ provider.http.fetchJsonWithCrumb = async () => ({
141
+ optionChain: {
142
+ result: [{
143
+ underlyingSymbol: "AMD",
144
+ expirationDates: [expiration],
145
+ quote: { marketState: underlyingMarketState },
146
+ options: [{
147
+ calls: [{
148
+ contractSymbol: "AMD270917C00230000",
149
+ strike: 230,
150
+ currency: "USD",
151
+ lastPrice: 251,
152
+ change: 1.5,
153
+ percentChange: 0.6,
154
+ volume: 10,
155
+ openInterest: 20,
156
+ bid: 250.25,
157
+ ask: 254.5,
158
+ impliedVolatility: 0.4,
159
+ inTheMoney: false,
160
+ expiration,
161
+ lastTradeDate: 1_800_000_000,
162
+ }],
163
+ puts: [],
164
+ }],
165
+ }],
166
+ },
158
167
  });
159
168
 
169
+ const requestedAt = Date.now();
160
170
  const quote = await provider.getQuote("AMD 270917C00230000");
161
171
 
162
172
  expect(quote).toMatchObject({
@@ -166,8 +176,17 @@ describe("YahooFinanceClient exchange aliases", () => {
166
176
  bid: 250.25,
167
177
  ask: 254.5,
168
178
  providerId: "yahoo",
179
+ marketState: "CLOSED",
180
+ sessionConfidence: "derived",
181
+ });
182
+ expect(quote.lastUpdated).toBeGreaterThanOrEqual(requestedAt);
183
+
184
+ underlyingMarketState = "REGULAR";
185
+ const regularQuote = await provider.getQuote("AMD 270917C00230000");
186
+ expect(regularQuote).toMatchObject({
187
+ marketState: "REGULAR",
188
+ sessionConfidence: "derived",
169
189
  });
170
- expect(quote.lastUpdated).toBe(1_800_000_000_000);
171
190
  });
172
191
 
173
192
  test("preserves analyst rating price targets from upgrade history", async () => {
@@ -31,6 +31,7 @@ import {
31
31
  import {
32
32
  getYahooOptionQuote,
33
33
  loadYahooOptionsChain,
34
+ loadYahooOptionsChainResult,
34
35
  } from "./yahoo-finance/options";
35
36
  import {
36
37
  loadYahooAnalystResearch,
@@ -155,8 +156,13 @@ export class YahooFinanceClient implements DataProvider {
155
156
  if (parseOptionSymbol(ticker)) {
156
157
  return getYahooOptionQuote({
157
158
  context,
158
- getOptionsChain: (underlying, requestExchange, expirationDate, requestContext) => (
159
- this.getOptionsChain(underlying, requestExchange, expirationDate, requestContext)
159
+ getOptionsChainResult: (underlying, requestExchange, expirationDate) => (
160
+ loadYahooOptionsChainResult({
161
+ exchange: requestExchange ?? "",
162
+ expirationDate,
163
+ fetchJsonWithCrumb: (url) => this.http.fetchJsonWithCrumb(url),
164
+ ticker: underlying,
165
+ })
160
166
  ),
161
167
  providerId: this.id,
162
168
  ticker,
@@ -189,13 +195,12 @@ export class YahooFinanceClient implements DataProvider {
189
195
  fromCurrency = normalized;
190
196
  if (fromCurrency === "USD") return 1;
191
197
 
192
- try {
193
- const { meta, history } = await this.fetchChart(`${fromCurrency}USD=X`, "1mo");
194
- const rate = meta.regularMarketPrice || history[history.length - 1]?.close || 1;
195
- return rate;
196
- } catch {
197
- return 1;
198
+ const { meta, history } = await this.fetchChart(`${fromCurrency}USD=X`, "1mo");
199
+ const rate = meta.regularMarketPrice ?? history[history.length - 1]?.close;
200
+ if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) {
201
+ throw new Error(`No exchange rate data for ${fromCurrency}/USD`);
198
202
  }
203
+ return rate;
199
204
  }
200
205
 
201
206
  /** Search for a ticker by name/symbol - uses direct fetch (no retry) for speed */
@@ -4,87 +4,6 @@ import { appReducer, createInitialState } from "./index";
4
4
  import { removePane } from "../../../plugins/pane-manager";
5
5
 
6
6
  describe("appReducer command bar state", () => {
7
- test("opens the command bar with an explicit query", () => {
8
- const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
9
- const next = appReducer(state, { type: "SET_COMMAND_BAR", open: true, query: "Sync Broker Account" });
10
-
11
- expect(next.commandBarOpen).toBe(true);
12
- expect(next.commandBarQuery).toBe("Sync Broker Account");
13
- });
14
-
15
- test("clears the command bar query when closing", () => {
16
- const state = appReducer(
17
- createInitialState(createDefaultConfig("/tmp/gloomberb-test")),
18
- { type: "SET_COMMAND_BAR", open: true, query: "Disconnect Broker Account" },
19
- );
20
- const next = appReducer(state, { type: "SET_COMMAND_BAR", open: false });
21
-
22
- expect(next.commandBarOpen).toBe(false);
23
- expect(next.commandBarQuery).toBe("");
24
- });
25
-
26
- test("stores and clears command bar launch requests", () => {
27
- const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
28
- const opened = appReducer(initial, {
29
- type: "SET_COMMAND_BAR",
30
- open: true,
31
- launch: { kind: "plugin-command", commandId: "set-alert" },
32
- });
33
- const relaunched = appReducer(opened, {
34
- type: "SET_COMMAND_BAR",
35
- open: true,
36
- launch: { kind: "plugin-command", commandId: "set-alert" },
37
- });
38
- const closed = appReducer(relaunched, { type: "SET_COMMAND_BAR", open: false });
39
-
40
- expect(opened.commandBarOpen).toBe(true);
41
- expect(opened.commandBarLaunchRequest).toEqual({
42
- kind: "plugin-command",
43
- commandId: "set-alert",
44
- sequence: 1,
45
- });
46
- expect(relaunched.commandBarLaunchRequest?.sequence).toBe(2);
47
- expect(closed.commandBarLaunchRequest).toBeNull();
48
- });
49
-
50
- test("stores ticker search command bar launch requests", () => {
51
- const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
52
- const opened = appReducer(initial, {
53
- type: "SET_COMMAND_BAR",
54
- open: true,
55
- launch: { kind: "ticker-search", query: "" },
56
- });
57
-
58
- expect(opened.commandBarOpen).toBe(true);
59
- expect(opened.commandBarLaunchRequest).toEqual({
60
- kind: "ticker-search",
61
- query: "",
62
- sequence: 1,
63
- });
64
- });
65
-
66
- test("toggle opens blank and clears on close", () => {
67
- const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
68
- const opened = appReducer(initial, { type: "TOGGLE_COMMAND_BAR" });
69
- const closed = appReducer({ ...opened, commandBarQuery: "stale" }, { type: "TOGGLE_COMMAND_BAR" });
70
-
71
- expect(opened.commandBarOpen).toBe(true);
72
- expect(opened.commandBarQuery).toBe("");
73
- expect(closed.commandBarOpen).toBe(false);
74
- expect(closed.commandBarQuery).toBe("");
75
- });
76
-
77
- test("updates the live command bar query without closing the overlay", () => {
78
- const state = appReducer(
79
- createInitialState(createDefaultConfig("/tmp/gloomberb-test")),
80
- { type: "SET_COMMAND_BAR", open: true, query: "PL " },
81
- );
82
- const next = appReducer(state, { type: "SET_COMMAND_BAR_QUERY", query: "PL notes" });
83
-
84
- expect(next.commandBarOpen).toBe(true);
85
- expect(next.commandBarQuery).toBe("PL notes");
86
- });
87
-
88
7
  test("re-shows the gridlock tip and refreshes its sequence on every trigger", () => {
89
8
  const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
90
9
 
@@ -82,8 +82,12 @@ function createDesktopBridge(
82
82
  }
83
83
 
84
84
  describe("pane selectors", () => {
85
- afterEach(() => {
86
- testSetup?.renderer.destroy();
85
+ afterEach(async () => {
86
+ if (testSetup) {
87
+ await act(async () => {
88
+ testSetup!.renderer.destroy();
89
+ });
90
+ }
87
91
  testSetup = undefined;
88
92
  capturedDispatch = null;
89
93
  applyTheme("amber");
@@ -0,0 +1,31 @@
1
+ import { expect, test } from "bun:test";
2
+ import type { AppState } from "../core/state/app/state";
3
+ import type { TickerRepository } from "../data/ticker-repository";
4
+ import { CloudSyncController } from "./controller";
5
+ import type { SyncTransport } from "./types";
6
+
7
+ test("does not push local state when the initial pull fails", async () => {
8
+ let pushes = 0;
9
+ const transport: SyncTransport = {
10
+ id: "failing-pull",
11
+ isAvailable: () => true,
12
+ pullSnapshot: () => Promise.reject(new Error("pull failed")),
13
+ pushSnapshot: async () => {
14
+ pushes += 1;
15
+ return { revision: 1, updatedAt: new Date().toISOString() };
16
+ },
17
+ };
18
+ const controller = new CloudSyncController();
19
+ controller.setRuntime({
20
+ state: {} as AppState,
21
+ dispatch: () => {},
22
+ tickerRepository: {} as TickerRepository,
23
+ getContributors: () => [],
24
+ getTransport: () => ({ pluginId: "test", transport }),
25
+ });
26
+
27
+ await controller.requestSync({ force: true });
28
+
29
+ expect(pushes).toBe(0);
30
+ expect(controller.getStatus()).toMatchObject({ phase: "error", error: "pull failed" });
31
+ });
@@ -1,6 +1,7 @@
1
1
  import type { Dispatch } from "react";
2
2
  import type { AppAction, AppState } from "../core/state/app/state";
3
3
  import type { TickerRepository } from "../data/ticker-repository";
4
+ import { stableStringify } from "../remote/revision";
4
5
  import {
5
6
  SYNC_SNAPSHOT_SCHEMA_VERSION,
6
7
  type RegisteredSyncContributor,
@@ -37,15 +38,6 @@ function nowIso(): string {
37
38
  return new Date().toISOString();
38
39
  }
39
40
 
40
- function stableStringify(value: unknown): string {
41
- if (value == null || typeof value !== "object") return JSON.stringify(value);
42
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
43
- const entries = Object.entries(value as Record<string, unknown>)
44
- .sort(([left], [right]) => left.localeCompare(right))
45
- .map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`);
46
- return `{${entries.join(",")}}`;
47
- }
48
-
49
41
  function resolveClientId(): string {
50
42
  const storage = globalThis.localStorage;
51
43
  const existing = storage?.getItem(CLIENT_ID_STORAGE_KEY);
@@ -55,13 +47,14 @@ function resolveClientId(): string {
55
47
  return id;
56
48
  }
57
49
 
58
- class CloudSyncController {
50
+ export class CloudSyncController {
59
51
  private runtime: SyncRuntime | null = null;
60
52
  private contributors = new Map<string, RegisteredSyncContributor>();
61
53
  private transports = new Map<string, RegisteredSyncTransport>();
62
54
  private listeners = new Set<() => void>();
63
55
  private pushTimer: ReturnType<typeof setTimeout> | null = null;
64
56
  private inFlight: Promise<void> | null = null;
57
+ private syncQueued = false;
65
58
  private clientId: string | null = null;
66
59
  private lastSignature: string | null = null;
67
60
  private lastPulledAtMs = 0;
@@ -116,6 +109,7 @@ class CloudSyncController {
116
109
  this.runtime = null;
117
110
  if (this.pushTimer) clearTimeout(this.pushTimer);
118
111
  this.pushTimer = null;
112
+ this.syncQueued = false;
119
113
  }
120
114
 
121
115
  subscribe(listener: () => void): () => void {
@@ -141,14 +135,21 @@ class CloudSyncController {
141
135
  }
142
136
  if (this.pushTimer) clearTimeout(this.pushTimer);
143
137
  this.pushTimer = setTimeout(() => {
138
+ this.pushTimer = null;
144
139
  void this.requestSync({ reason });
145
140
  }, PUSH_DEBOUNCE_MS);
146
141
  }
147
142
 
148
143
  async requestSync(options: { reason?: string; force?: boolean } = {}): Promise<void> {
149
- if (this.inFlight) return this.inFlight;
150
- const run = this.syncOnce(options).finally(() => {
144
+ if (this.inFlight) {
145
+ this.syncQueued = true;
146
+ return this.inFlight;
147
+ }
148
+ const run = this.syncOnce(options).finally(async () => {
151
149
  this.inFlight = null;
150
+ if (!this.syncQueued) return;
151
+ this.syncQueued = false;
152
+ await this.requestSync({ reason: "queued-state-change" });
152
153
  });
153
154
  this.inFlight = run;
154
155
  return run;
@@ -164,8 +165,9 @@ class CloudSyncController {
164
165
  }
165
166
  const currentMs = Date.now();
166
167
  if (!options.force && currentMs - this.lastPulledAtMs < PULL_MIN_INTERVAL_MS) return;
167
- this.lastPulledAtMs = currentMs;
168
- await this.runPull(runtime, transport.transport);
168
+ if (await this.runPull(runtime, transport.transport)) {
169
+ this.lastPulledAtMs = currentMs;
170
+ }
169
171
  }
170
172
 
171
173
  private async syncOnce(options: { reason?: string; force?: boolean }): Promise<void> {
@@ -178,7 +180,7 @@ class CloudSyncController {
178
180
  }
179
181
  const transport = transportRegistration.transport;
180
182
  if (!this.hasPulledForTransport.has(transport.id)) {
181
- await this.runPull(runtime, transport);
183
+ if (!await this.runPull(runtime, transport)) return;
182
184
  this.hasPulledForTransport.add(transport.id);
183
185
  }
184
186
 
@@ -206,7 +208,7 @@ class CloudSyncController {
206
208
  }
207
209
  }
208
210
 
209
- private async runPull(runtime: SyncRuntime, transport: SyncTransport): Promise<void> {
211
+ private async runPull(runtime: SyncRuntime, transport: SyncTransport): Promise<boolean> {
210
212
  this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
211
213
  try {
212
214
  const response = await transport.pullSnapshot();
@@ -218,7 +220,7 @@ class CloudSyncController {
218
220
  lastSyncAt: response.updatedAt,
219
221
  error: null,
220
222
  });
221
- if (!response.snapshot) return;
223
+ if (!response.snapshot) return true;
222
224
  for (const entry of runtime.getContributors()) {
223
225
  const payload = response.snapshot.contributors[entry.contributor.id]?.payload;
224
226
  if (payload === undefined || !entry.contributor.apply) continue;
@@ -229,12 +231,14 @@ class CloudSyncController {
229
231
  tickerRepository: runtime.tickerRepository,
230
232
  });
231
233
  }
234
+ return true;
232
235
  } catch (error) {
233
236
  this.setStatus({
234
237
  phase: "error",
235
238
  transportId: transport.id,
236
239
  error: error instanceof Error ? error.message : String(error),
237
240
  });
241
+ return false;
238
242
  }
239
243
  }
240
244
 
@@ -14,7 +14,6 @@ export function createTestDataProvider(overrides: Partial<DataProvider> = {}): D
14
14
  getQuote: async () => unused<Quote>("getQuote"),
15
15
  getExchangeRate: async () => 1,
16
16
  search: async () => [] satisfies InstrumentSearchResult[],
17
- getNews: async () => [],
18
17
  getArticleSummary: async () => null,
19
18
  getPriceHistory: async () => [],
20
19
  ...overrides,
package/src/ui/host.tsx CHANGED
@@ -101,6 +101,7 @@ interface ScrollBarRenderable {
101
101
  }
102
102
 
103
103
  export interface ScrollBoxRenderable {
104
+ width?: number;
104
105
  scrollTop: number;
105
106
  scrollLeft?: number;
106
107
  scrollHeight: number;
@@ -272,7 +273,7 @@ export interface HostCheckboxProps {
272
273
  disabled?: boolean;
273
274
  active?: boolean;
274
275
  description?: string;
275
- width?: number;
276
+ width?: number | string;
276
277
  variant?: "default" | "desktop";
277
278
  }
278
279
 
package/src/ui/index.tsx CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  } from "./context-menu";
34
34
  export { contextMenuDivider } from "../types/context-menu";
35
35
  export type {
36
+ BitmapSurface,
36
37
  BoxRenderable,
37
38
  ChartSurfaceProps,
38
39
  HostCheckboxProps,
@@ -4,7 +4,6 @@ import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { gzipSync } from "zlib";
6
6
  import {
7
- canSelfUpdate,
8
7
  checkForUpdate,
9
8
  checkForUpdateDetailed,
10
9
  detectUpdateAction,
@@ -153,26 +152,6 @@ describe("resolveSelfUpdateTargetPath", () => {
153
152
  });
154
153
  });
155
154
 
156
- describe("canSelfUpdate", () => {
157
- test("returns false for manual update releases", () => {
158
- expect(canSelfUpdate({
159
- updateAction: { kind: "manual", command: "bun install -g gloomberb@latest" },
160
- })).toBe(false);
161
- });
162
-
163
- test("returns true for self-update releases", () => {
164
- expect(canSelfUpdate({
165
- updateAction: { kind: "self" },
166
- })).toBe(true);
167
- });
168
-
169
- test("returns true for desktop update releases", () => {
170
- expect(canSelfUpdate({
171
- updateAction: { kind: "desktop" },
172
- })).toBe(true);
173
- });
174
- });
175
-
176
155
  describe("update host", () => {
177
156
  test("delegates update checks to an installed host", async () => {
178
157
  setUpdateHost({