gloomberb 0.12.0 → 0.13.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 (135) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -2
  3. package/src/api-client/auth.ts +2 -1
  4. package/src/api-client/index.ts +10 -2
  5. package/src/api-client/research-activity.ts +61 -0
  6. package/src/api-client/types.ts +2 -1
  7. package/src/cli/commands/plugins.ts +4 -3
  8. package/src/cli/desktop-pane-shot.ts +79 -10
  9. package/src/cli/pane-functions/capabilities.ts +76 -23
  10. package/src/cli/pane-functions/dom.test.ts +41 -0
  11. package/src/cli/pane-functions/dom.ts +199 -0
  12. package/src/cli/pane-functions/index.test.ts +13 -2
  13. package/src/cli/pane-functions/index.ts +4 -4
  14. package/src/cli/pane-functions/intraday-shot.test.ts +307 -0
  15. package/src/cli/pane-functions/intraday-shot.ts +334 -0
  16. package/src/cli/pane-functions/report.test.ts +29 -1
  17. package/src/cli/pane-functions/report.ts +68 -7
  18. package/src/cli/pane-functions/screenshot.ts +193 -23
  19. package/src/cli/restore-plugins.ts +10 -3
  20. package/src/components/chart/composite/composite-chart.test.tsx +68 -1
  21. package/src/components/chart/composite/composite-chart.tsx +87 -21
  22. package/src/components/chart/composite/rasterizer.ts +33 -0
  23. package/src/components/chart/composite/renderers.test.ts +51 -4
  24. package/src/components/chart/composite/scene.test.ts +128 -2
  25. package/src/components/chart/composite/scene.ts +92 -16
  26. package/src/components/chart/composite/text-renderer.ts +13 -0
  27. package/src/components/chart/composite/time-scale.ts +30 -10
  28. package/src/components/chart/composite/types.ts +26 -0
  29. package/src/components/command-bar/surface/assist.test.tsx +20 -9
  30. package/src/components/command-bar/surface/test-harness.tsx +23 -4
  31. package/src/components/onboarding/account-step/chooser-panel.tsx +2 -2
  32. package/src/components/onboarding/account-step/qr-panel.tsx +2 -10
  33. package/src/components/onboarding/onboarding-wizard.test.tsx +38 -12
  34. package/src/components/onboarding/onboarding-wizard.tsx +49 -14
  35. package/src/data/config/store/index.test.ts +23 -0
  36. package/src/data/config/store/node.ts +7 -1
  37. package/src/data/config/store/normalize.ts +4 -0
  38. package/src/plugins/builtin/cds/client.test.ts +20 -0
  39. package/src/plugins/builtin/cds/client.ts +13 -3
  40. package/src/plugins/builtin/cds/headless.test.ts +30 -0
  41. package/src/plugins/builtin/cds/headless.ts +113 -0
  42. package/src/plugins/builtin/cds/index.tsx +4 -0
  43. package/src/plugins/builtin/chart-composer/cli-options.ts +4 -1
  44. package/src/plugins/builtin/chart-composer/index.tsx +1 -1
  45. package/src/plugins/builtin/cloud/askg/protocol.test.ts +189 -0
  46. package/src/plugins/builtin/cloud/askg/protocol.ts +274 -0
  47. package/src/plugins/builtin/cloud/auth-dialog.tsx +7 -1
  48. package/src/plugins/builtin/cloud/device-signin-dialog.tsx +21 -8
  49. package/src/plugins/builtin/cloud/verification-panel.tsx +56 -0
  50. package/src/plugins/builtin/credit-conditions/client.ts +1 -1
  51. package/src/plugins/builtin/credit-conditions/headless.test.ts +31 -0
  52. package/src/plugins/builtin/credit-conditions/headless.ts +55 -0
  53. package/src/plugins/builtin/credit-conditions/index.tsx +4 -0
  54. package/src/plugins/builtin/earnings-calls/pane.tsx +4 -0
  55. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +5 -2
  56. package/src/plugins/builtin/fear-greed/headless.test.ts +53 -0
  57. package/src/plugins/builtin/fear-greed/headless.ts +84 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +4 -0
  59. package/src/plugins/builtin/market-movers/client.ts +76 -0
  60. package/src/plugins/builtin/market-movers/headless.test.ts +34 -0
  61. package/src/plugins/builtin/market-movers/headless.ts +73 -0
  62. package/src/plugins/builtin/market-movers/index.tsx +11 -51
  63. package/src/plugins/builtin/research/corporate-actions-pane.tsx +3 -237
  64. package/src/plugins/builtin/research/event-model.ts +245 -0
  65. package/src/plugins/builtin/research/events-headless.test.ts +32 -0
  66. package/src/plugins/builtin/research/events-headless.ts +87 -0
  67. package/src/plugins/builtin/research/index.tsx +4 -0
  68. package/src/plugins/builtin/research-search/pane.tsx +4 -0
  69. package/src/plugins/builtin/scanner/headless.test.ts +59 -0
  70. package/src/plugins/builtin/scanner/headless.ts +125 -0
  71. package/src/plugins/builtin/scanner/index.ts +4 -0
  72. package/src/plugins/builtin/sectors/client.ts +54 -0
  73. package/src/plugins/builtin/sectors/headless.test.ts +28 -0
  74. package/src/plugins/builtin/sectors/headless.ts +80 -0
  75. package/src/plugins/builtin/sectors/index.tsx +6 -51
  76. package/src/plugins/builtin/shared/cloud-upgrade.ts +8 -4
  77. package/src/plugins/builtin/ticker-detail/pane.tsx +14 -0
  78. package/src/plugins/builtin/treasury-auctions/headless.test.ts +34 -0
  79. package/src/plugins/builtin/treasury-auctions/headless.ts +95 -0
  80. package/src/plugins/builtin/treasury-auctions/index.tsx +4 -0
  81. package/src/plugins/builtin/volatility/client.ts +16 -3
  82. package/src/plugins/builtin/volatility/headless.test.ts +35 -0
  83. package/src/plugins/builtin/volatility/headless.ts +75 -0
  84. package/src/plugins/builtin/volatility/index.tsx +4 -0
  85. package/src/plugins/builtin/world-indices/client.ts +43 -0
  86. package/src/plugins/builtin/world-indices/headless.test.ts +32 -0
  87. package/src/plugins/builtin/world-indices/headless.ts +109 -0
  88. package/src/plugins/builtin/world-indices/index.tsx +4 -0
  89. package/src/plugins/builtin/yield-curve/headless.test.ts +24 -0
  90. package/src/plugins/builtin/yield-curve/headless.ts +67 -0
  91. package/src/plugins/builtin/yield-curve/index.tsx +6 -8
  92. package/src/plugins/builtin/yield-curve/treasury-data.ts +16 -6
  93. package/src/plugins/bundle.test.ts +58 -1
  94. package/src/plugins/bundle.ts +4 -2
  95. package/src/plugins/catalog-backend.test.ts +26 -0
  96. package/src/plugins/catalog-backend.ts +9 -2
  97. package/src/plugins/catalog-browser.test.ts +0 -13
  98. package/src/plugins/catalog-browser.ts +4 -0
  99. package/src/plugins/catalog-manifest.test.ts +15 -0
  100. package/src/plugins/catalog-manifest.ts +12 -5
  101. package/src/plugins/catalog-ui.ts +0 -6
  102. package/src/plugins/host-contract.ts +9 -0
  103. package/src/plugins/host-modules.ts +12 -0
  104. package/src/plugins/loader.ts +63 -15
  105. package/src/plugins/seed.ts +3 -0
  106. package/src/public/broker.ts +6 -0
  107. package/src/remote/schema.test.ts +65 -0
  108. package/src/remote/schema.ts +322 -39
  109. package/src/remote/types.ts +49 -2
  110. package/src/renderers/browser/app-services.ts +3 -0
  111. package/src/renderers/browser/config-host.ts +24 -8
  112. package/src/renderers/browser/deeplink-bridge.test.ts +11 -0
  113. package/src/renderers/browser/deeplink-bridge.ts +15 -3
  114. package/src/renderers/browser/main.tsx +3 -1
  115. package/src/renderers/browser/research-entry.test.ts +8 -0
  116. package/src/renderers/browser/research-entry.ts +7 -0
  117. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +2 -2
  118. package/src/renderers/electrobun/bun/desktop/initialization.ts +10 -3
  119. package/src/renderers/electrobun/bun/external-plugins.ts +20 -4
  120. package/src/renderers/electrobun/view/build-assets.ts +0 -2
  121. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +63 -10
  122. package/src/sync/core-contributors.test.ts +111 -0
  123. package/src/sync/core-contributors.ts +155 -1
  124. package/src/time-series/resolve.test.ts +118 -1
  125. package/src/time-series/resolve.ts +14 -16
  126. package/src/types/config.ts +2 -0
  127. package/src/plugins/broker-sync/broker-sync.test.ts +0 -188
  128. package/src/plugins/broker-sync/native-loader.ts +0 -10
  129. package/src/plugins/broker-sync/normalize.ts +0 -308
  130. package/src/plugins/broker-sync/public.ts +0 -122
  131. package/src/plugins/broker-sync/robinhood-native.ts +0 -367
  132. package/src/plugins/broker-sync/robinhood.ts +0 -114
  133. package/src/plugins/broker-sync/simplefin-native.ts +0 -201
  134. package/src/plugins/broker-sync/simplefin.ts +0 -70
  135. package/src/renderers/electrobun/view/native-stubs/broker-sync-native-loader.ts +0 -7
package/README.md CHANGED
@@ -137,6 +137,8 @@ Open command mode with `Ctrl+P`, then type a command. Press `` ` `` to open tick
137
137
 
138
138
  Use **New Portfolio** or **Add Broker Account** to connect a broker. Gloomberb can import positions from Interactive Brokers, Public, Robinhood, and SimpleFIN.
139
139
 
140
+ Each broker is a plugin with its own repository, installed on first launch and updatable on its own. Manage them from the plugin directory, or with `gloomberb install gloom-sh/gloomberb-public` and friends.
141
+
140
142
  - Robinhood opens a browser sign-in page. Gloomberb uses only the read-only account and equity-position tools from the Robinhood Trading MCP server.
141
143
  - Public needs an API secret from Public API settings. Gloomberb creates a short-lived access token and uses only the account and portfolio endpoints.
142
144
  - SimpleFIN needs a one-time setup token from SimpleFIN Bridge. Gloomberb exchanges the token and imports only accounts that contain holdings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/index.tsx",
@@ -94,7 +94,6 @@
94
94
  "dependencies": {
95
95
  "@earendil-works/pi-agent-core": "0.81.1",
96
96
  "@earendil-works/pi-ai": "0.81.1",
97
- "@modelcontextprotocol/sdk": "^1.30.0",
98
97
  "@opentui/core": "^0.3.2",
99
98
  "@opentui/react": "^0.3.2",
100
99
  "@stoqey/ib": "^1.5.6",
@@ -1,3 +1,4 @@
1
+ import { getCurrentPluginTarget } from "../plugins/current-target";
1
2
  import { ApiRequestError, isHardSessionInvalidMessage } from "./errors";
2
3
  import type {
3
4
  AccountProfile,
@@ -151,7 +152,7 @@ export class CloudAuthApi {
151
152
  async sendVerification(): Promise<CloudVerificationResponse> {
152
153
  return this.options.request<CloudVerificationResponse>("/cloud/auth/send-verification", {
153
154
  method: "POST",
154
- body: JSON.stringify({}),
155
+ body: JSON.stringify({ returnTo: getCurrentPluginTarget() === "web" ? location.href : undefined }),
155
156
  });
156
157
  }
157
158
 
@@ -362,8 +362,16 @@ class GloomApiClient {
362
362
  }
363
363
 
364
364
  /** Creates a Stripe checkout session for Cloud Pro; the URL opens in a browser. */
365
- async createCloudCheckout(): Promise<{ url: string }> {
366
- return this.request<{ url: string }>("/stripe/checkout", { method: "POST", body: JSON.stringify({}) });
365
+ async createCloudCheckout(returnTo?: string): Promise<{ url: string }> {
366
+ return this.request<{ url: string }>("/stripe/checkout", { method: "POST", body: JSON.stringify({ returnTo }) });
367
+ }
368
+
369
+ async recordResearchActivity(payload: {
370
+ event: import("./research-activity").ResearchActivity; eventId: string;
371
+ surface: "web" | "desktop" | "tui" | "cli"; anonymousId?: string;
372
+ attribution?: Record<string, string>; feature?: import("./research-activity").ResearchFeature;
373
+ }): Promise<void> {
374
+ await this.request("/activity/research", { method: "POST", body: JSON.stringify(payload) });
367
375
  }
368
376
 
369
377
  /** Stores a verified user's public terminal snapshot or pane handoff. */
@@ -0,0 +1,61 @@
1
+ import { apiClient } from "./index";
2
+ import { getCurrentPluginTarget } from "../plugins/current-target";
3
+
4
+ export type ResearchActivity = "workspace_opened" | "research_viewed" | "ticker_saved" | "pro_feature_used" | "upgrade_intent";
5
+ export type ResearchFeature = "overview" | "chart" | "financials" | "news" | "transcripts" | "search" | "research";
6
+ const sent = new Set<string>();
7
+ let anonymousId: string | undefined;
8
+ let attribution: Record<string, string> = {};
9
+
10
+ /** Hosted web analytics only. Native local use never creates an identifier. */
11
+ export function initializeBrowserResearchActivity(): void {
12
+ try {
13
+ if (navigator.doNotTrack === "1" || (navigator as Navigator & { globalPrivacyControl?: boolean }).globalPrivacyControl) return;
14
+ const url = new URL(location.href);
15
+ const incoming = url.searchParams.get("_gloom");
16
+ const stored = localStorage.getItem("gloomberb.web.anonymous-id");
17
+ anonymousId = [incoming, stored].find((value) => value && /^[a-f0-9-]{36}$/.test(value)) ?? crypto.randomUUID();
18
+ localStorage.setItem("gloomberb.web.anonymous-id", anonymousId);
19
+ const keys = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "twclid"];
20
+ const saved = JSON.parse(localStorage.getItem("gloomberb.web.attribution") ?? "{}");
21
+ const age = Date.now() - Date.parse(saved?.last_touch_at ?? "");
22
+ attribution = Number.isFinite(age) && age >= 0 && age < 30 * 24 * 60 * 60 * 1000 ? saved : {};
23
+ if (keys.some((key) => url.searchParams.has(key))) {
24
+ // A new campaign replaces the old click id instead of inheriting it.
25
+ attribution = { last_touch_at: new Date().toISOString() };
26
+ for (const key of keys) {
27
+ const value = url.searchParams.get(key);
28
+ if (value) attribution[key] = value.slice(0, 300);
29
+ }
30
+ }
31
+ localStorage.setItem("gloomberb.web.attribution", JSON.stringify(attribution));
32
+ url.searchParams.delete("_gloom");
33
+ history.replaceState(history.state, "", url.href);
34
+ } catch { /* Private browsing must still work. */ }
35
+ }
36
+
37
+ /** Counts milestones once per feature/session/account, never their content. */
38
+ export function recordResearchActivity(event: ResearchActivity, feature?: ResearchFeature): void {
39
+ const target = getCurrentPluginTarget();
40
+ const user = apiClient.getCurrentUser();
41
+ if (target === "web" ? !anonymousId : !user) return;
42
+ const key = `${user?.id ?? "guest"}:${event}:${feature ?? ""}`;
43
+ if (sent.has(key)) return;
44
+ sent.add(key);
45
+ if (event !== "workspace_opened") recordResearchActivity("workspace_opened");
46
+ void apiClient.recordResearchActivity({ event, eventId: crypto.randomUUID(),
47
+ surface: target === "desktop" ? "desktop" : target,
48
+ anonymousId: target === "web" ? anonymousId : undefined,
49
+ attribution: target === "web" ? attribution : undefined, feature,
50
+ }).catch(() => { sent.delete(key); });
51
+ }
52
+
53
+ export function researchUpgradeUrl(returnTo?: string): string {
54
+ const url = new URL("https://gloom.sh/cloud?upgrade=pro");
55
+ if (returnTo) url.searchParams.set("returnTo", returnTo);
56
+ if (anonymousId) url.searchParams.set("_gloom", anonymousId);
57
+ for (const [key, value] of Object.entries(attribution)) {
58
+ if (/^(utm_(source|medium|campaign|content|term)|twclid)$/.test(key)) url.searchParams.set(key, value);
59
+ }
60
+ return url.href;
61
+ }
@@ -518,7 +518,8 @@ export interface CloudTranscriptTurnPayload {
518
518
  company?: string;
519
519
  text: string;
520
520
  isQa: boolean;
521
- startSeconds: number;
521
+ /** Offset into the recording; null when the company published the transcript as a document. */
522
+ startSeconds: number | null;
522
523
  }
523
524
 
524
525
  export interface CloudTranscriptParticipantPayload {
@@ -1,7 +1,7 @@
1
1
  import { join } from "path";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "fs";
3
3
  import { execFileSync } from "child_process";
4
- import { getPluginsDir } from "../../plugins/loader";
4
+ import { getPluginsDir, isPluginDirectory } from "../../plugins/loader";
5
5
  import { linkHostPackages } from "../../plugins/host-link";
6
6
  import {
7
7
  cliStyles,
@@ -149,7 +149,7 @@ export async function updatePlugins(name?: string) {
149
149
  const dirs = name
150
150
  ? [validatePluginDirectoryName(name)]
151
151
  : readdirSync(PLUGINS_DIR, { withFileTypes: true })
152
- .filter((entry) => entry.isDirectory())
152
+ .filter((entry) => entry.isDirectory() && isPluginDirectory(entry.name))
153
153
  .map((entry) => entry.name);
154
154
 
155
155
  if (dirs.length === 0) {
@@ -179,7 +179,8 @@ export async function updatePlugins(name?: string) {
179
179
 
180
180
  export function listPlugins() {
181
181
  ensurePluginsDir();
182
- const entries = readdirSync(PLUGINS_DIR, { withFileTypes: true }).filter((entry) => entry.isDirectory());
182
+ const entries = readdirSync(PLUGINS_DIR, { withFileTypes: true })
183
+ .filter((entry) => entry.isDirectory() && isPluginDirectory(entry.name));
183
184
 
184
185
  if (entries.length === 0) {
185
186
  console.log(cliStyles.muted("No plugins installed."));
@@ -3,7 +3,8 @@ import { tmpdir } from "os";
3
3
  import { join, resolve, sep } from "path";
4
4
  import type { AppConfig } from "../types/config";
5
5
  import type { ResolvedSeries } from "../time-series/types";
6
- import type { OptionsChain, TickerFinancials } from "../types/financials";
6
+ import type { OptionsChain, PricePoint, TickerFinancials } from "../types/financials";
7
+ import type { ManualChartResolution } from "../time-series/resolution";
7
8
  import type { TickerRecord } from "../types/ticker";
8
9
  import type { PaneRuntimeState } from "../core/state/app/state";
9
10
  import type { RemoteUiNodeSnapshot } from "../remote/types";
@@ -14,6 +15,19 @@ import {
14
15
  writeElectrobunViewPage,
15
16
  } from "../renderers/electrobun/view/build-assets";
16
17
 
18
+ export interface DesktopPaneShotIntradayHistory {
19
+ symbol: string;
20
+ exchange: string;
21
+ rangePreset: "1D" | "1W";
22
+ resolution: ManualChartResolution;
23
+ requestedSession: string | null;
24
+ sessionDates: string[];
25
+ points: PricePoint[];
26
+ start: string | null;
27
+ end: string | null;
28
+ unavailableReason: string | null;
29
+ }
30
+
17
31
  export interface DesktopPaneShotPayload {
18
32
  config: AppConfig;
19
33
  paneId: string;
@@ -27,6 +41,7 @@ export interface DesktopPaneShotPayload {
27
41
  watermark?: string | null;
28
42
  tickers: TickerRecord[];
29
43
  financials: Array<[string, TickerFinancials]>;
44
+ intradayHistories: DesktopPaneShotIntradayHistory[];
30
45
  optionsChains: Array<[string, OptionsChain]>;
31
46
  fredSeries: Array<[string, FredSeriesCacheEntry]>;
32
47
  valuationSeries: Array<[string, DatedObservation[]]>;
@@ -58,6 +73,8 @@ export interface DesktopPaneShotRenderedRow {
58
73
  export interface DesktopPaneShotRenderResult {
59
74
  visibleText: string;
60
75
  rows: DesktopPaneShotRenderedRow[];
76
+ truncated: boolean;
77
+ truncationReasons: string[];
61
78
  loadingStateDetected: boolean;
62
79
  errorStateDetected: boolean;
63
80
  errorStateMarkers: string[];
@@ -98,6 +115,7 @@ export async function renderDesktopPaneScreenshot(
98
115
  payload: DesktopPaneShotPayload,
99
116
  outputPath: string,
100
117
  apiProxy: DesktopPaneShotApiProxy,
118
+ options: { captureImage?: boolean } = {},
101
119
  ): Promise<DesktopPaneShotRenderResult> {
102
120
  const tempDir = await mkdtemp(join(tmpdir(), "gloom-pane-shot-"));
103
121
  let server: ReturnType<typeof Bun.serve> | null = null;
@@ -115,6 +133,7 @@ export async function renderDesktopPaneScreenshot(
115
133
  heightPx: payload.heightPx,
116
134
  deviceScaleFactor: payload.deviceScaleFactor ?? DEFAULT_DEVICE_SCALE_FACTOR,
117
135
  userDataDir: join(tempDir, "chrome-profile"),
136
+ captureImage: options.captureImage !== false,
118
137
  });
119
138
  } finally {
120
139
  server?.stop(true);
@@ -296,6 +315,7 @@ async function capturePageScreenshot({
296
315
  heightPx,
297
316
  deviceScaleFactor,
298
317
  userDataDir,
318
+ captureImage,
299
319
  }: {
300
320
  chrome: string;
301
321
  url: string;
@@ -304,6 +324,7 @@ async function capturePageScreenshot({
304
324
  heightPx: number;
305
325
  deviceScaleFactor: number;
306
326
  userDataDir: string;
327
+ captureImage: boolean;
307
328
  }): Promise<DesktopPaneShotRenderResult> {
308
329
  await mkdir(userDataDir, { recursive: true });
309
330
  const port = 43000 + Math.floor(Math.random() * 10000);
@@ -344,13 +365,15 @@ async function capturePageScreenshot({
344
365
  });
345
366
  await waitForShotReady(session);
346
367
  const rendered = await readRenderedPaneState(session);
347
- const screenshot = await session.send("Page.captureScreenshot", {
348
- format: "png",
349
- fromSurface: true,
350
- captureBeyondViewport: false,
351
- }) as { data?: string };
352
- if (!screenshot.data) throw new Error("Chrome did not return screenshot data.");
353
- await writeFile(outputPath, Uint8Array.from(Buffer.from(screenshot.data, "base64")));
368
+ if (captureImage) {
369
+ const screenshot = await session.send("Page.captureScreenshot", {
370
+ format: "png",
371
+ fromSurface: true,
372
+ captureBeyondViewport: false,
373
+ }) as { data?: string };
374
+ if (!screenshot.data) throw new Error("Chrome did not return screenshot data.");
375
+ await writeFile(outputPath, Uint8Array.from(Buffer.from(screenshot.data, "base64")));
376
+ }
354
377
  return rendered;
355
378
  } catch (error) {
356
379
  const message = error instanceof Error ? error.message : String(error);
@@ -373,6 +396,7 @@ const ERROR_STATE_PATTERNS = [
373
396
  /\bCould not load\b/gi,
374
397
  /\bSign in to\b/gi,
375
398
  /\bVerify your email\b/gi,
399
+ /\brequires signup and email verification\b/gi,
376
400
  /\bpart of Gloom Cloud Pro\b/gi,
377
401
  /\bCloud API request failed\b/gi,
378
402
  ];
@@ -416,6 +440,25 @@ async function readRenderedPaneState(session: CdpSession): Promise<DesktopPaneSh
416
440
  };
417
441
  const semanticTables = semanticUi.filter((node) => node && node.role === "table");
418
442
  const rows = [];
443
+ const truncationReasons = new Set();
444
+ const markScrollTruncation = (element) => {
445
+ if (!(element instanceof HTMLElement) || !isVisible(element)) return;
446
+ if (
447
+ element.getAttribute("data-gloom-scrollbar-y") === "visible"
448
+ || element.scrollHeight > element.clientHeight + 1
449
+ ) {
450
+ truncationReasons.add("rows extend below the rendered viewport");
451
+ }
452
+ if (
453
+ element.getAttribute("data-gloom-scrollbar-x") === "visible"
454
+ || element.scrollWidth > element.clientWidth + 1
455
+ ) {
456
+ truncationReasons.add("columns extend beyond the rendered viewport");
457
+ }
458
+ };
459
+ [...root.querySelectorAll(
460
+ '[data-gloom-scrollbar-x="visible"], [data-gloom-scrollbar-y="visible"], [data-gloom-role="data-table-body-scroll"]',
461
+ )].forEach(markScrollTruncation);
419
462
  [...root.querySelectorAll('[data-gloom-role="data-table"]')]
420
463
  .filter(isVisible)
421
464
  .forEach((table, tableIndex) => {
@@ -433,12 +476,24 @@ async function readRenderedPaneState(session: CdpSession): Promise<DesktopPaneSh
433
476
  const semanticRow = semanticRows[rowIndex] || {};
434
477
  const cells = values.map((cell, cellIndex) => {
435
478
  const semanticColumn = semanticColumns[cellIndex] || {};
479
+ const text = normalize(cell.innerText || cell.textContent);
480
+ const textNodes = [cell, ...cell.querySelectorAll('*')];
481
+ if (
482
+ /\\u2026|\\.\\.\\./.test(text)
483
+ || textNodes.some((node) => (
484
+ node instanceof HTMLElement
485
+ && isVisible(node)
486
+ && node.scrollWidth > node.clientWidth + 1
487
+ ))
488
+ ) {
489
+ truncationReasons.add("one or more cells are visibly clipped");
490
+ }
436
491
  return {
437
492
  ...(typeof semanticColumn.id === "string" ? { columnId: semanticColumn.id } : {}),
438
493
  columnLabel: typeof semanticColumn.label === "string"
439
494
  ? semanticColumn.label
440
495
  : headers[cellIndex] || (values.length === 1 ? "Row" : String(cellIndex + 1)),
441
- text: normalize(cell.innerText || cell.textContent),
496
+ text,
442
497
  };
443
498
  }).filter((cell) => cell.text.length > 0);
444
499
  if (cells.length === 0) return;
@@ -472,6 +527,8 @@ async function readRenderedPaneState(session: CdpSession): Promise<DesktopPaneSh
472
527
  errorStateDetected: root.querySelector('[data-gloom-status="error"]') !== null,
473
528
  emptyStateDetected: root.querySelector('[data-gloom-status="empty"]') !== null,
474
529
  rows,
530
+ truncated: truncationReasons.size > 0,
531
+ truncationReasons: [...truncationReasons],
475
532
  semanticUi,
476
533
  };
477
534
  })()`,
@@ -485,6 +542,8 @@ async function readRenderedPaneState(session: CdpSession): Promise<DesktopPaneSh
485
542
  errorStateDetected?: boolean;
486
543
  emptyStateDetected?: boolean;
487
544
  rows?: DesktopPaneShotRenderedRow[];
545
+ truncated?: boolean;
546
+ truncationReasons?: string[];
488
547
  semanticUi?: RemoteUiNodeSnapshot[];
489
548
  };
490
549
  };
@@ -495,9 +554,19 @@ async function readRenderedPaneState(session: CdpSession): Promise<DesktopPaneSh
495
554
  const loadingStateMarkers = stateMarkers(visibleText, LOADING_STATE_PATTERNS);
496
555
  const errorStateMarkers = stateMarkers(visibleText, ERROR_STATE_PATTERNS);
497
556
  const emptyStateMarkers = stateMarkers(visibleText, EMPTY_STATE_PATTERNS);
557
+ const rows = Array.isArray(value?.rows) ? value.rows : [];
558
+ const textShowsEllipsis = rows.some((row) => row.cells.some((cell) => /\u2026|\.\.\./.test(cell.text)));
559
+ const truncationReasons = Array.isArray(value?.truncationReasons)
560
+ ? value.truncationReasons.filter((reason): reason is string => typeof reason === "string")
561
+ : [];
562
+ if (textShowsEllipsis && !truncationReasons.includes("one or more cells are visibly clipped")) {
563
+ truncationReasons.push("one or more cells are visibly clipped");
564
+ }
498
565
  return {
499
566
  visibleText,
500
- rows: Array.isArray(value?.rows) ? value.rows : [],
567
+ rows,
568
+ truncated: value?.truncated === true || textShowsEllipsis,
569
+ truncationReasons,
501
570
  loadingStateDetected: value?.loadingStateDetected === true || loadingStateMarkers.length > 0,
502
571
  errorStateDetected: value?.errorStateDetected === true || errorStateMarkers.length > 0,
503
572
  errorStateMarkers,
@@ -9,8 +9,8 @@ import type {
9
9
  PaneTemplateDef,
10
10
  } from "../../types/plugin";
11
11
 
12
- export type PaneFunctionReadiness = "ready" | "partial" | "unsupported";
13
- export type PaneFunctionScreenshotReadiness = PaneFunctionReadiness | "live-dom";
12
+ export type PaneFunctionReadiness = "ready" | "partial" | "live-dom" | "unsupported";
13
+ export type PaneFunctionScreenshotReadiness = PaneFunctionReadiness;
14
14
  export type PaneFunctionTickerCardinality = "none" | "one" | "one-or-more" | "two-or-more" | "one-or-two";
15
15
  export type PaneFunctionOptionType = HeadlessPaneOptionType;
16
16
  export type PaneFunctionOptionValue = HeadlessPaneOptionValue;
@@ -376,28 +376,80 @@ const CAPABILITIES: Record<string, PaneFunctionCapability> = {
376
376
  intents: ["chart intraday security prices"],
377
377
  outputKind: "price-history",
378
378
  reportReadiness: "ready",
379
- screenshotReadiness: "partial",
379
+ screenshotReadiness: "ready",
380
380
  dataRequirements: ["intraday price history"],
381
- limitations: ["Intraday availability depends on the selected market-data provider."],
382
- options: [],
381
+ limitations: ["Intraday availability and historical retention depend on the selected market-data provider."],
382
+ options: [
383
+ {
384
+ key: "rangePreset",
385
+ description: "Session window. 1D selects the latest session and 1W selects the latest five sessions.",
386
+ type: "enum",
387
+ aliases: ["range"],
388
+ values: RANGE_VALUES.filter(({ value }) => value === "1D" || value === "1W"),
389
+ defaultValue: "1D",
390
+ },
391
+ {
392
+ key: "chartResolution",
393
+ description: "Intraday sampling interval. Auto uses 1m for 1D and 5m for 1W.",
394
+ type: "enum",
395
+ aliases: ["resolution"],
396
+ values: ["auto", "5m", "15m", "1h"].map((value) => ({ value })),
397
+ defaultValue: "auto",
398
+ },
399
+ {
400
+ key: "session",
401
+ description: "Specific exchange-local session in YYYY-MM-DD form. Overrides the session window.",
402
+ type: "string",
403
+ },
404
+ ],
383
405
  },
384
406
  };
385
407
 
386
- const UNSUPPORTED_CAPABILITY: PaneFunctionCapability = {
387
- id: "unverified-pane",
388
- botSafe: false,
389
- tickerCardinality: "none",
390
- aliases: [],
391
- intents: [],
392
- outputKind: "pane",
393
- reportReadiness: "unsupported",
394
- screenshotReadiness: "live-dom",
395
- dataRequirements: [],
396
- limitations: [
397
- "Reports are not verified for automation. Screenshots render live and derive evidence from visible DOM rows.",
398
- ],
399
- options: [],
400
- };
408
+ const NON_DATA_PANE_IDS = new Set([
409
+ "account-management",
410
+ "brokers",
411
+ "changelog",
412
+ "chat",
413
+ "connections",
414
+ "data-catalog",
415
+ "debug",
416
+ "help",
417
+ "ibkr-trading",
418
+ "layout-marketplace",
419
+ "local-agent-workspace",
420
+ "macro-tv",
421
+ "plugin-marketplace",
422
+ "world-venue-map",
423
+ ]);
424
+
425
+ const RENDERED_VIEW_LIMITATION =
426
+ "Reports contain only values exposed by the rendered view and may omit clipped or off-screen data.";
427
+
428
+ function fallbackCapability(
429
+ template: PaneTemplateDef | undefined,
430
+ pane: PaneDef,
431
+ ): PaneFunctionCapability {
432
+ const dataPane = isDataPaneForDomFallback(pane);
433
+ return {
434
+ id: template?.id ?? pane.id,
435
+ botSafe: false,
436
+ tickerCardinality: "none",
437
+ aliases: [],
438
+ intents: [],
439
+ outputKind: dataPane ? "rendered-view" : "pane",
440
+ reportReadiness: dataPane ? "live-dom" : "unsupported",
441
+ screenshotReadiness: "live-dom",
442
+ dataRequirements: [],
443
+ limitations: [dataPane
444
+ ? RENDERED_VIEW_LIMITATION
445
+ : "This pane is an interactive surface and does not expose a data report."],
446
+ options: [],
447
+ };
448
+ }
449
+
450
+ export function isDataPaneForDomFallback(pane: Pick<PaneDef, "id">): boolean {
451
+ return !NON_DATA_PANE_IDS.has(pane.id);
452
+ }
401
453
 
402
454
  function optionToken(value: string): string {
403
455
  return value.trim().toLowerCase().replace(/[\s_.-]+/g, "");
@@ -431,13 +483,14 @@ export function getPaneFunctionCapability(
431
483
  template: PaneTemplateDef | undefined,
432
484
  pane: PaneDef,
433
485
  ): PaneFunctionCapability {
434
- const existing = template ? CAPABILITIES[template.id] ?? UNSUPPORTED_CAPABILITY : UNSUPPORTED_CAPABILITY;
486
+ const fallback = fallbackCapability(template, pane);
487
+ const existing = template ? CAPABILITIES[template.id] ?? fallback : fallback;
435
488
  const headless = getHeadlessPaneDefinition(template, pane);
436
489
  if (!headless) return existing;
437
490
 
438
- const hasExistingCapability = existing !== UNSUPPORTED_CAPABILITY;
491
+ const hasExistingCapability = existing !== fallback;
439
492
  return {
440
- ...(hasExistingCapability ? existing : UNSUPPORTED_CAPABILITY),
493
+ ...(hasExistingCapability ? existing : fallback),
441
494
  id: hasExistingCapability ? existing.id : template?.id ?? pane.id,
442
495
  botSafe: true,
443
496
  tickerCardinality: headlessTickerCardinality(headless.argument),
@@ -0,0 +1,41 @@
1
+ import { expect, test } from "bun:test";
2
+ import type { ResolvedPaneFunction } from "./resolver";
3
+ import type { PaneScreenshotResult } from "./screenshot";
4
+ import { buildDomPaneReportFromRender } from "./dom";
5
+
6
+ test("marks rendered reports as truncated when a visible cell contains an ellipsis", () => {
7
+ const resolved = {
8
+ token: "INS",
9
+ label: "Insider",
10
+ capability: { id: "insider-pane" },
11
+ options: {},
12
+ } as unknown as ResolvedPaneFunction;
13
+ const screenshot = {
14
+ symbols: ["AVGO"],
15
+ render: {
16
+ visibleText: "NVIDIA Corporati…",
17
+ rows: [{
18
+ tableIndex: 0,
19
+ rowIndex: 0,
20
+ selected: false,
21
+ cells: [{ columnLabel: "Issuer", text: "NVIDIA Corporati…" }],
22
+ }],
23
+ truncated: false,
24
+ truncationReasons: [],
25
+ loadingStateDetected: false,
26
+ errorStateDetected: false,
27
+ errorStateMarkers: [],
28
+ emptyStateDetected: false,
29
+ emptyStateMarkers: [],
30
+ },
31
+ } as unknown as PaneScreenshotResult;
32
+
33
+ const report = buildDomPaneReportFromRender(resolved, screenshot);
34
+ expect(report.data).toMatchObject({
35
+ kind: "rendered-view",
36
+ rowCount: 1,
37
+ truncated: true,
38
+ complete: false,
39
+ limitation: expect.stringContaining("visible"),
40
+ });
41
+ });