gloomberb 0.13.2 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,6 +62,8 @@ curl -fsSL gloom.sh/install | bash
62
62
 
63
63
  Both install `Gloomberb.app` and a `gloomberb` command that runs the TUI through the app bundle, so the bundled runtime is stored once.
64
64
 
65
+ `Gloomberb.app` is Apple Silicon (arm64) only. On an Intel Mac the install script installs the standalone `gloomberb` terminal app instead, and the Homebrew cask refuses to install rather than leaving an app that cannot launch.
66
+
65
67
  Prefer a direct download?
66
68
 
67
69
  - [Download Gloomberb for Mac](https://gloom.sh/download/desktop)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/index.tsx",
@@ -56,6 +56,33 @@ export interface DesktopPaneShotApiProxy {
56
56
  sessionToken: string | null;
57
57
  }
58
58
 
59
+ /**
60
+ * The page renders panes that fetch their own data. Left alone it can only
61
+ * reach the cloud API, so a pane whose values come from a provider the router
62
+ * merges in (analyst price targets, Yahoo dividends) rendered thinner than the
63
+ * same pane in the terminal. The bridge lets the page ask the Bun process to
64
+ * run those requests through the real provider router instead.
65
+ */
66
+ export interface DesktopPaneShotBridge {
67
+ marketData(operation: string, args: unknown[]): Promise<unknown>;
68
+ httpFetch(request: DesktopPaneShotHttpRequest): Promise<DesktopPaneShotHttpResponse>;
69
+ }
70
+
71
+ export interface DesktopPaneShotHttpRequest {
72
+ url: string;
73
+ method?: string;
74
+ headers?: Record<string, string>;
75
+ body?: string;
76
+ }
77
+
78
+ export interface DesktopPaneShotHttpResponse {
79
+ status: number;
80
+ statusText: string;
81
+ headers: Record<string, string>;
82
+ setCookie: string[];
83
+ body: string;
84
+ }
85
+
59
86
  export interface DesktopPaneShotRenderedCell {
60
87
  columnId?: string;
61
88
  columnLabel: string;
@@ -109,13 +136,15 @@ const SHOT_READY_TIMEOUT_MS = 45_000;
109
136
  const CDP_CALL_TIMEOUT_MS = 10_000;
110
137
  const DEFAULT_DEVICE_SCALE_FACTOR = 2;
111
138
  const SHOT_API_PROXY_PREFIX = "/__gloom_cli_api__";
139
+ export const SHOT_MARKET_BRIDGE_PATH = "/__gloom_cli_market__";
140
+ export const SHOT_HTTP_BRIDGE_PATH = "/__gloom_cli_http__";
112
141
  const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
113
142
 
114
143
  export async function renderDesktopPaneScreenshot(
115
144
  payload: DesktopPaneShotPayload,
116
145
  outputPath: string,
117
146
  apiProxy: DesktopPaneShotApiProxy,
118
- options: { captureImage?: boolean } = {},
147
+ options: { captureImage?: boolean; bridge?: DesktopPaneShotBridge } = {},
119
148
  ): Promise<DesktopPaneShotRenderResult> {
120
149
  const tempDir = await mkdtemp(join(tmpdir(), "gloom-pane-shot-"));
121
150
  let server: ReturnType<typeof Bun.serve> | null = null;
@@ -123,7 +152,7 @@ export async function renderDesktopPaneScreenshot(
123
152
  const outdir = join(tempDir, "assets");
124
153
  await mkdir(outdir, { recursive: true });
125
154
  await buildShotPage(outdir, payload);
126
- server = serveShotPage(outdir, apiProxy);
155
+ server = serveShotPage(outdir, apiProxy, options.bridge ?? null);
127
156
  const chrome = await findChromeExecutable();
128
157
  return await capturePageScreenshot({
129
158
  chrome,
@@ -183,6 +212,7 @@ async function buildShotPage(outdir: string, payload: DesktopPaneShotPayload): P
183
212
  function serveShotPage(
184
213
  outdir: string,
185
214
  apiProxy: DesktopPaneShotApiProxy,
215
+ bridge: DesktopPaneShotBridge | null,
186
216
  ): ReturnType<typeof Bun.serve> {
187
217
  const staticRoot = resolve(outdir);
188
218
  return Bun.serve({
@@ -194,11 +224,51 @@ function serveShotPage(
194
224
  if (url.pathname.startsWith(`${SHOT_API_PROXY_PREFIX}/`)) {
195
225
  return proxyShotApiRequest(request, url, apiProxy);
196
226
  }
227
+ if (url.pathname === SHOT_MARKET_BRIDGE_PATH) {
228
+ return runShotMarketBridge(request, bridge);
229
+ }
230
+ if (url.pathname === SHOT_HTTP_BRIDGE_PATH) {
231
+ return runShotHttpBridge(request, bridge);
232
+ }
197
233
  return serveShotAsset(url, staticRoot);
198
234
  },
199
235
  });
200
236
  }
201
237
 
238
+ async function runShotMarketBridge(
239
+ request: Request,
240
+ bridge: DesktopPaneShotBridge | null,
241
+ ): Promise<Response> {
242
+ if (!bridge) return Response.json({ ok: false, error: "No market bridge" }, { status: 404 });
243
+ try {
244
+ const payload = await request.json() as { operation?: unknown; args?: unknown };
245
+ const operation = typeof payload.operation === "string" ? payload.operation : "";
246
+ const args = Array.isArray(payload.args) ? payload.args : [];
247
+ const data = await bridge.marketData(operation, args);
248
+ return Response.json({ ok: true, data });
249
+ } catch (error) {
250
+ return Response.json({ ok: false, error: errorMessage(error) });
251
+ }
252
+ }
253
+
254
+ async function runShotHttpBridge(
255
+ request: Request,
256
+ bridge: DesktopPaneShotBridge | null,
257
+ ): Promise<Response> {
258
+ if (!bridge) return Response.json({ ok: false, error: "No HTTP bridge" }, { status: 404 });
259
+ try {
260
+ const payload = await request.json() as DesktopPaneShotHttpRequest;
261
+ const data = await bridge.httpFetch(payload);
262
+ return Response.json({ ok: true, data });
263
+ } catch (error) {
264
+ return Response.json({ ok: false, error: errorMessage(error) });
265
+ }
266
+ }
267
+
268
+ function errorMessage(error: unknown): string {
269
+ return error instanceof Error ? error.message : String(error);
270
+ }
271
+
202
272
  async function serveShotAsset(url: URL, staticRoot: string): Promise<Response> {
203
273
  let pathname: string;
204
274
  try {
@@ -52,3 +52,26 @@ describe("pane function Cloud session", () => {
52
52
  }
53
53
  });
54
54
  });
55
+
56
+ describe("cloud session token from the environment", () => {
57
+ test("prefers GLOOMBERB_SESSION_TOKEN over persisted state so containers can authenticate", () => {
58
+ const context = {
59
+ persistence: {
60
+ pluginState: {
61
+ get: () => ({ value: { sessionToken: "persisted-token" } }),
62
+ },
63
+ },
64
+ } as unknown as Parameters<typeof resolvePersistedCloudSessionToken>[0];
65
+
66
+ const previous = process.env.GLOOMBERB_SESSION_TOKEN;
67
+ try {
68
+ process.env.GLOOMBERB_SESSION_TOKEN = "env-token";
69
+ expect(resolvePersistedCloudSessionToken(context)).toBe("env-token");
70
+ delete process.env.GLOOMBERB_SESSION_TOKEN;
71
+ expect(resolvePersistedCloudSessionToken(context)).toBe("persisted-token");
72
+ } finally {
73
+ if (previous === undefined) delete process.env.GLOOMBERB_SESSION_TOKEN;
74
+ else process.env.GLOOMBERB_SESSION_TOKEN = previous;
75
+ }
76
+ });
77
+ });
@@ -8,9 +8,18 @@ interface PersistedCloudSession {
8
8
  sessionToken?: unknown;
9
9
  }
10
10
 
11
+ /**
12
+ * A container has no local plugin state, so a bot or a CI job has no way to
13
+ * reach Pro gated panes. `GLOOMBERB_SESSION_TOKEN` gives headless callers the
14
+ * same session the desktop app persists after signing in, and it wins over the
15
+ * stored one so a service account can be pointed somewhere else without
16
+ * touching the user's own state.
17
+ */
11
18
  export function resolvePersistedCloudSessionToken(
12
19
  context: Pick<MarketContext, "persistence">,
13
20
  ): string | null {
21
+ const fromEnv = process.env.GLOOMBERB_SESSION_TOKEN?.trim();
22
+ if (fromEnv) return fromEnv;
14
23
  for (const key of CLOUD_SESSION_KEYS) {
15
24
  const value = context.persistence.pluginState.get<PersistedCloudSession>(
16
25
  CLOUD_PLUGIN_ID,
@@ -3,6 +3,7 @@ import type { RemoteUiNodeSnapshot } from "../../remote/types";
3
3
  import {
4
4
  chartSeriesEvidenceWithinRange,
5
5
  chartEvidenceMismatchesFor,
6
+ createDesktopShotBridge,
6
7
  isPaneScreenshotUsable,
7
8
  missingActiveTabSelections,
8
9
  resolveDesktopShotApiProxy,
@@ -94,6 +95,32 @@ describe("pane screenshot payload credentials", () => {
94
95
  });
95
96
  });
96
97
 
98
+ describe("pane screenshot market bridge", () => {
99
+ /**
100
+ * The page can only reach the cloud API on its own, and the cloud carries no
101
+ * per-rating price targets. Serving research requests from the router is what
102
+ * keeps a screenshot showing the same values as `gloomberb fn`.
103
+ */
104
+ test("answers research requests from the routed provider and refuses anything else", async () => {
105
+ const calls: Array<[string, unknown[]]> = [];
106
+ const bridge = createDesktopShotBridge({
107
+ dataProvider: {
108
+ getAnalystResearch: (...args: unknown[]) => {
109
+ calls.push(["getAnalystResearch", args]);
110
+ return Promise.resolve({ symbol: "NKE", ratings: [{ currentPriceTarget: 42 }] });
111
+ },
112
+ } as any,
113
+ });
114
+
115
+ expect(await bridge.marketData("getAnalystResearch", ["NKE", "NYSE"])).toEqual({
116
+ symbol: "NKE",
117
+ ratings: [{ currentPriceTarget: 42 }],
118
+ });
119
+ expect(calls).toEqual([["getAnalystResearch", ["NKE", "NYSE"]]]);
120
+ await expect(bridge.marketData("getOptionsChain", ["NKE"])).rejects.toThrow(/does not serve/);
121
+ });
122
+ });
123
+
97
124
  describe("pane screenshot active-state verification", () => {
98
125
  const expected: PaneScreenshotExpectedSelection[] = [
99
126
  { control: "statement", label: "Cash Flow" },
@@ -11,6 +11,9 @@ const DEFAULT_SHOT_DEVICE_SCALE_FACTOR = 2;
11
11
  import {
12
12
  renderDesktopPaneScreenshot,
13
13
  type DesktopPaneShotApiProxy,
14
+ type DesktopPaneShotBridge,
15
+ type DesktopPaneShotHttpRequest,
16
+ type DesktopPaneShotHttpResponse,
14
17
  type DesktopPaneShotIntradayHistory,
15
18
  type DesktopPaneShotPayload,
16
19
  type DesktopPaneShotRenderResult,
@@ -139,6 +142,82 @@ export function resolveDesktopShotApiProxy(
139
142
  };
140
143
  }
141
144
 
145
+ /**
146
+ * Data the page may request while it renders. The cloud API is only one of the
147
+ * router's sources, so calling it directly from the page dropped whatever
148
+ * another provider contributes: analyst rating price targets came back empty
149
+ * and corporate actions vanished whenever the cloud leg failed. Quotes are
150
+ * here for board panes that list symbols the payload was never built for.
151
+ * These run on the Bun side through the same router `gloomberb fn` uses.
152
+ */
153
+ const SHOT_BRIDGE_MARKET_OPERATIONS = new Set([
154
+ "getAnalystResearch",
155
+ "getCorporateActions",
156
+ "getEarningsCalendar",
157
+ "getHolders",
158
+ "getPriceHistory",
159
+ "getQuote",
160
+ "getQuotesBatch",
161
+ "getSecFilings",
162
+ ]);
163
+
164
+ const SHOT_BRIDGE_HTTP_TIMEOUT_MS = 20_000;
165
+
166
+ export function createDesktopShotBridge(
167
+ context: Pick<MarketContext, "dataProvider">,
168
+ ): DesktopPaneShotBridge {
169
+ return {
170
+ async marketData(operation, args) {
171
+ if (!SHOT_BRIDGE_MARKET_OPERATIONS.has(operation)) {
172
+ throw new Error(`Screenshot market bridge does not serve "${operation}".`);
173
+ }
174
+ const provider = context.dataProvider as unknown as Record<string, unknown>;
175
+ const handler = provider[operation];
176
+ if (typeof handler !== "function") {
177
+ throw new Error(`No provider available for ${operation}.`);
178
+ }
179
+ return await (handler as (...values: unknown[]) => Promise<unknown>).apply(
180
+ context.dataProvider,
181
+ args,
182
+ );
183
+ },
184
+ httpFetch: (request) => runDesktopShotHttpFetch(request),
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Panes such as DVD fetch a third-party API directly. A browser cannot: the
190
+ * request is cross-origin and dies in CORS, so the pane reported that the
191
+ * ticker pays no dividend. The desktop renderer already proxies these through
192
+ * its native half; the screenshot page gets the same treatment.
193
+ */
194
+ async function runDesktopShotHttpFetch(
195
+ request: DesktopPaneShotHttpRequest,
196
+ ): Promise<DesktopPaneShotHttpResponse> {
197
+ const target = new URL(request.url);
198
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
199
+ throw new Error(`Screenshot HTTP bridge refuses ${target.protocol} requests.`);
200
+ }
201
+ const response = await fetch(request.url, {
202
+ method: request.method ?? "GET",
203
+ headers: request.headers,
204
+ body: request.body,
205
+ redirect: "follow",
206
+ signal: AbortSignal.timeout(SHOT_BRIDGE_HTTP_TIMEOUT_MS),
207
+ });
208
+ const headers: Record<string, string> = {};
209
+ response.headers.forEach((value, name) => {
210
+ headers[name] = value;
211
+ });
212
+ return {
213
+ status: response.status,
214
+ statusText: response.statusText,
215
+ headers,
216
+ setCookie: response.headers.getSetCookie?.() ?? [],
217
+ body: await response.text(),
218
+ };
219
+ }
220
+
142
221
  /** Same reason as the valuation legs: the renderer cannot fill its own cache. */
143
222
  async function collectShotStatSeries(
144
223
  resolved: ResolvedPaneFunction,
@@ -617,7 +696,10 @@ export async function renderDesktopShot({
617
696
  scale ?? 1,
618
697
  watermark ?? null,
619
698
  );
620
- render = await renderDesktopPaneScreenshot(payload, outputPath, apiProxy, { captureImage });
699
+ render = await renderDesktopPaneScreenshot(payload, outputPath, apiProxy, {
700
+ captureImage,
701
+ bridge: createDesktopShotBridge(context),
702
+ });
621
703
  } finally {
622
704
  apiClient.setSessionToken(previousSessionToken);
623
705
  }
@@ -7,8 +7,11 @@ import { useAppSelector, usePaneInstance } from "../../../state/app/context";
7
7
  import { parseTickerListInput, formatTickerListInput } from "../../../tickers/list";
8
8
  import { useAssetData, usePluginPaneState, usePluginTickerActions } from "../../runtime";
9
9
  import { useAutoRefresh } from "../shared/auto-refresh";
10
- import type { PaneSettingsContext, PaneSettingsDef } from "../../../types/plugin";
11
- import { formatTickerListInput as formatTickers } from "../../../tickers/list";
10
+ import type {
11
+ PaneSettingsContext,
12
+ PaneSettingsDef,
13
+ PaneTemplateCreateOptions,
14
+ } from "../../../types/plugin";
12
15
  import {
13
16
  attachEarningsCalendarPersistence,
14
17
  loadEarningsCalendar,
@@ -188,6 +191,18 @@ function EarningsCalendarPane({ focused, width, height }: PaneProps) {
188
191
  );
189
192
  }
190
193
 
194
+ /** Tickers named on the command bar or the CLI win over the active collection. */
195
+ function earningsScopeSymbols(options: PaneTemplateCreateOptions | undefined): string[] {
196
+ if (options?.symbols && options.symbols.length > 0) return options.symbols;
197
+ const raw = options?.arg?.trim() ?? "";
198
+ if (!raw) return [];
199
+ try {
200
+ return parseTickerListInput(raw);
201
+ } catch {
202
+ return [];
203
+ }
204
+ }
205
+
191
206
  /** The monitor's scope lives in pane settings, so it has to be editable there too. */
192
207
  function earningsSettings(context: PaneSettingsContext): PaneSettingsDef {
193
208
  const collections = [
@@ -197,7 +212,7 @@ function earningsSettings(context: PaneSettingsContext): PaneSettingsDef {
197
212
  const symbols = scopedSymbolsFromSettings(context.settings);
198
213
  return {
199
214
  title: "Earnings Scope",
200
- values: { symbolsText: symbols.length > 0 ? formatTickers(symbols) : "" },
215
+ values: { symbolsText: symbols.length > 0 ? formatTickerListInput(symbols) : "" },
201
216
  fields: [
202
217
  {
203
218
  key: "symbolsText",
@@ -266,13 +281,22 @@ export const earningsModule: PluginModule = {
266
281
  label: "Earnings Calendar",
267
282
  description: "Upcoming earnings dates and estimates for your tickers.",
268
283
  keywords: ["earn", "earnings", "calendar", "eps", "revenue", "quarterly"],
269
- shortcut: { prefix: "ERN" },
284
+ shortcut: { prefix: "ERN", argPlaceholder: "tickers", argKind: "ticker-list" },
270
285
  headless: earningsCalendarHeadless,
271
- createInstance: (context) => ({
272
- settings: context.activeCollectionId
273
- ? { collectionId: context.activeCollectionId }
274
- : undefined,
275
- }),
286
+ // The shortcut takes tickers, so honor them the way the report does.
287
+ // Ignoring them left `ERN NKE` scoped to the active collection, which
288
+ // rendered "No tickers in scope" while the report listed NKE's earnings.
289
+ createInstance: (context, options) => {
290
+ const symbols = earningsScopeSymbols(options);
291
+ return {
292
+ title: symbols.length > 0 ? `ERN ${formatTickerListInput(symbols)}` : undefined,
293
+ settings: symbols.length > 0
294
+ ? { symbols, symbolsText: formatTickerListInput(symbols) }
295
+ : context.activeCollectionId
296
+ ? { collectionId: context.activeCollectionId }
297
+ : undefined,
298
+ };
299
+ },
276
300
  },
277
301
  {
278
302
  id: "earnings-monitor-pane",
@@ -1,6 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { TickerRecord } from "../../../types/ticker";
3
- import { groupEarningsByRelativeDate, resolveEarningsCollectionId, trackedEarningsSymbols } from "./model";
3
+ import type { PaneTemplateContext } from "../../../types/plugin";
4
+ import { earningsModule } from "./index";
5
+ import {
6
+ groupEarningsByRelativeDate,
7
+ resolveEarningsCollectionId,
8
+ scopedSymbolsFromSettings,
9
+ trackedEarningsSymbols,
10
+ } from "./model";
4
11
 
5
12
  function ticker(
6
13
  symbol: string,
@@ -22,6 +29,27 @@ function ticker(
22
29
  };
23
30
  }
24
31
 
32
+ describe("ERN pane scope", () => {
33
+ /**
34
+ * The shortcut takes tickers, but the template used to drop them and follow
35
+ * the active collection instead, so `ERN NKE` opened a pane that reported no
36
+ * tickers in scope while the same argument produced a report.
37
+ */
38
+ test("scopes the pane to the tickers the shortcut was given", async () => {
39
+ const template = earningsModule.paneTemplates?.find(
40
+ (candidate) => candidate.id === "earnings-calendar-pane",
41
+ );
42
+ const context = { activeCollectionId: "main" } as unknown as PaneTemplateContext;
43
+
44
+ const scoped = await template?.createInstance?.(context, { arg: "nke, msft" });
45
+ expect(scopedSymbolsFromSettings(scoped?.settings)).toEqual(["NKE", "MSFT"]);
46
+
47
+ const unscoped = await template?.createInstance?.(context, undefined);
48
+ expect(scopedSymbolsFromSettings(unscoped?.settings)).toEqual([]);
49
+ expect(unscoped?.settings?.collectionId).toBe("main");
50
+ });
51
+ });
52
+
25
53
  describe("trackedEarningsSymbols", () => {
26
54
  test("gives settings-less legacy panes one stable collection scope", () => {
27
55
  expect(resolveEarningsCollectionId(undefined, "main")).toBe("main");
@@ -0,0 +1,145 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { act } from "react";
3
+ import { testRender } from "../../../renderers/opentui/test-utils";
4
+ import { AppContext, PaneInstanceProvider, createInitialState } from "../../../state/app/context";
5
+ import { createTestPluginRuntime } from "../../../test-support/plugin-runtime";
6
+ import { cloneLayout, createDefaultConfig } from "../../../types/config";
7
+ import type { AnalystResearchData } from "../../../types/financials";
8
+ import type { DataProvider } from "../../../types/data-provider";
9
+ import type { TickerRecord } from "../../../types/ticker";
10
+ import { Box } from "../../../ui";
11
+ import { PluginRenderProvider } from "../../runtime";
12
+ import { AnalystResearchView } from "./analyst-pane";
13
+
14
+ const TEST_PANE_ID = "analyst-research:NKE";
15
+ const WIDTH = 96;
16
+ const HEIGHT = 20;
17
+
18
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
19
+
20
+ const research: AnalystResearchData = {
21
+ providerId: "test",
22
+ symbol: "NKE",
23
+ currency: "USD",
24
+ priceTarget: { current: 38.4, average: 50.46, low: 23, median: 46.5, high: 94, currency: "USD" },
25
+ recommendationRating: 6.1,
26
+ recommendations: [],
27
+ ratings: [
28
+ {
29
+ date: "2026-08-26",
30
+ firm: "Truist Securities",
31
+ action: "Downgrade",
32
+ current: "Hold",
33
+ prior: "Buy",
34
+ currentPriceTarget: 42,
35
+ priorPriceTarget: 47,
36
+ },
37
+ {
38
+ date: "2026-08-25",
39
+ firm: "Solo Target",
40
+ action: "Initiates",
41
+ current: "Buy",
42
+ currentPriceTarget: 61,
43
+ },
44
+ {
45
+ date: "2026-08-24",
46
+ firm: "No Target",
47
+ action: "Reiterates",
48
+ current: "Buy",
49
+ prior: "Buy",
50
+ },
51
+ ],
52
+ earningsEstimates: [],
53
+ revenueEstimates: [],
54
+ };
55
+
56
+ function makeTicker(symbol: string): TickerRecord {
57
+ return {
58
+ metadata: {
59
+ ticker: symbol,
60
+ exchange: "NYSE",
61
+ currency: "USD",
62
+ name: symbol,
63
+ portfolios: [],
64
+ watchlists: [],
65
+ positions: [],
66
+ custom: {},
67
+ tags: [],
68
+ },
69
+ };
70
+ }
71
+
72
+ function AnalystHarness({ provider }: { provider: DataProvider }) {
73
+ const config = createDefaultConfig("/tmp/gloomberb-analyst-pane-test");
74
+ config.layout = {
75
+ dockRoot: { kind: "pane", instanceId: TEST_PANE_ID },
76
+ instances: [{
77
+ instanceId: TEST_PANE_ID,
78
+ paneId: "analyst-research",
79
+ binding: { kind: "fixed", symbol: "NKE" },
80
+ }],
81
+ floating: [],
82
+ detached: [],
83
+ };
84
+ config.layouts = [{ name: "Default", layout: cloneLayout(config.layout) }];
85
+
86
+ const state = createInitialState(config);
87
+ state.focusedPaneId = TEST_PANE_ID;
88
+ state.tickers = new Map([["NKE", makeTicker("NKE")]]);
89
+
90
+ return (
91
+ <AppContext value={{ state, dispatch: () => {} }}>
92
+ <PaneInstanceProvider paneId={TEST_PANE_ID}>
93
+ <PluginRenderProvider
94
+ pluginId="ticker-research"
95
+ runtime={createTestPluginRuntime({ getMarketData: () => provider })}
96
+ >
97
+ <Box flexDirection="column" width={WIDTH} height={HEIGHT}>
98
+ <AnalystResearchView width={WIDTH} height={HEIGHT} focused />
99
+ </Box>
100
+ </PluginRenderProvider>
101
+ </PaneInstanceProvider>
102
+ </AppContext>
103
+ );
104
+ }
105
+
106
+ async function renderHarness(provider: DataProvider): Promise<void> {
107
+ await act(async () => {
108
+ testSetup = await testRender(<AnalystHarness provider={provider} />, { width: WIDTH, height: HEIGHT });
109
+ });
110
+ for (let attempt = 0; attempt < 6; attempt += 1) {
111
+ await act(async () => {
112
+ await Bun.sleep(1);
113
+ await testSetup!.renderOnce();
114
+ });
115
+ }
116
+ }
117
+
118
+ afterEach(async () => {
119
+ if (testSetup) {
120
+ await act(async () => testSetup!.renderer.destroy());
121
+ testSetup = undefined;
122
+ }
123
+ });
124
+
125
+ /**
126
+ * The pane used to reach a source that carries the aggregate price target but
127
+ * no per-rating targets, so every TARGET cell rendered as a dash under a
128
+ * header that advertised an average target.
129
+ */
130
+ test("renders each rating's price target and only dashes the rows without one", async () => {
131
+ await renderHarness({
132
+ id: "test",
133
+ name: "Test",
134
+ getAnalystResearch: async () => research,
135
+ } as unknown as DataProvider);
136
+
137
+ const frame = testSetup!.captureCharFrame();
138
+ expect(frame).toContain("$50.46 avg target");
139
+ expect(frame).toContain("$47 → $42");
140
+ expect(frame).toContain("$61");
141
+ const noTargetRow = frame.split("\n").find((line) => line.includes("No Target"));
142
+ expect(noTargetRow).toBeDefined();
143
+ expect(noTargetRow).not.toMatch(/\$\d/);
144
+ expect(noTargetRow).toContain("-");
145
+ });
@@ -34,7 +34,12 @@ import {
34
34
  buildInlineFilingContentTargets,
35
35
  useSecFilingContentCache,
36
36
  } from "../sec/filing-content";
37
- import { buildEventRows, type EventRow, type EventStatus } from "./event-model";
37
+ import {
38
+ buildEventRows,
39
+ eventSourceNotice,
40
+ type EventRow,
41
+ type EventStatus,
42
+ } from "./event-model";
38
43
 
39
44
  export { buildEventRows } from "./event-model";
40
45
 
@@ -284,6 +289,18 @@ export function CorporateActionsView({
284
289
  : allRows
285
290
  ), [allRows, variant]);
286
291
  const columns = useMemo(() => buildEventColumns(), []);
292
+ const sourceNotice = useMemo(() => (
293
+ actionsLoading || analystLoading
294
+ ? null
295
+ : eventSourceNotice({
296
+ variant,
297
+ symbol: symbol ?? "this ticker",
298
+ actions: actionsData,
299
+ actionsError,
300
+ estimates: analystData,
301
+ estimatesError: analystError,
302
+ })
303
+ ), [actionsData, actionsError, actionsLoading, analystData, analystError, analystLoading, symbol, variant]);
287
304
  const [selectedIdx, setSelectedIdx] = useState(0);
288
305
  const [openRowId, setOpenRowId] = useState<string | null>(null);
289
306
  const detailScrollRef = useRef<ScrollBoxRenderable>(null);
@@ -480,6 +497,15 @@ export function CorporateActionsView({
480
497
  }}
481
498
  onActivate={(row) => setOpenRowId(row.id)}
482
499
  onDetailKeyDown={handleDetailKeyDown}
500
+ rootBefore={sourceNotice && rows.length > 0 ? (
501
+ <Box
502
+ height={1}
503
+ paddingX={1}
504
+ {...(sourceNotice.failed ? { "data-gloom-status": "error" } : {})}
505
+ >
506
+ <Text fg={sourceNotice.failed ? colors.negative : colors.textDim}>{sourceNotice.text}</Text>
507
+ </Box>
508
+ ) : undefined}
483
509
  rootWidth={width}
484
510
  rootHeight={height}
485
511
  onRootKeyDown={handleKeyDown}
@@ -243,3 +243,59 @@ export function buildEventRows(
243
243
  || left.period.localeCompare(right.period)
244
244
  ));
245
245
  }
246
+
247
+ export interface EventSourceState {
248
+ variant: "corporate-actions" | "earnings-estimates";
249
+ symbol: string;
250
+ actions: CorporateActionsData | null;
251
+ actionsError: string | null;
252
+ estimates: AnalystResearchData | null;
253
+ estimatesError: string | null;
254
+ }
255
+
256
+ export interface EventSourceNotice {
257
+ text: string;
258
+ /** True when a source failed, as opposed to a source that had nothing. */
259
+ failed: boolean;
260
+ }
261
+
262
+ /**
263
+ * The table can still show a TTM line built from statements while both event
264
+ * sources returned nothing, which used to read as a working pane that happens
265
+ * to be almost empty. This says which source is missing so a thin pane
266
+ * explains itself instead of looking broken.
267
+ */
268
+ export function eventSourceNotice(state: EventSourceState): EventSourceNotice | null {
269
+ const actionsLabel = state.variant === "earnings-estimates"
270
+ ? "Reported earnings"
271
+ : "Corporate actions";
272
+ const notices: string[] = [];
273
+
274
+ if (state.actionsError) {
275
+ notices.push(`${actionsLabel} unavailable: ${state.actionsError}`);
276
+ } else if (state.actions && !hasCorporateActionRows(state.actions)) {
277
+ notices.push(state.variant === "earnings-estimates"
278
+ ? `No reported earnings for ${state.symbol}`
279
+ : `No dividends, splits, or reported earnings for ${state.symbol}`);
280
+ }
281
+
282
+ if (state.estimatesError) {
283
+ notices.push(`Analyst estimates unavailable: ${state.estimatesError}`);
284
+ } else if (state.estimates && !hasAnalystEstimates(state.estimates)) {
285
+ notices.push(`No analyst estimates for ${state.symbol}`);
286
+ }
287
+
288
+ if (notices.length === 0) return null;
289
+ return {
290
+ text: notices.join(" "),
291
+ failed: !!state.actionsError || !!state.estimatesError,
292
+ };
293
+ }
294
+
295
+ function hasCorporateActionRows(data: CorporateActionsData): boolean {
296
+ return data.dividends.length > 0 || data.splits.length > 0 || data.earnings.length > 0;
297
+ }
298
+
299
+ function hasAnalystEstimates(data: AnalystResearchData): boolean {
300
+ return data.earningsEstimates.length > 0 || data.revenueEstimates.length > 0;
301
+ }
@@ -9,6 +9,7 @@ import {
9
9
  type RatingSortPreference,
10
10
  } from "./analyst-pane";
11
11
  import { buildEventRows, matchEarningsSecFiling } from "./corporate-actions-pane";
12
+ import { eventSourceNotice } from "./event-model";
12
13
 
13
14
  const ratings: AnalystRatingRecord[] = [
14
15
  {
@@ -362,3 +363,45 @@ describe("event rows", () => {
362
363
  expect(matchEarningsSecFiling(row, filings)?.accessionNumber).toBe("0000320193-26-000009");
363
364
  });
364
365
  });
366
+
367
+ describe("event source notice", () => {
368
+ const loaded = {
369
+ variant: "corporate-actions" as const,
370
+ symbol: "DBK",
371
+ actions: { symbol: "DBK", dividends: [{ exDate: "2026-05-29", amount: 1 }], splits: [], earnings: [] },
372
+ actionsError: null,
373
+ estimates: {
374
+ symbol: "DBK",
375
+ recommendations: [],
376
+ ratings: [],
377
+ earningsEstimates: [{ date: "2026-12-31", period: "current_year", average: 3.3, analysts: 10 }],
378
+ revenueEstimates: [],
379
+ } satisfies AnalystResearchData,
380
+ estimatesError: null,
381
+ };
382
+
383
+ test("stays quiet when both sources delivered", () => {
384
+ expect(eventSourceNotice(loaded)).toBeNull();
385
+ });
386
+
387
+ /**
388
+ * A failed corporate-actions request left the table showing only estimate and
389
+ * TTM rows, which reads as a working pane for a company with no events.
390
+ */
391
+ test("names a failed source and marks it as a failure", () => {
392
+ expect(eventSourceNotice({ ...loaded, actions: null, actionsError: "Cloud request failed" })).toEqual({
393
+ text: "Corporate actions unavailable: Cloud request failed",
394
+ failed: true,
395
+ });
396
+ });
397
+
398
+ test("reports genuinely empty data without calling it a failure", () => {
399
+ expect(eventSourceNotice({
400
+ ...loaded,
401
+ actions: { symbol: "DBK", dividends: [], splits: [], earnings: [] },
402
+ })).toEqual({
403
+ text: "No dividends, splits, or reported earnings for DBK",
404
+ failed: false,
405
+ });
406
+ });
407
+ });
@@ -97,17 +97,28 @@ export function nextSortPreference(
97
97
  return { columnId: columnId as ShortInterestColumnId, direction: "desc" };
98
98
  }
99
99
 
100
- export function buildColumns(width: number): ShortInterestColumn[] {
100
+ /**
101
+ * FINRA settlement history carries no float, so percent of float is null for
102
+ * every row on that route. Keeping the column drew a header over a column of
103
+ * dashes, which reads as missing data rather than data the source never had.
104
+ */
105
+ export function buildColumns(
106
+ width: number,
107
+ records: readonly ShortInterestRecord[] = [],
108
+ ): ShortInterestColumn[] {
109
+ const hasPercentFloat = records.some((record) => record.shortPercentFloat != null);
101
110
  const dateWidth = 12;
102
111
  const sharesWidth = 12;
103
112
  const ratioWidth = 12;
104
- const advWidth = Math.max(12, width - 2 - dateWidth - sharesWidth - ratioWidth - 12);
105
- const percentWidth = 10;
113
+ const percentWidth = hasPercentFloat ? 10 : 0;
114
+ const advWidth = Math.max(12, width - 2 - dateWidth - sharesWidth - ratioWidth - (hasPercentFloat ? 12 : 2));
106
115
  return [
107
116
  { id: "settlementDate", label: "DATE", width: dateWidth, align: "left" },
108
117
  { id: "sharesShort", label: "SHARES SHORT", width: sharesWidth, align: "right" },
109
118
  { id: "shortRatio", label: "DAYS TO COVER", width: ratioWidth, align: "right" },
110
119
  { id: "averageDailyVolume", label: "AVG DAILY VOL", width: advWidth, align: "right" },
111
- { id: "shortPercentFloat", label: "% FLOAT", width: percentWidth, align: "right" },
120
+ ...(hasPercentFloat
121
+ ? [{ id: "shortPercentFloat" as const, label: "% FLOAT", width: percentWidth, align: "right" as const }]
122
+ : []),
112
123
  ];
113
124
  }
@@ -71,7 +71,7 @@ function ShortInterestView({ width, height, focused }: { width: number; height:
71
71
 
72
72
  const rows = useMemo(() => buildRows(records), [records]);
73
73
  const sortedRows = useMemo(() => sortRows(rows, sortPreference), [rows, sortPreference]);
74
- const columns = useMemo(() => buildColumns(width), [width]);
74
+ const columns = useMemo(() => buildColumns(width, records), [records, width]);
75
75
  const chartPoints = useMemo(() => recordsToChartPoints(records), [records]);
76
76
 
77
77
  const boundedSelectedIdx = sortedRows.length > 0
@@ -0,0 +1,70 @@
1
+ import { expect, test } from "bun:test";
2
+ import { getDockedPaneIds } from "../../plugins/pane-manager/dock-tree";
3
+ import {
4
+ BROWSER_DEFAULT_THEME,
5
+ BROWSER_ECON_CALENDAR_ID,
6
+ BROWSER_RESEARCH_CHART_ID,
7
+ BROWSER_RESEARCH_NEWS_ID,
8
+ BROWSER_RESEARCH_PANE_ID,
9
+ BROWSER_SECTORS_ID,
10
+ BROWSER_WORLD_INDICES_ID,
11
+ createBrowserConfigStore,
12
+ } from "./config-host";
13
+ import type { StorageLike } from "./storage";
14
+
15
+ function memoryStorage(): StorageLike {
16
+ const map = new Map<string, string>();
17
+ return {
18
+ getItem: (key) => map.get(key) ?? null,
19
+ setItem: (key, value) => { map.set(key, value); },
20
+ removeItem: (key) => { map.delete(key); },
21
+ };
22
+ }
23
+
24
+ test("a first visit opens six panes with one company down the middle", async () => {
25
+ const store = createBrowserConfigStore(memoryStorage(), "?ticker=aapl&tab=financials");
26
+ const config = await store.loadConfig("browser://local");
27
+
28
+ expect(config.theme).toBe(BROWSER_DEFAULT_THEME);
29
+ expect(config.layouts[0]?.name).toBe("Research");
30
+ expect(getDockedPaneIds(config.layout)).toEqual([
31
+ BROWSER_WORLD_INDICES_ID,
32
+ BROWSER_SECTORS_ID,
33
+ BROWSER_RESEARCH_PANE_ID,
34
+ BROWSER_RESEARCH_NEWS_ID,
35
+ BROWSER_RESEARCH_CHART_ID,
36
+ BROWSER_ECON_CALENDAR_ID,
37
+ ]);
38
+
39
+ const byId = new Map(config.layout.instances.map((instance) => [instance.instanceId, instance]));
40
+ expect(byId.get(BROWSER_RESEARCH_PANE_ID)?.binding).toEqual({ kind: "fixed", symbol: "AAPL" });
41
+ expect(byId.get(BROWSER_RESEARCH_CHART_ID)?.binding).toEqual({
42
+ kind: "follow",
43
+ sourceInstanceId: BROWSER_RESEARCH_PANE_ID,
44
+ });
45
+ expect(byId.get(BROWSER_RESEARCH_NEWS_ID)?.binding).toEqual({
46
+ kind: "follow",
47
+ sourceInstanceId: BROWSER_RESEARCH_PANE_ID,
48
+ });
49
+ expect(config.layouts[0]?.paneState?.[BROWSER_RESEARCH_PANE_ID]).toEqual({ activeTabId: "financials" });
50
+ expect(config.onboardingComplete).toBe(true);
51
+ });
52
+
53
+ test("a visit without a research link still opens NVDA", async () => {
54
+ const store = createBrowserConfigStore(memoryStorage());
55
+ const config = await store.loadConfig("browser://local");
56
+ const research = config.layout.instances.find((instance) => instance.instanceId === BROWSER_RESEARCH_PANE_ID);
57
+ expect(research?.binding).toEqual({ kind: "fixed", symbol: "NVDA" });
58
+ });
59
+
60
+ test("a returning visitor keeps the saved layout", async () => {
61
+ const storage = memoryStorage();
62
+ const first = createBrowserConfigStore(storage, "?ticker=NVDA");
63
+ await first.saveConfig(await first.loadConfig("browser://local"));
64
+
65
+ const second = createBrowserConfigStore(storage, "?ticker=MSFT");
66
+ const restored = await second.loadConfig("browser://local");
67
+ const research = restored.layout.instances.find((instance) => instance.instanceId === BROWSER_RESEARCH_PANE_ID);
68
+ expect(research?.binding).toEqual({ kind: "fixed", symbol: "NVDA" });
69
+ expect(getDockedPaneIds(restored.layout)).toHaveLength(6);
70
+ });
@@ -6,7 +6,14 @@ import {
6
6
  normalizeConfigForSave,
7
7
  normalizeLoadedConfig,
8
8
  } from "../../data/config/store/normalize";
9
- import { createDefaultConfig, TICKER_RESEARCH_PANE_ID, type AppConfig } from "../../types/config";
9
+ import {
10
+ CHART_COMPOSER_PANE_ID,
11
+ createDefaultConfig,
12
+ TICKER_RESEARCH_PANE_ID,
13
+ type AppConfig,
14
+ type DockLayoutNode,
15
+ type LayoutConfig,
16
+ } from "../../types/config";
10
17
  import { researchEntryFromSearch } from "./research-entry";
11
18
  import { BROWSER_STORAGE_KEYS, SafeJsonStorage, type StorageLike } from "./storage";
12
19
 
@@ -16,20 +23,77 @@ function browserReady(config: AppConfig): AppConfig {
16
23
  return { ...config, onboardingComplete: true, onboardingProgress: undefined };
17
24
  }
18
25
 
26
+ export const BROWSER_RESEARCH_PANE_ID = "ticker-detail:main";
27
+ export const BROWSER_RESEARCH_CHART_ID = "chart-composer:research";
28
+ export const BROWSER_RESEARCH_NEWS_ID = "ticker-news:research";
29
+ export const BROWSER_WORLD_INDICES_ID = "world-indices:main";
30
+ export const BROWSER_SECTORS_ID = "sectors:main";
31
+ export const BROWSER_ECON_CALENDAR_ID = "econ-calendar:main";
32
+
33
+ /** The browser ships the monochrome theme the website uses. */
34
+ export const BROWSER_DEFAULT_THEME = "white";
35
+
36
+ function column(first: string, second: string, ratio = 0.5): DockLayoutNode {
37
+ return {
38
+ kind: "split",
39
+ axis: "vertical",
40
+ ratio,
41
+ first: { kind: "pane", instanceId: first },
42
+ second: { kind: "pane", instanceId: second },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * The first-visit workspace: six panes, with one company down the middle.
48
+ * The research pane owns the symbol; the news and chart panes follow it, so
49
+ * typing a new ticker while the research pane is focused swaps all three.
50
+ * The market panes on either side stay put.
51
+ */
52
+ export function createBrowserResearchLayout(symbol: string): LayoutConfig {
53
+ const follow = { kind: "follow" as const, sourceInstanceId: BROWSER_RESEARCH_PANE_ID };
54
+ return {
55
+ dockRoot: {
56
+ kind: "split",
57
+ axis: "horizontal",
58
+ ratio: 0.3,
59
+ first: column(BROWSER_WORLD_INDICES_ID, BROWSER_SECTORS_ID),
60
+ second: {
61
+ kind: "split",
62
+ axis: "horizontal",
63
+ ratio: 0.57,
64
+ first: column(BROWSER_RESEARCH_PANE_ID, BROWSER_RESEARCH_NEWS_ID),
65
+ second: column(BROWSER_RESEARCH_CHART_ID, BROWSER_ECON_CALENDAR_ID),
66
+ },
67
+ },
68
+ instances: [
69
+ { instanceId: BROWSER_WORLD_INDICES_ID, paneId: "world-indices", binding: { kind: "none" } },
70
+ { instanceId: BROWSER_SECTORS_ID, paneId: "sectors", binding: { kind: "none" } },
71
+ {
72
+ instanceId: BROWSER_RESEARCH_PANE_ID,
73
+ paneId: TICKER_RESEARCH_PANE_ID,
74
+ binding: { kind: "fixed", symbol },
75
+ settings: { hideTabs: false },
76
+ },
77
+ { instanceId: BROWSER_RESEARCH_NEWS_ID, paneId: "ticker-news", binding: follow },
78
+ { instanceId: BROWSER_RESEARCH_CHART_ID, paneId: CHART_COMPOSER_PANE_ID, binding: follow },
79
+ { instanceId: BROWSER_ECON_CALENDAR_ID, paneId: "econ-calendar", binding: { kind: "none" } },
80
+ ],
81
+ floating: [],
82
+ detached: [],
83
+ };
84
+ }
85
+
19
86
  function createBrowserDefaultConfig(dataDir: string, search = ""): AppConfig {
20
87
  const config = createDefaultConfig(dataDir);
88
+ config.theme = BROWSER_DEFAULT_THEME;
21
89
  const entry = researchEntryFromSearch(search) ?? { symbol: "NVDA", tab: "overview" };
22
- // A first visit starts with one useful research pane. Saved layouts retain
23
- // their existing panes and bindings when a visitor returns.
90
+ // A first visit starts with a research workspace around one company. Saved
91
+ // layouts retain their existing panes and bindings when a visitor returns.
24
92
  config.layouts[0] = {
25
93
  name: "Research",
26
- layout: {
27
- dockRoot: { kind: "pane", instanceId: "ticker-detail:main" },
28
- instances: [{ instanceId: "ticker-detail:main", paneId: TICKER_RESEARCH_PANE_ID,
29
- binding: { kind: "fixed", symbol: entry.symbol }, settings: { hideTabs: false } }],
30
- floating: [], detached: [],
31
- },
32
- paneState: { "ticker-detail:main": { activeTabId: entry.tab } },
94
+ layout: createBrowserResearchLayout(entry.symbol),
95
+ paneState: { [BROWSER_RESEARCH_PANE_ID]: { activeTabId: entry.tab } },
96
+ focusedPaneId: BROWSER_RESEARCH_PANE_ID,
33
97
  };
34
98
  config.layout = config.layouts[0].layout;
35
99
  return browserReady(config);
@@ -35,8 +35,24 @@ import type {
35
35
  AppTickerRepositoryPort,
36
36
  } from "../../../core/app-service-ports";
37
37
  import type { CachedResourceRecord, ResourceCacheKey, SetResourceOptions } from "../../../data/resource-store";
38
- import type { CachedFinancialsTarget, DataProvider, QuoteSubscriptionTarget } from "../../../types/data-provider";
39
- import type { OptionsChain, PricePoint, TickerFinancials } from "../../../types/financials";
38
+ import type {
39
+ CachedFinancialsTarget,
40
+ DataProvider,
41
+ EarningsEvent,
42
+ QuoteBatchResult,
43
+ QuoteSubscriptionTarget,
44
+ SecFilingItem,
45
+ } from "../../../types/data-provider";
46
+ import type {
47
+ AnalystResearchData,
48
+ CorporateActionsData,
49
+ HolderData,
50
+ OptionsChain,
51
+ PricePoint,
52
+ Quote,
53
+ TickerFinancials,
54
+ } from "../../../types/financials";
55
+ import { setHttpFetchTransport } from "../../../utils/http-transport";
40
56
  import type { TickerRecord } from "../../../types/ticker";
41
57
  import type { AppState, PaneRuntimeState } from "../../../core/state/app/state";
42
58
  import type { PaneDef } from "../../../types/plugin";
@@ -100,6 +116,9 @@ const SHOT_READY_STABLE_FRAMES = 10;
100
116
  // headline) wait forever and time out.
101
117
  const SHOT_LOADING_SELECTOR = "[data-gloom-status=\"loading\"]";
102
118
  const SHOT_API_PROXY_PREFIX = "/__gloom_cli_api__";
119
+ // Served by the Bun process that owns this page, see src/cli/desktop-pane-shot.ts.
120
+ const SHOT_MARKET_BRIDGE_PATH = "/__gloom_cli_market__";
121
+ const SHOT_HTTP_BRIDGE_PATH = "/__gloom_cli_http__";
103
122
  const TRACKED_RESPONSE_METHODS = new Set<PropertyKey>([
104
123
  "arrayBuffer",
105
124
  "blob",
@@ -183,6 +202,68 @@ function installShotCloudApiTransport(): void {
183
202
  });
184
203
  }
185
204
 
205
+ /**
206
+ * Panes such as DVD and SI call a third-party API through httpFetch. A browser
207
+ * cannot do that cross-origin, so those requests died in CORS and the panes
208
+ * reported the ticker as having no data. The desktop renderer proxies the same
209
+ * calls through its native half; here the Bun process that serves this page
210
+ * runs them and returns the response, cookies included so the Yahoo crumb
211
+ * handshake still works.
212
+ */
213
+ function installShotHttpFetchTransport(): void {
214
+ setHttpFetchTransport(async (url, init = {}) => {
215
+ const headers: Record<string, string> = {};
216
+ new Headers(init.headers).forEach((value, name) => {
217
+ headers[name] = value;
218
+ });
219
+ const response = await window.fetch(SHOT_HTTP_BRIDGE_PATH, {
220
+ method: "POST",
221
+ headers: { "Content-Type": "application/json" },
222
+ body: JSON.stringify({
223
+ url,
224
+ method: init.method,
225
+ headers,
226
+ body: typeof init.body === "string" ? init.body : undefined,
227
+ }),
228
+ });
229
+ const result = await response.json() as {
230
+ ok: boolean;
231
+ error?: string;
232
+ data?: {
233
+ status: number;
234
+ statusText: string;
235
+ headers: Record<string, string>;
236
+ setCookie: string[];
237
+ body: string;
238
+ };
239
+ };
240
+ if (!result.ok || !result.data) throw new Error(result.error ?? `Request failed: ${url}`);
241
+ return createShotHttpResponse(result.data);
242
+ });
243
+ }
244
+
245
+ function createShotHttpResponse(payload: {
246
+ status: number;
247
+ statusText: string;
248
+ headers: Record<string, string>;
249
+ setCookie: string[];
250
+ body: string;
251
+ }): Response {
252
+ // Yahoo's crumb handshake reads set-cookie, which fetch hides from a page.
253
+ const headers = new Headers(payload.headers);
254
+ const originalGet = headers.get.bind(headers);
255
+ headers.get = ((name: string) => (
256
+ name.toLowerCase() === "set-cookie" ? payload.setCookie[0] ?? null : originalGet(name)
257
+ )) as Headers["get"];
258
+ (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie = () => [...payload.setCookie];
259
+ const response = new Response(payload.body, {
260
+ status: payload.status,
261
+ statusText: payload.statusText,
262
+ });
263
+ Object.defineProperty(response, "headers", { value: headers, configurable: true });
264
+ return response;
265
+ }
266
+
186
267
  async function restoreShotCloudSession(): Promise<void> {
187
268
  await apiClient.getSession().catch(() => null);
188
269
  }
@@ -191,6 +272,51 @@ function resolveShotWork<T>(value: T): Promise<T> {
191
272
  return trackShotWork(Promise.resolve(value));
192
273
  }
193
274
 
275
+ /**
276
+ * Runs an asset-data request in the Bun process so the page sees exactly what
277
+ * `gloomberb fn` sees. The page can only reach the cloud API on its own, and
278
+ * the cloud is one source among several: analyst rating price targets and part
279
+ * of the corporate action history come from providers the router merges in.
280
+ */
281
+ async function requestShotMarketData<T>(operation: string, args: unknown[]): Promise<T> {
282
+ const response = await window.fetch(SHOT_MARKET_BRIDGE_PATH, {
283
+ method: "POST",
284
+ headers: { "Content-Type": "application/json" },
285
+ body: JSON.stringify({ operation, args }),
286
+ });
287
+ const result = await response.json() as { ok: boolean; data?: T; error?: string };
288
+ if (!result.ok) throw new Error(result.error ?? `Screenshot data request failed: ${operation}`);
289
+ return result.data as T;
290
+ }
291
+
292
+ /** JSON has no Date, so every bridged date arrives as a string. */
293
+ function reviveDate(value: unknown): Date | undefined {
294
+ if (value instanceof Date) return value;
295
+ if (typeof value !== "string" && typeof value !== "number") return undefined;
296
+ const date = new Date(value);
297
+ return Number.isNaN(date.getTime()) ? undefined : date;
298
+ }
299
+
300
+ function reviveSecFilings(filings: SecFilingItem[]): SecFilingItem[] {
301
+ return filings.map((filing) => ({
302
+ ...filing,
303
+ filingDate: reviveDate(filing.filingDate) ?? new Date(0),
304
+ ...(filing.acceptedAt ? { acceptedAt: reviveDate(filing.acceptedAt) } : {}),
305
+ }));
306
+ }
307
+
308
+ function revivePricePoints(points: PricePoint[]): PricePoint[] {
309
+ return points.map((point) => ({ ...point, date: reviveDate(point.date) ?? new Date(0) }));
310
+ }
311
+
312
+ function reviveEarningsEvents(events: EarningsEvent[]): EarningsEvent[] {
313
+ return events.map((event) => ({
314
+ ...event,
315
+ earningsDate: reviveDate(event.earningsDate) ?? new Date(0),
316
+ ...(event.earningsCallDate ? { earningsCallDate: reviveDate(event.earningsCallDate) ?? null } : {}),
317
+ }));
318
+ }
319
+
194
320
  function isShotLoadingTextVisible(): boolean {
195
321
  return document.querySelector(SHOT_LOADING_SELECTOR) !== null;
196
322
  }
@@ -301,9 +427,13 @@ function createShotDataProvider(payload: CliPaneShotPayload): DataProvider {
301
427
  if (!instrument.exchange) optionsChains.set(normalizeSymbol(instrument.symbol), data);
302
428
  }
303
429
 
430
+ const findFinancials = (symbol: string, exchange?: string) => (
431
+ financials.get(canonicalTickerKey(symbol, exchange))
432
+ ?? financials.get(normalizeSymbol(symbol))
433
+ );
434
+
304
435
  const getFinancials = (symbol: string, exchange?: string) => {
305
- const data = financials.get(canonicalTickerKey(symbol, exchange))
306
- ?? financials.get(normalizeSymbol(symbol));
436
+ const data = findFinancials(symbol, exchange);
307
437
  if (!data) throw new Error(`No screenshot market data available for ${symbol}.`);
308
438
  return data;
309
439
  };
@@ -323,18 +453,26 @@ function createShotDataProvider(payload: CliPaneShotPayload): DataProvider {
323
453
  })));
324
454
  },
325
455
  getQuote(ticker, exchange) {
326
- return trackShotWork(Promise.resolve().then(() => {
327
- const quote = getFinancials(ticker, exchange).quote;
328
- if (!quote) throw new Error(`No screenshot quote data available for ${ticker}.`);
329
- return quote;
330
- }));
456
+ const quote = findFinancials(ticker, exchange)?.quote;
457
+ if (quote) return resolveShotWork(quote);
458
+ return requestShotMarketData<Quote>("getQuote", [ticker, exchange]);
331
459
  },
460
+ // Board panes such as WEI, MOST, and BI quote symbols the shot payload was
461
+ // never built for, and returning null for those drew a full table of
462
+ // dashes. The payload still wins wherever it has the symbol, so mapped
463
+ // capabilities keep rendering exactly the data their evidence checks use.
332
464
  getQuotesBatch(targets: QuoteSubscriptionTarget[]) {
333
- return resolveShotWork(targets.map((target) => ({
334
- target,
335
- quote: (financials.get(canonicalTickerKey(target.symbol, target.exchange))
336
- ?? financials.get(normalizeSymbol(target.symbol)))?.quote ?? null,
337
- })));
465
+ const resolved: QuoteBatchResult[] = [];
466
+ const missing: QuoteSubscriptionTarget[] = [];
467
+ for (const target of targets) {
468
+ const quote = findFinancials(target.symbol, target.exchange)?.quote;
469
+ if (quote) resolved.push({ target, quote });
470
+ else missing.push(target);
471
+ }
472
+ if (missing.length === 0) return resolveShotWork(resolved);
473
+ return requestShotMarketData<QuoteBatchResult[]>("getQuotesBatch", [missing])
474
+ .then((results) => [...resolved, ...results])
475
+ .catch(() => [...resolved, ...missing.map((target) => ({ target, quote: null }))]);
338
476
  },
339
477
  getExchangeRate(fromCurrency: string) {
340
478
  // Returning 1 for every currency used to render the FX matrix as a grid
@@ -373,13 +511,18 @@ function createShotDataProvider(payload: CliPaneShotPayload): DataProvider {
373
511
  return resolveShotWork(null);
374
512
  },
375
513
  getPriceHistory(ticker, exchange, range) {
514
+ const intraday = intradayHistory(ticker, exchange);
515
+ const local = intraday ? intraday.points : findFinancials(ticker, exchange)?.priceHistory;
516
+ // Sector and board panes chart symbols the payload does not carry, and
517
+ // an empty series turned their trailing-return columns into dashes.
518
+ if (!intraday && !local) {
519
+ return requestShotMarketData<PricePoint[]>("getPriceHistory", [ticker, exchange, range])
520
+ .then(revivePricePoints)
521
+ .catch(() => []);
522
+ }
376
523
  return trackShotWork(Promise.resolve().then(() => {
377
- const intraday = intradayHistory(ticker, exchange);
378
524
  if (intraday?.unavailableReason) throw new Error(intraday.unavailableReason);
379
- return clipPriceHistoryToRange(
380
- intraday ? intraday.points : getFinancials(ticker, exchange).priceHistory ?? [],
381
- range,
382
- );
525
+ return clipPriceHistoryToRange(local ?? [], range);
383
526
  }));
384
527
  },
385
528
  getPriceHistoryForResolution(ticker, exchange, bufferRange, resolution) {
@@ -410,6 +553,23 @@ function createShotDataProvider(payload: CliPaneShotPayload): DataProvider {
410
553
  getChartResolutionSupport() {
411
554
  return resolveShotWork(SHOT_CHART_RESOLUTION_SUPPORT);
412
555
  },
556
+ getAnalystResearch(ticker, exchange) {
557
+ return requestShotMarketData<AnalystResearchData>("getAnalystResearch", [ticker, exchange]);
558
+ },
559
+ getCorporateActions(ticker, exchange) {
560
+ return requestShotMarketData<CorporateActionsData>("getCorporateActions", [ticker, exchange]);
561
+ },
562
+ getHolders(ticker, exchange) {
563
+ return requestShotMarketData<HolderData>("getHolders", [ticker, exchange]);
564
+ },
565
+ getSecFilings(ticker, count, exchange) {
566
+ return requestShotMarketData<SecFilingItem[]>("getSecFilings", [ticker, count, exchange])
567
+ .then(reviveSecFilings);
568
+ },
569
+ getEarningsCalendar(symbols) {
570
+ return requestShotMarketData<EarningsEvent[]>("getEarningsCalendar", [symbols])
571
+ .then(reviveEarningsEvents);
572
+ },
413
573
  subscribeQuotes() {
414
574
  return () => {};
415
575
  },
@@ -684,6 +844,7 @@ async function render() {
684
844
  revivePayloadDates(payload);
685
845
  installShotFetchTracker();
686
846
  installShotCloudApiTransport();
847
+ installShotHttpFetchTransport();
687
848
  hydrateFredSeries(payload.fredSeries ?? []);
688
849
  hydrateValuationSeries(payload.valuationSeries ?? []);
689
850
  statsCache.hydrate(payload.statSeries ?? []);
@@ -25,7 +25,7 @@ afterEach(() => {
25
25
 
26
26
  function expectedAssetName(compressed = false): string {
27
27
  const os = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux";
28
- const arch = os === "darwin" || process.arch === "arm64" ? "arm64" : "x64";
28
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
29
29
  const extension = os === "windows" ? ".exe" : "";
30
30
  return compressed ? `gloomberb-${os}-${arch}${extension}.gz` : `gloomberb-${os}-${arch}${extension}`;
31
31
  }
@@ -37,6 +37,22 @@ describe("getAssetBaseNameForRuntime", () => {
37
37
  arch: "x64",
38
38
  })).toBe("gloomberb-windows-x64.exe");
39
39
  });
40
+
41
+ test("keeps macOS arm64 runtimes on the arm64 asset", () => {
42
+ expect(getAssetBaseNameForRuntime({
43
+ platform: "darwin",
44
+ arch: "arm64",
45
+ })).toBe("gloomberb-darwin-arm64");
46
+ });
47
+
48
+ // An arm64 asset cannot run on an x64 macOS runtime, so it must never be
49
+ // offered as that runtime's update.
50
+ test("asks for an x64 asset on an x64 macOS runtime", () => {
51
+ expect(getAssetBaseNameForRuntime({
52
+ platform: "darwin",
53
+ arch: "x64",
54
+ })).toBe("gloomberb-darwin-x64");
55
+ });
40
56
  });
41
57
 
42
58
  describe("detectUpdateAction", () => {
package/src/updater.ts CHANGED
@@ -62,8 +62,10 @@ export function getAssetBaseNameForRuntime(
62
62
  : runtimeProcess?.platform === "win32"
63
63
  ? "windows"
64
64
  : "linux";
65
- // macOS x64 uses arm64 binary (runs via Rosetta 2)
66
- const arch = os === "darwin" || runtimeProcess?.arch === "arm64" ? "arm64" : "x64";
65
+ // Always update to the architecture the current runtime actually is. macOS
66
+ // only publishes arm64 today, so an x64 macOS runtime resolves no asset and
67
+ // reports that instead of overwriting itself with an unrunnable binary.
68
+ const arch = runtimeProcess?.arch === "arm64" ? "arm64" : "x64";
67
69
  const extension = os === "windows" ? ".exe" : "";
68
70
  return `gloomberb-${os}-${arch}${extension}`;
69
71
  }