gloomberb 0.10.2 → 0.10.4

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 (164) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +2 -2
  3. package/package.json +2 -1
  4. package/src/api-client/auth.ts +8 -0
  5. package/src/api-client/index.ts +70 -0
  6. package/src/api-client/types.ts +49 -0
  7. package/src/app/runtime/desktop-deeplink.test.ts +8 -0
  8. package/src/app/runtime/desktop-deeplink.ts +14 -1
  9. package/src/components/chart/composite/composite-chart.test.tsx +340 -17
  10. package/src/components/chart/composite/composite-chart.tsx +859 -68
  11. package/src/components/chart/composite/interactions.test.ts +78 -22
  12. package/src/components/chart/composite/interactions.ts +45 -17
  13. package/src/components/chart/composite/rasterizer.ts +8 -2
  14. package/src/components/chart/composite/renderers.test.ts +61 -1
  15. package/src/components/chart/composite/scene.ts +14 -4
  16. package/src/components/chart/composite/tools.test.ts +371 -0
  17. package/src/components/chart/composite/tools.ts +508 -0
  18. package/src/components/chart/composite/types.ts +4 -0
  19. package/src/components/chart/core/pointer.ts +2 -0
  20. package/src/components/chart/native/raster/primitives.ts +18 -0
  21. package/src/components/chart/price-axis-labels.tsx +42 -2
  22. package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
  23. package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
  24. package/src/components/command-bar/action-runtime.ts +3 -0
  25. package/src/components/command-bar/assist/inventory.test.ts +93 -0
  26. package/src/components/command-bar/assist/inventory.ts +92 -0
  27. package/src/components/command-bar/assist/model.test.ts +104 -0
  28. package/src/components/command-bar/assist/model.ts +169 -0
  29. package/src/components/command-bar/assist/runtime.ts +185 -0
  30. package/src/components/command-bar/commands/direct/index.ts +1 -1
  31. package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
  32. package/src/components/command-bar/list/model.ts +15 -2
  33. package/src/components/command-bar/list/navigation.ts +6 -0
  34. package/src/components/command-bar/list/view.tsx +16 -3
  35. package/src/components/command-bar/pane-templates/workflow.ts +1 -0
  36. package/src/components/command-bar/panel/body.tsx +2 -0
  37. package/src/components/command-bar/panel/index.tsx +3 -0
  38. package/src/components/command-bar/panel/runtime.ts +6 -0
  39. package/src/components/command-bar/panel/types.ts +1 -0
  40. package/src/components/command-bar/routes/root/results.ts +28 -2
  41. package/src/components/command-bar/routes/root/runtime.ts +66 -36
  42. package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
  43. package/src/components/command-bar/routing/navigation-state.ts +17 -0
  44. package/src/components/command-bar/selection-runtime.ts +26 -0
  45. package/src/components/command-bar/surface/assist.test.tsx +276 -0
  46. package/src/components/command-bar/surface/index.tsx +93 -2
  47. package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
  48. package/src/components/command-bar/view-model.ts +7 -1
  49. package/src/components/command-bar/workflow/broker.test.ts +22 -0
  50. package/src/components/command-bar/workflow/broker.ts +1 -0
  51. package/src/components/index.ts +1 -1
  52. package/src/components/layout/detached-pane-shell.tsx +40 -2
  53. package/src/components/layout/floating-pane.tsx +4 -1
  54. package/src/components/layout/pane/header.tsx +64 -6
  55. package/src/components/layout/pane/index.tsx +4 -1
  56. package/src/components/layout/shell/index.tsx +23 -0
  57. package/src/components/layout/shell/pane/layers.tsx +5 -0
  58. package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
  59. package/src/components/onboarding/account-step/form-panel.tsx +157 -0
  60. package/src/components/onboarding/account-step/index.tsx +58 -0
  61. package/src/components/onboarding/account-step/model.test.ts +67 -0
  62. package/src/components/onboarding/account-step/model.ts +126 -0
  63. package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
  64. package/src/components/onboarding/onboarding-steps.tsx +91 -55
  65. package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
  66. package/src/components/onboarding/onboarding-wizard.tsx +93 -9
  67. package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
  68. package/src/components/onboarding/wizard-account.ts +235 -0
  69. package/src/components/onboarding/wizard-keyboard.ts +67 -1
  70. package/src/components/onboarding/wizard-model.ts +2 -2
  71. package/src/components/ui/data-table/index.tsx +1 -0
  72. package/src/components/ui/data-table/opentui/index.tsx +44 -2
  73. package/src/components/ui/data-table/types.ts +10 -0
  74. package/src/components/ui/data-table/visible-range.test.ts +29 -0
  75. package/src/components/ui/data-table/visible-range.ts +21 -0
  76. package/src/components/ui/ui.test.tsx +23 -1
  77. package/src/i18n/es.ts +1093 -0
  78. package/src/i18n/index.test.ts +5 -2
  79. package/src/i18n/index.ts +3 -0
  80. package/src/i18n/ja.ts +66 -3
  81. package/src/i18n/ko.ts +66 -3
  82. package/src/i18n/languages.test.ts +4 -1
  83. package/src/i18n/languages.ts +12 -1
  84. package/src/i18n/zh-cn.ts +66 -3
  85. package/src/i18n/zh-tw.ts +66 -3
  86. package/src/market-data/coordinator/index.ts +1 -1
  87. package/src/market-data/coordinator/quotes.ts +64 -1
  88. package/src/market-data/coordinator/subscriptions.test.ts +72 -0
  89. package/src/market-data/quotes/cadence.ts +2 -0
  90. package/src/plugins/builtin/account-management/footer.ts +9 -3
  91. package/src/plugins/builtin/account-management/model.ts +53 -1
  92. package/src/plugins/builtin/account-management/pane.tsx +107 -24
  93. package/src/plugins/builtin/ai/index.tsx +7 -2
  94. package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
  95. package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
  96. package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
  97. package/src/plugins/builtin/chart-composer/index.tsx +10 -2
  98. package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
  99. package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
  100. package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
  101. package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
  102. package/src/plugins/builtin/cloud/plugin.tsx +9 -1
  103. package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
  104. package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
  105. package/src/plugins/builtin/help/index.tsx +38 -0
  106. package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
  107. package/src/plugins/builtin/options/footer.test.ts +20 -112
  108. package/src/plugins/builtin/options/footer.ts +35 -78
  109. package/src/plugins/builtin/options/index.tsx +6 -0
  110. package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
  111. package/src/plugins/builtin/options/live-quotes.ts +93 -39
  112. package/src/plugins/builtin/options/view.test.tsx +5 -2
  113. package/src/plugins/builtin/options/view.tsx +49 -9
  114. package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
  115. package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
  116. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
  117. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
  118. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
  119. package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
  120. package/src/plugins/builtin/shared/live-streaming.ts +60 -0
  121. package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
  122. package/src/plugins/builtin/shared/plan-access.ts +109 -0
  123. package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
  124. package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
  125. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
  126. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
  127. package/src/plugins/registry/index.test.ts +51 -0
  128. package/src/plugins/registry/index.ts +12 -0
  129. package/src/plugins/registry/pane-settings.ts +29 -0
  130. package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
  131. package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
  132. package/src/renderers/electrobun/view/host/box.tsx +3 -0
  133. package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
  134. package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
  135. package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
  136. package/src/renderers/electrobun/view/host/input.tsx +16 -1
  137. package/src/renderers/electrobun/view/host/mouse.ts +1 -0
  138. package/src/renderers/electrobun/view/host/style.ts +1 -1
  139. package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
  140. package/src/renderers/electrobun/view/main.tsx +2 -0
  141. package/src/renderers/opentui/chart-surface.tsx +2 -0
  142. package/src/renderers/opentui/host.tsx +3 -0
  143. package/src/sources/gloomberb-cloud/index.test.ts +8 -0
  144. package/src/sources/gloomberb-cloud/index.ts +4 -4
  145. package/src/sources/provider-router/history.test.ts +3 -3
  146. package/src/sources/provider-router/history.ts +15 -4
  147. package/src/sources/yahoo-finance/history.test.ts +47 -0
  148. package/src/sources/yahoo-finance/history.ts +5 -1
  149. package/src/state/hooks/quote-streaming.test.tsx +181 -1
  150. package/src/state/hooks/quote-streaming.ts +93 -7
  151. package/src/theme/colors.ts +20 -1
  152. package/src/theme/themes.test.ts +3 -0
  153. package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
  154. package/src/time-series/resolution.test.ts +51 -0
  155. package/src/time-series/resolution.ts +41 -0
  156. package/src/time-series/resolve.test.ts +207 -1
  157. package/src/time-series/resolve.ts +196 -29
  158. package/src/time-series/spec-studies.test.ts +4 -0
  159. package/src/time-series/spec.ts +15 -1
  160. package/src/time-series/types.ts +7 -0
  161. package/src/time-series/use-chart-resolution.test.tsx +37 -0
  162. package/src/time-series/use-chart-resolution.ts +17 -1
  163. package/src/types/plugin.ts +9 -0
  164. package/src/ui/host.tsx +17 -0
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, jest, test } from "bun:test";
2
2
  import { MarketDataCoordinator } from "./index";
3
3
  import { buildQuoteKey } from "../selectors";
4
+ import { QUOTE_STREAM_UPDATE_THROTTLE_MS } from "../quotes/cadence";
4
5
  import { createTestDataProvider } from "../../test-support/data-provider";
5
6
  import type { Quote } from "../../types/financials";
6
7
  import type { DataProvider, QuoteSubscriptionTarget } from "../../types/data-provider";
@@ -208,6 +209,38 @@ describe("MarketDataCoordinator key subscriptions", () => {
208
209
  expect(coordinator.getVersion()).toBe(1);
209
210
  });
210
211
 
212
+ test("coalesces bursty stream quotes into one readable update cadence", () => {
213
+ jest.useFakeTimers();
214
+ const originalDateNow = Date.now;
215
+ let now = 1_700_000_000_000;
216
+ Date.now = () => now;
217
+
218
+ try {
219
+ const { provider, emitQuote } = createProvider();
220
+ const coordinator = new MarketDataCoordinator(provider);
221
+ const amd = { symbol: "AMD", exchange: "NASDAQ" };
222
+ const msft = { symbol: "MSFT", exchange: "NASDAQ" };
223
+ coordinator.subscribeQuotes([{ instrument: amd }, { instrument: msft }]);
224
+
225
+ emitQuote(amd, quote("AMD", 100));
226
+ expect(coordinator.getQuoteEntry(amd).data?.price).toBe(100);
227
+
228
+ now += 100;
229
+ emitQuote(msft, quote("MSFT", 200));
230
+ emitQuote(amd, quote("AMD", 101));
231
+ expect(coordinator.getQuoteEntry(amd).data?.price).toBe(100);
232
+ expect(coordinator.getQuoteEntry(msft).data).toBeNull();
233
+
234
+ now += QUOTE_STREAM_UPDATE_THROTTLE_MS - 100;
235
+ jest.advanceTimersByTime(QUOTE_STREAM_UPDATE_THROTTLE_MS - 100);
236
+ expect(coordinator.getQuoteEntry(amd).data?.price).toBe(101);
237
+ expect(coordinator.getQuoteEntry(msft).data?.price).toBe(200);
238
+ } finally {
239
+ Date.now = originalDateNow;
240
+ jest.useRealTimers();
241
+ }
242
+ });
243
+
211
244
  test("applies repeated stream quotes that refresh quote freshness", async () => {
212
245
  const realDateNow = Date.now;
213
246
  const { provider, emitQuote } = createProvider();
@@ -245,6 +278,45 @@ describe("MarketDataCoordinator key subscriptions", () => {
245
278
  }
246
279
  });
247
280
 
281
+ test("advances receipt freshness for an identical live stream heartbeat", async () => {
282
+ const realDateNow = Date.now;
283
+ const { provider, emitQuote } = createProvider();
284
+ const coordinator = new MarketDataCoordinator(provider);
285
+ const option = { symbol: "AAPL260731C00110000", exchange: "OPTIONS" };
286
+ const firstTimestamp = 1_800_000_000_000;
287
+ let calls = 0;
288
+
289
+ try {
290
+ coordinator.subscribeKeys([buildQuoteKey(option)], () => { calls += 1; });
291
+ coordinator.subscribeQuotes([{ instrument: option }]);
292
+
293
+ Date.now = () => firstTimestamp;
294
+ const heartbeat = quote(option.symbol, 2.5, {
295
+ lastUpdated: firstTimestamp,
296
+ bid: 2.4,
297
+ ask: 2.6,
298
+ mark: 2.5,
299
+ dataSource: "live",
300
+ delivery: "stream",
301
+ stale: false,
302
+ });
303
+ emitQuote(option, heartbeat);
304
+ await flushCoordinator();
305
+
306
+ Date.now = () => firstTimestamp + 60_000;
307
+ emitQuote(option, heartbeat);
308
+ await flushCoordinator();
309
+
310
+ expect(calls).toBe(2);
311
+ expect(coordinator.getQuoteEntry(option).data).toMatchObject({
312
+ lastUpdated: firstTimestamp,
313
+ receivedAt: firstTimestamp + 60_000,
314
+ });
315
+ } finally {
316
+ Date.now = realDateNow;
317
+ }
318
+ });
319
+
248
320
  test("does not refresh receipt time from an explicitly stale polled quote", async () => {
249
321
  const realDateNow = Date.now;
250
322
  const { provider, emitQuote } = createProvider();
@@ -0,0 +1,2 @@
1
+ // Leave a short gap after the 450 ms value flash before the next streamed update.
2
+ export const QUOTE_STREAM_UPDATE_THROTTLE_MS = 500;
@@ -1,8 +1,9 @@
1
1
  import { useMemo } from "react";
2
2
  import { usePaneFooter, type PaneHint } from "../../../components";
3
3
  import type { AccountProfile } from "../../../api-client";
4
+ import { resolvePlanAccess } from "../shared/plan-access";
4
5
  import { formatPlan, type AccountDraft } from "./model";
5
- import { t } from "../../../i18n";
6
+ import { t, tf } from "../../../i18n";
6
7
  import { useAppLanguage } from "../../../i18n/react";
7
8
 
8
9
  export function useAccountManagementFooter({
@@ -25,15 +26,20 @@ export function useAccountManagementFooter({
25
26
  { id: "save", key: "Ctrl+S", label: t("save"), onPress: () => { void saveProfile(); }, disabled: !!busy || !hasSession },
26
27
  ], [busy, hasSession, language, saveProfile]);
27
28
 
29
+ const planAccess = resolvePlanAccess(profile);
30
+ const planText = planAccess.isTrialActive
31
+ ? tf("Pro trial · {days}d left", { days: planAccess.trialDaysLeft })
32
+ : formatPlan(profile?.plan);
33
+
28
34
  usePaneFooter("account-management", () => ({
29
35
  info: [
30
36
  ...(message ? [{ id: "status", parts: [{ text: message.text, tone: message.tone === "error" ? "negative" as const : message.tone === "success" ? "positive" as const : "muted" as const }] }] : []),
31
37
  ...(profile ? [
32
38
  { id: "account", parts: [{ text: profile.email, tone: "muted" as const }] },
33
- { id: "plan", parts: [{ text: formatPlan(profile.plan), tone: profile.plan === "pro" ? "positive" as const : "muted" as const }] },
39
+ { id: "plan", parts: [{ text: planText, tone: planAccess.hasProAccess ? "positive" as const : "muted" as const }] },
34
40
  { id: "visibility", parts: [{ text: draft.profilePublic ? t("public") : t("private"), tone: "muted" as const }] },
35
41
  ] : []),
36
42
  ],
37
43
  hints: footerHints,
38
- }), [draft.profilePublic, footerHints, language, message, profile]);
44
+ }), [draft.profilePublic, footerHints, language, message, planAccess.hasProAccess, planText, profile]);
39
45
  }
@@ -1,5 +1,5 @@
1
1
  import type { ChoiceDialogChoice } from "../../../components";
2
- import type { AccountProfile, PublicPortfolioAnalytics } from "../../../api-client";
2
+ import type { AccountProfile, CloudPricing, PublicPortfolioAnalytics } from "../../../api-client";
3
3
  import type { Portfolio, TickerRecord } from "../../../types/ticker";
4
4
  import { formatNumber } from "../../../utils/format";
5
5
  import { t, tf } from "../../../i18n";
@@ -86,6 +86,58 @@ export function formatPlan(plan: AccountProfile["plan"] | null | undefined): str
86
86
  return plan === "pro" ? t("Pro") : t("Free");
87
87
  }
88
88
 
89
+ const DEFAULT_TRIAL_DAYS = 7;
90
+
91
+ export interface PlanPriceDisplay {
92
+ /** Charged price, already suffixed with the billing period. */
93
+ price: string;
94
+ /** List price to strike through, or null when the list price is the price. */
95
+ anchor: string | null;
96
+ note: string | null;
97
+ }
98
+
99
+ function formatUsdCents(cents: number): string {
100
+ const dollars = cents / 100;
101
+ return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`;
102
+ }
103
+
104
+ /**
105
+ * Monthly Pro price for the plan comparison. Falls back to the list price when
106
+ * `/pricing` is unreachable so the table never renders a blank cell.
107
+ */
108
+ export function formatCloudMonthlyPrice(pricing: CloudPricing | null | undefined): PlanPriceDisplay {
109
+ if (!pricing) return { price: t("$49/mo"), anchor: null, note: null };
110
+ const monthly = pricing.monthly;
111
+ // Without the founding discount the list price is the price, shown plainly.
112
+ if (!pricing.founding || monthly.anchorAmount <= monthly.amount) {
113
+ return {
114
+ price: tf("{amount}/mo", { amount: formatUsdCents(monthly.anchorAmount) }),
115
+ anchor: null,
116
+ note: null,
117
+ };
118
+ }
119
+ return {
120
+ price: tf("{amount}/mo", { amount: formatUsdCents(monthly.amount) }),
121
+ anchor: tf("{amount}/mo", { amount: formatUsdCents(monthly.anchorAmount) }),
122
+ note: t("Founding price"),
123
+ };
124
+ }
125
+
126
+ /** Short calendar date for the trial countdown, e.g. "Aug 11". */
127
+ export function formatTrialEnd(endsAt: Date | null | undefined): string | null {
128
+ if (!endsAt || Number.isNaN(endsAt.getTime())) return null;
129
+ return endsAt.toLocaleDateString("en-US", { month: "short", day: "numeric" });
130
+ }
131
+
132
+ /**
133
+ * Pro-column note for the plan comparison, e.g. "7-day free trial". The trial
134
+ * is part of the Pro subscription (card required, cancel before it converts),
135
+ * so it is never advertised as something the free plan includes.
136
+ */
137
+ export function formatTrialOffer(pricing: CloudPricing | null | undefined): string {
138
+ return tf("{days}-day free trial", { days: pricing?.trialDays ?? DEFAULT_TRIAL_DAYS });
139
+ }
140
+
89
141
  export function countPortfolioHoldings(tickers: ReadonlyMap<string, TickerRecord>): Record<string, number> {
90
142
  const counts: Record<string, number> = {};
91
143
  for (const record of tickers.values()) {
@@ -8,7 +8,7 @@ import type { PaneProps } from "../../../types/plugin";
8
8
  import { Box, ScrollBox, Text, Textarea, TextAttributes, type TextareaRenderable, useRendererHost, useUiHost } from "../../../ui";
9
9
  import { useDialog, type AlertContext, type PromptContext } from "../../../ui/dialog";
10
10
  import { openNativeSelect, type NativeSelectElement } from "../../../components/ui/native-select";
11
- import { apiClient, type AccountProfile } from "../../../api-client";
11
+ import { apiClient, type AccountProfile, type CloudPricing } from "../../../api-client";
12
12
  import { chatController } from "../chat/controller";
13
13
  import { CloudAuthNotice } from "../cloud/auth-actions";
14
14
  import {
@@ -26,13 +26,17 @@ import {
26
26
  computeCumulativeReturn,
27
27
  countPortfolioHoldings,
28
28
  emptyToNull,
29
+ formatCloudMonthlyPrice,
29
30
  formatPlan,
31
+ formatTrialEnd,
32
+ formatTrialOffer,
30
33
  getPortfolioPositionTickers,
31
34
  portfolioOptionIds,
32
35
  profileToDraft,
33
36
  selectedPortfolioLabel,
34
37
  type AccountDraft,
35
38
  type AccountFieldKey,
39
+ type PlanPriceDisplay,
36
40
  } from "./model";
37
41
  import { PasswordChangeDialog } from "./password-dialog";
38
42
  import { useAccountManagementFooter } from "./footer";
@@ -47,7 +51,7 @@ import { computeDatedBeta } from "../analytics/metrics";
47
51
  import { useCloudSyncStatus } from "../../../sync/react";
48
52
  import { cloudSyncController } from "../../../sync/controller";
49
53
  import { setSyncedProfileAnalytics } from "../../../sync/profile-analytics";
50
- import { t } from "../../../i18n";
54
+ import { t, tf } from "../../../i18n";
51
55
  import { useAppLanguage } from "../../../i18n/react";
52
56
  import {
53
57
  consumeRequestedAccountManagementTab,
@@ -55,6 +59,7 @@ import {
55
59
  type AccountManagementTab,
56
60
  } from "./navigation";
57
61
  import { CLOUD_UPGRADE_URL } from "../shared/cloud-upgrade";
62
+ import { resolvePlanAccess } from "../shared/plan-access";
58
63
 
59
64
  type AccountBusy = "profile" | "password" | "alerts" | "billing" | "delete" | null;
60
65
  const ACCOUNT_TAB_DEFS: Array<{ label: string; value: AccountManagementTab }> = [
@@ -91,6 +96,7 @@ const PLAN_COMPARISON_ROWS = [
91
96
  { capability: "US equities", free: "15m delay", pro: "Real-time", proTone: "positive" },
92
97
  { capability: "Options data", free: "15m delay", pro: "Real-time", proTone: "positive" },
93
98
  { capability: "News", free: "12h delay", pro: "Real-time wire", proTone: "positive" },
99
+ { capability: "AI command bar", free: "Included", pro: "Included", proTone: "neutral" },
94
100
  { capability: "Cloud sync", free: "Included", pro: "Included", proTone: "neutral" },
95
101
  { capability: "X data", free: "No", pro: "Included", proTone: "positive" },
96
102
  { capability: "AI Screener", free: "No", pro: "Soon", proTone: "muted" },
@@ -99,10 +105,15 @@ const PLAN_COMPARISON_ROWS = [
99
105
  function PlanComparison({
100
106
  width,
101
107
  activePlan,
108
+ proNote,
109
+ price,
102
110
  upgradeButton,
103
111
  }: {
104
112
  width: number;
105
113
  activePlan: "free" | "pro";
114
+ /** Pro-column detail such as the free-trial offer; null once entitled. */
115
+ proNote: string | null;
116
+ price: PlanPriceDisplay;
106
117
  upgradeButton: ReactNode;
107
118
  }) {
108
119
  const isDesktop = useUiHost().kind === "desktop-web";
@@ -162,17 +173,34 @@ function PlanComparison({
162
173
  >
163
174
  {t("Free")}
164
175
  </Text>
165
- <Box flexDirection="row" alignItems="baseline" gap={1}>
166
- <Text
167
- fg={colors.borderFocused}
168
- attributes={TextAttributes.BOLD}
169
- style={{ ...desktopText, fontWeight: 750 }}
170
- >
171
- {t("Pro")}
172
- </Text>
173
- <Text fg={colors.textBright} style={desktopText}>
174
- {t("$49/mo")}
175
- </Text>
176
+ <Box flexDirection="column">
177
+ <Box flexDirection="row" alignItems="baseline" gap={1}>
178
+ <Text
179
+ fg={colors.borderFocused}
180
+ attributes={TextAttributes.BOLD}
181
+ style={{ ...desktopText, fontWeight: 750 }}
182
+ >
183
+ {t("Pro")}
184
+ </Text>
185
+ {price.anchor ? (
186
+ <Text
187
+ fg={colors.textMuted}
188
+ attributes={TextAttributes.STRIKETHROUGH}
189
+ style={desktopText}
190
+ >
191
+ {price.anchor}
192
+ </Text>
193
+ ) : null}
194
+ <Text fg={colors.textBright} style={desktopText}>
195
+ {price.price}
196
+ </Text>
197
+ {price.note ? (
198
+ <Text fg={colors.positive} style={desktopText}>{price.note}</Text>
199
+ ) : null}
200
+ </Box>
201
+ {proNote ? (
202
+ <Text fg={colors.textMuted} style={desktopText}>{proNote}</Text>
203
+ ) : null}
176
204
  </Box>
177
205
  </Box>
178
206
  {PLAN_COMPARISON_ROWS.map((row, index) => {
@@ -260,7 +288,7 @@ function PlanComparison({
260
288
  </Text>
261
289
  <Box width={valueWidth} backgroundColor={proCellBg} paddingX={1}>
262
290
  <Text fg={proHeadingFg} attributes={TextAttributes.BOLD}>
263
- {`${t("Pro")} ${t("$49/mo")}`}
291
+ {`${t("Pro")} ${price.price}`}
264
292
  </Text>
265
293
  </Box>
266
294
  </Box>
@@ -275,6 +303,21 @@ function PlanComparison({
275
303
  </Box>
276
304
  </Box>
277
305
  ))}
306
+ {price.anchor ? (
307
+ <Box height={1} flexDirection="row" gap={1}>
308
+ <Text fg={colors.textMuted} attributes={TextAttributes.STRIKETHROUGH}>{price.anchor}</Text>
309
+ {price.note ? <Text fg={colors.positive}>{price.note}</Text> : null}
310
+ </Box>
311
+ ) : null}
312
+ {proNote ? (
313
+ <Box height={1} flexDirection="row" gap={1}>
314
+ <Box width={capabilityWidth} />
315
+ <Box width={valueWidth} />
316
+ <Box width={valueWidth} paddingX={1}>
317
+ <Text fg={colors.textMuted}>{proNote}</Text>
318
+ </Box>
319
+ </Box>
320
+ ) : null}
278
321
  <Box height={1} flexDirection="row" gap={1}>
279
322
  <Box width={capabilityWidth} />
280
323
  <Box width={valueWidth} />
@@ -286,6 +329,22 @@ function PlanComparison({
286
329
  );
287
330
  }
288
331
 
332
+ /**
333
+ * Reloads the profile whenever the account identity *or* its entitlement changes,
334
+ * so a completed checkout shows the new plan as soon as the session refreshes.
335
+ */
336
+ function buildAccountSessionMarker(): string {
337
+ const snapshot = chatController.getSnapshot();
338
+ const user = apiClient.getCurrentUser();
339
+ return [
340
+ apiClient.getSessionToken() ?? "",
341
+ snapshot.user?.id ?? "",
342
+ snapshot.user?.username ?? "",
343
+ user?.effectivePlan ?? user?.plan ?? "",
344
+ user?.trialEndsAt ?? "",
345
+ ].join(":");
346
+ }
347
+
289
348
  export function AccountManagementPane({ focused, width, height }: PaneProps) {
290
349
  const language = useAppLanguage();
291
350
  const dialog = useDialog();
@@ -297,12 +356,10 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
297
356
  const tickers = useAppSelector((state) => state.tickers);
298
357
  const cachedFinancials = useAppSelector((state) => state.financials);
299
358
  const cachedExchangeRates = useAppSelector((state) => state.exchangeRates);
300
- const [sessionMarker, setSessionMarker] = useState(() => {
301
- const snapshot = chatController.getSnapshot();
302
- return `${apiClient.getSessionToken() ?? ""}:${snapshot.user?.id ?? ""}:${snapshot.user?.username ?? ""}`;
303
- });
359
+ const [sessionMarker, setSessionMarker] = useState(() => buildAccountSessionMarker());
304
360
  const [hasSession, setHasSession] = useState(() => !!apiClient.getSessionToken());
305
361
  const [profile, setProfile] = useState<AccountProfile | null>(null);
362
+ const [pricing, setPricing] = useState<CloudPricing | null>(null);
306
363
  const [draft, setDraft] = useState<AccountDraft>(() => profileToDraft(null));
307
364
  const [initialTab] = useState<AccountManagementTab>(
308
365
  () => consumeRequestedAccountManagementTab() ?? "profile",
@@ -334,6 +391,14 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
334
391
  const fieldOrder = ACCOUNT_TAB_FIELD_ORDER[activeTab];
335
392
  const accountTabs = ACCOUNT_TAB_DEFS.map((tab) => ({ ...tab, label: t(tab.label) }));
336
393
 
394
+ const planAccess = useMemo(() => resolvePlanAccess(profile), [profile]);
395
+ const planPrice = useMemo(() => formatCloudMonthlyPrice(pricing), [language, pricing]);
396
+ const trialOffer = useMemo(() => formatTrialOffer(pricing), [language, pricing]);
397
+ const planStatusLabel = useMemo(() => {
398
+ const trialEnd = planAccess.isTrialActive ? formatTrialEnd(planAccess.trialEndsAt) : null;
399
+ return trialEnd ? tf("Pro trial — ends {date}", { date: trialEnd }) : formatPlan(profile?.plan);
400
+ }, [language, planAccess.isTrialActive, planAccess.trialEndsAt, profile?.plan]);
401
+
337
402
  const portfolioHoldingCounts = useMemo(() => countPortfolioHoldings(tickers), [tickers]);
338
403
  const portfolioChoices = useMemo(
339
404
  () => buildPortfolioChoices(portfolios, portfolioHoldingCounts),
@@ -481,7 +546,7 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
481
546
  useEffect(() => {
482
547
  const unsubscribe = chatController.subscribe((snapshot) => {
483
548
  setHasSession(!!apiClient.getSessionToken() || snapshot.hasSavedSession);
484
- setSessionMarker(`${apiClient.getSessionToken() ?? ""}:${snapshot.user?.id ?? ""}:${snapshot.user?.username ?? ""}`);
549
+ setSessionMarker(buildAccountSessionMarker());
485
550
  });
486
551
  void chatController.refreshSession().catch(() => {});
487
552
  return unsubscribe;
@@ -512,6 +577,19 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
512
577
  void loadProfile();
513
578
  }, [loadProfile, sessionMarker]);
514
579
 
580
+ useEffect(() => {
581
+ let cancelled = false;
582
+ void apiClient.getCloudPricing()
583
+ .then((nextPricing) => {
584
+ if (!cancelled) setPricing(nextPricing);
585
+ })
586
+ // Pricing is decoration: the comparison table falls back to list prices.
587
+ .catch(() => {});
588
+ return () => {
589
+ cancelled = true;
590
+ };
591
+ }, []);
592
+
515
593
  useEffect(() => {
516
594
  if (!hasSession || !apiClient.getSessionToken()) return;
517
595
  if (syncStatus.phase !== "synced" || syncStatus.revision == null) return;
@@ -1002,18 +1080,23 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
1002
1080
  <>
1003
1081
  <Box flexDirection="row" gap={1}>
1004
1082
  <Text fg={colors.textDim}>{t("Status")}</Text>
1005
- <Text fg={profile?.plan === "pro" ? colors.positive : colors.textBright} attributes={TextAttributes.BOLD}>
1006
- {formatPlan(profile?.plan)}
1083
+ <Text
1084
+ fg={planAccess.hasProAccess ? colors.positive : colors.textBright}
1085
+ attributes={TextAttributes.BOLD}
1086
+ >
1087
+ {planStatusLabel}
1007
1088
  </Text>
1008
1089
  {profile?.email ? <Text fg={colors.textMuted}>{profile.email}</Text> : null}
1009
1090
  </Box>
1010
1091
  <PlanComparison
1011
1092
  width={contentWidth}
1012
- activePlan={profile?.plan === "pro" ? "pro" : "free"}
1093
+ activePlan={planAccess.hasProAccess ? "pro" : "free"}
1094
+ proNote={planAccess.hasProAccess ? null : trialOffer}
1095
+ price={planPrice}
1013
1096
  upgradeButton={(
1014
1097
  <Button
1015
- label={profile?.plan === "pro" ? t("Manage Pro") : busy === "billing" ? t("Opening...") : t("Upgrade to Pro")}
1016
- variant={profile?.plan === "pro" ? "secondary" : "primary"}
1098
+ label={planAccess.isPayingPro ? t("Manage Pro") : busy === "billing" ? t("Opening...") : t("Upgrade to Pro")}
1099
+ variant={planAccess.isPayingPro ? "secondary" : "primary"}
1017
1100
  width={isDesktop ? 28 : undefined}
1018
1101
  height={isDesktop ? "28px" : undefined}
1019
1102
  active={activeField === "upgradeAction"}
@@ -46,6 +46,10 @@ import {
46
46
  normalizeTabs,
47
47
  type PersistedAiScreenerPaneState,
48
48
  } from "./screener/model";
49
+ import {
50
+ LIVE_STREAMING_QUICK_SETTING,
51
+ withLiveStreamingSetting,
52
+ } from "../shared/live-streaming";
49
53
 
50
54
  function settingOrFallback(
51
55
  settings: Record<string, unknown>,
@@ -331,6 +335,7 @@ export const aiPlugin: GloomPlugin = {
331
335
  defaultPosition: "right",
332
336
  defaultMode: "floating",
333
337
  defaultFloatingSize: { width: 76, height: 24 },
338
+ quickSettings: [LIVE_STREAMING_QUICK_SETTING],
334
339
  settings: (context) => {
335
340
  const providers = detectProviders();
336
341
  const screenerProviders = providers.filter((provider) => (
@@ -351,7 +356,7 @@ export const aiPlugin: GloomPlugin = {
351
356
  ? context.paneState.activeTabId
352
357
  : null;
353
358
  const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] ?? null;
354
- return buildAiPaneSettingsDef({
359
+ return withLiveStreamingSetting(buildAiPaneSettingsDef({
355
360
  title: "AI Screener Settings",
356
361
  providers: settingsProviders(screenerProviders),
357
362
  models: settingsModels(providerIds),
@@ -369,7 +374,7 @@ export const aiPlugin: GloomPlugin = {
369
374
  additional: buildAiScreenerPaneSettingsDef(
370
375
  getAiScreenerPaneSettings(context.settings),
371
376
  ),
372
- });
377
+ }), context.settings);
373
378
  },
374
379
  });
375
380
 
@@ -5,7 +5,7 @@ import { useAppSelector } from "../../../../state/app/context";
5
5
  import { useFxRatesMap, useTickerFinancialsMap } from "../../../../market-data/hooks";
6
6
  import { getSharedMarketDataCoordinator } from "../../../../market-data/coordinator";
7
7
  import { instrumentFromTicker, quoteSubscriptionTargetFromTicker } from "../../../../market-data/request-types";
8
- import { useQuoteStreaming } from "../../../../state/hooks/quote-streaming";
8
+ import { useQuoteUpdates } from "../../../../state/hooks/quote-streaming";
9
9
  import type { ColumnContext } from "../../portfolio-list/metrics";
10
10
  import type { ColumnConfig } from "../../../../types/config";
11
11
  import type { ValidatedScreenerResult } from "./contract";
@@ -21,6 +21,7 @@ interface UseAiScreenerMarketRuntimeOptions {
21
21
  resultMap: Map<string, ValidatedScreenerResult>;
22
22
  setCursorSymbol: (symbol: string | null) => void;
23
23
  tickers: Map<string, TickerRecord>;
24
+ liveStreaming: boolean;
24
25
  }
25
26
 
26
27
  export function useAiScreenerMarketRuntime({
@@ -32,6 +33,7 @@ export function useAiScreenerMarketRuntime({
32
33
  resultMap,
33
34
  setCursorSymbol,
34
35
  tickers,
36
+ liveStreaming,
35
37
  }: UseAiScreenerMarketRuntimeOptions) {
36
38
  const baseCurrency = useAppSelector((state) => state.config.baseCurrency);
37
39
  const cachedExchangeRates = useAppSelector((state) => state.exchangeRates);
@@ -68,7 +70,7 @@ export function useAiScreenerMarketRuntime({
68
70
  })
69
71
  ), [sortedTickers]);
70
72
 
71
- useQuoteStreaming(quoteTargets);
73
+ useQuoteUpdates(quoteTargets, { liveStreaming });
72
74
 
73
75
  useEffect(() => {
74
76
  const coordinator = getSharedMarketDataCoordinator();
@@ -52,12 +52,14 @@ import {
52
52
  AI_DEFAULT_PROVIDER_SETTING_KEY,
53
53
  resolveAiPaneSelection,
54
54
  } from "../pane-settings";
55
+ import { useLiveStreamingSetting } from "../../shared/live-streaming";
55
56
 
56
57
  export function AiScreenerPane({ focused, width, height }: PaneProps) {
57
58
  const dataProvider = useAssetData();
58
59
  const { pinTicker } = usePluginTickerActions();
59
60
  const paneId = usePaneInstanceId();
60
61
  const paneInstance = usePaneInstance();
62
+ const liveStreaming = useLiveStreamingSetting();
61
63
  const dispatch = useAppDispatch();
62
64
  const tickers = useAppSelector((state) => state.tickers);
63
65
  const providers = useAiRuntimeProviders();
@@ -169,6 +171,7 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
169
171
  resultMap,
170
172
  setCursorSymbol,
171
173
  tickers,
174
+ liveStreaming,
172
175
  });
173
176
 
174
177
  const updateTabs = useCallback((updater: (tabs: AiScreenerTab[]) => AiScreenerTab[]) => {
@@ -144,7 +144,6 @@ export function SeriesEditorDialog({ dialogId, resolve, initialSpec }: SeriesEdi
144
144
  : undefined}
145
145
  scrollable={items.length > (isDesktop ? 5 : 7)}
146
146
  rowGap={isDesktop ? 0 : undefined}
147
- selectOnHover={isDesktop}
148
147
  onSelect={setSelectedIndex}
149
148
  onActivate={(_, index) => setSelectedIndex(index)}
150
149
  />
@@ -20,6 +20,10 @@ import {
20
20
  } from "./presets";
21
21
  import { buildChartComposerPaneSettingsDef } from "./settings";
22
22
  import type { PluginModule } from "../plugin-module";
23
+ import {
24
+ LIVE_STREAMING_QUICK_SETTING,
25
+ withLiveStreamingSetting,
26
+ } from "../shared/live-streaming";
23
27
 
24
28
  const CHART_COMPOSER_TEMPLATE_ID = "chart-composer-pane";
25
29
 
@@ -199,9 +203,13 @@ export const chartComposerModule: PluginModule = {
199
203
  defaultPosition: "right",
200
204
  defaultMode: "floating",
201
205
  defaultFloatingSize: { width: 100, height: 32 },
202
- settings: (context) => buildChartComposerPaneSettingsDef(
206
+ quickSettings: [LIVE_STREAMING_QUICK_SETTING],
207
+ settings: (context) => withLiveStreamingSetting(
208
+ buildChartComposerPaneSettingsDef(
209
+ context.settings,
210
+ context.activeTicker,
211
+ ),
203
212
  context.settings,
204
- context.activeTicker,
205
213
  ),
206
214
  }],
207
215
  paneTemplates: chartComposerTemplates,