gloomberb 0.15.5 → 0.15.6

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 (85) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +28 -0
  3. package/src/api-client/index.ts +4 -1
  4. package/src/api-client/research-activity.ts +4 -1
  5. package/src/api-client/types.ts +8 -0
  6. package/src/app/pane-runtime/index.tsx +2 -0
  7. package/src/app/runtime/broker-import.ts +4 -2
  8. package/src/cli/pane-functions/function-help.ts +7 -0
  9. package/src/components/chart/composite/composite-chart.tsx +4 -2
  10. package/src/components/chart/composite/rasterizer.ts +69 -9
  11. package/src/components/command-bar/layout-items/current-layout.ts +10 -0
  12. package/src/components/command-bar/workflow/collection-actions.ts +8 -0
  13. package/src/components/command-bar/workflow/ops.ts +46 -3
  14. package/src/components/layout/pane/frame.tsx +2 -1
  15. package/src/components/layout/pane/surface.ts +26 -0
  16. package/src/components/layout/pane/title.ts +21 -5
  17. package/src/components/layout/shell/index.tsx +1 -1
  18. package/src/components/layout/shell/ticker-link-menu.ts +46 -23
  19. package/src/components/layout/status-bar.tsx +5 -0
  20. package/src/components/onboarding/desks-step.tsx +43 -0
  21. package/src/components/onboarding/onboarding-wizard.tsx +150 -25
  22. package/src/components/onboarding/wizard-positions.ts +19 -6
  23. package/src/components/toggle-list.tsx +18 -10
  24. package/src/core/state/app/layout.ts +2 -0
  25. package/src/data/config/store/normalize.ts +3 -0
  26. package/src/i18n/es.ts +26 -0
  27. package/src/i18n/ja.ts +26 -0
  28. package/src/i18n/ko.ts +26 -0
  29. package/src/i18n/zh-cn.ts +26 -0
  30. package/src/i18n/zh-tw.ts +26 -0
  31. package/src/layout/desks.ts +335 -0
  32. package/src/layout/pane-follow.ts +47 -0
  33. package/src/layout/ticker-navigation.ts +13 -0
  34. package/src/market-data/market/format.ts +15 -9
  35. package/src/plugins/builtin/account-management/calendar-tab.tsx +196 -0
  36. package/src/plugins/builtin/account-management/footer.ts +5 -2
  37. package/src/plugins/builtin/account-management/index.tsx +2 -2
  38. package/src/plugins/builtin/account-management/navigation.ts +1 -1
  39. package/src/plugins/builtin/account-management/pane.tsx +11 -1
  40. package/src/plugins/builtin/analytics/index.tsx +16 -9
  41. package/src/plugins/builtin/analytics/pane-model.ts +20 -11
  42. package/src/plugins/builtin/backtest/index.tsx +1 -1
  43. package/src/plugins/builtin/chart-composer/index.tsx +20 -0
  44. package/src/plugins/builtin/chart-composer/pane.tsx +35 -7
  45. package/src/plugins/builtin/debt-maturities/index.tsx +1 -0
  46. package/src/plugins/builtin/dividend-yield/index.tsx +1 -0
  47. package/src/plugins/builtin/estimate-revisions/index.tsx +1 -0
  48. package/src/plugins/builtin/executives/index.tsx +1 -0
  49. package/src/plugins/builtin/filing-events/index.tsx +1 -0
  50. package/src/plugins/builtin/holders/index.tsx +1 -0
  51. package/src/plugins/builtin/insider/index.tsx +1 -0
  52. package/src/plugins/builtin/iv-history/index.tsx +1 -1
  53. package/src/plugins/builtin/layout-manager/index.tsx +75 -1
  54. package/src/plugins/builtin/news/index.tsx +1 -0
  55. package/src/plugins/builtin/options/index.tsx +1 -0
  56. package/src/plugins/builtin/options-positioning/client.ts +5 -0
  57. package/src/plugins/builtin/options-positioning/headless.ts +2 -1
  58. package/src/plugins/builtin/options-positioning/index.tsx +1 -0
  59. package/src/plugins/builtin/options-positioning/pane.tsx +24 -9
  60. package/src/plugins/builtin/options-scenario/client.ts +78 -21
  61. package/src/plugins/builtin/options-scenario/headless.ts +14 -2
  62. package/src/plugins/builtin/options-scenario/model.ts +2 -0
  63. package/src/plugins/builtin/options-scenario/pane.tsx +14 -5
  64. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +7 -1
  65. package/src/plugins/builtin/portfolio-list/cli/render.ts +13 -13
  66. package/src/plugins/builtin/portfolio-list/column-values.ts +1 -0
  67. package/src/plugins/builtin/portfolio-list/metrics.ts +1 -1
  68. package/src/plugins/builtin/portfolio-list/mutations.ts +71 -0
  69. package/src/plugins/builtin/portfolio-list/pane/index.tsx +26 -14
  70. package/src/plugins/builtin/portfolio-list/summary/index.tsx +18 -9
  71. package/src/plugins/builtin/portfolio-list/summary/totals.ts +48 -7
  72. package/src/plugins/builtin/realized-vol/index.tsx +1 -1
  73. package/src/plugins/builtin/research/index.tsx +4 -0
  74. package/src/plugins/builtin/revenue-breakdown/index.tsx +1 -0
  75. package/src/plugins/builtin/risk-factors/index.tsx +1 -0
  76. package/src/plugins/builtin/seasonality/index.tsx +1 -1
  77. package/src/plugins/builtin/sec/index.tsx +1 -0
  78. package/src/plugins/builtin/short-interest/index.tsx +2 -1
  79. package/src/plugins/builtin/short-volume/index.tsx +6 -1
  80. package/src/plugins/builtin/ticker-detail/index.tsx +3 -0
  81. package/src/plugins/builtin/time-sales/index.tsx +1 -1
  82. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -0
  83. package/src/shares/portable-layout.ts +4 -6
  84. package/src/types/config.ts +19 -12
  85. package/src/types/plugin.ts +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.15.5",
3
+ "version": "0.15.6",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/public/index.ts",
@@ -6,6 +6,7 @@ import type {
6
6
  AuthUser,
7
7
  BuildoutAccountResponse,
8
8
  BuildoutTokenResponse,
9
+ CalendarFeed,
9
10
  CloudAccountPlan,
10
11
  CloudBrowserHandoffResponse,
11
12
  CloudPricing,
@@ -250,6 +251,33 @@ export class CloudAuthApi {
250
251
  );
251
252
  }
252
253
 
254
+ /** The private calendar feed link, or null until it is first created. */
255
+ async getCalendarFeed(): Promise<CalendarFeed | null> {
256
+ const result = await this.options.request<{ feed: CalendarFeed | null }>(
257
+ "/account/calendar-feed",
258
+ { method: "GET" },
259
+ );
260
+ return result.feed;
261
+ }
262
+
263
+ /** The calendar feed link, created on first use. */
264
+ async ensureCalendarFeed(): Promise<CalendarFeed> {
265
+ const result = await this.options.request<{ feed: CalendarFeed }>(
266
+ "/account/calendar-feed",
267
+ { method: "POST", body: JSON.stringify({}) },
268
+ );
269
+ return result.feed;
270
+ }
271
+
272
+ /** A new calendar feed link; the old one stops working. */
273
+ async rotateCalendarFeed(): Promise<CalendarFeed> {
274
+ const result = await this.options.request<{ feed: CalendarFeed }>(
275
+ "/account/calendar-feed/rotate",
276
+ { method: "POST", body: JSON.stringify({}) },
277
+ );
278
+ return result.feed;
279
+ }
280
+
253
281
  async updateAccountProfile(
254
282
  update: AccountProfileUpdate,
255
283
  ): Promise<AccountProfile> {
@@ -360,7 +360,7 @@ class GloomApiClient {
360
360
  event: import("./research-activity").ResearchActivity; eventId: string;
361
361
  surface: "web" | "desktop" | "tui" | "cli"; anonymousId?: string;
362
362
  attribution?: Record<string, string>; feature?: import("./research-activity").ResearchFeature;
363
- tab?: string;
363
+ tab?: string; desks?: readonly string[];
364
364
  }): Promise<void> {
365
365
  await this.request("/activity/research", { method: "POST", body: JSON.stringify(payload) });
366
366
  }
@@ -405,6 +405,9 @@ class GloomApiClient {
405
405
  getBuildoutAccount = this.auth.getBuildoutAccount.bind(this.auth);
406
406
  getBuildoutToken = this.auth.getBuildoutToken.bind(this.auth);
407
407
  updateAccountProfile = this.auth.updateAccountProfile.bind(this.auth);
408
+ getCalendarFeed = this.auth.getCalendarFeed.bind(this.auth);
409
+ ensureCalendarFeed = this.auth.ensureCalendarFeed.bind(this.auth);
410
+ rotateCalendarFeed = this.auth.rotateCalendarFeed.bind(this.auth);
408
411
 
409
412
  async getSyncSnapshot(): Promise<CloudSyncSnapshotResponse> {
410
413
  return this.request<CloudSyncSnapshotResponse>("/sync/snapshot", { method: "GET" });
@@ -318,12 +318,14 @@ function attributionPayload(): Record<string, string> | undefined {
318
318
  /**
319
319
  * Counts milestones once per feature/session/account, never their content.
320
320
  * Native surfaces report anonymously only with an identifier the website
321
- * handed over; they still never mint one of their own.
321
+ * handed over; they still never mint one of their own. `desks` rides on the
322
+ * end of the first-run funnel: the desks picked at "What do you trade?".
322
323
  */
323
324
  export function recordResearchActivity(
324
325
  event: ResearchActivity,
325
326
  feature?: ResearchFeature,
326
327
  tab?: string,
328
+ desks?: readonly string[],
327
329
  ): void {
328
330
  const target = getCurrentPluginTarget();
329
331
  const user = apiClient.getCurrentUser();
@@ -342,6 +344,7 @@ export function recordResearchActivity(
342
344
  attribution: attributionPayload(),
343
345
  feature,
344
346
  tab,
347
+ desks,
345
348
  })
346
349
  .catch(() => {
347
350
  sent.delete(key);
@@ -193,6 +193,14 @@ export interface CloudPricing {
193
193
  yearly: CloudPricingTier;
194
194
  }
195
195
 
196
+ /** The account's private calendar feed link (`/account/calendar-feed`). */
197
+ export interface CalendarFeed {
198
+ url: string;
199
+ createdAt: string;
200
+ /** The last time a calendar app read it, to the hour. */
201
+ lastFetchedAt: string | null;
202
+ }
203
+
196
204
  /** One command-bar prefix described for `/assist/command`. */
197
205
  export interface AssistCommandDescriptor {
198
206
  prefix: string;
@@ -18,6 +18,7 @@ import {
18
18
  type AppState,
19
19
  } from "../../state/app/context";
20
20
  import { scheduleConfigSave } from "../../state/config-save-scheduler";
21
+ import { pinFollowingPane } from "../../layout/pane-follow";
21
22
  import {
22
23
  createPaneInstance,
23
24
  isTickerPaneId,
@@ -82,6 +83,7 @@ export function useAppPaneRuntime({
82
83
  const currentState = stateRef.current;
83
84
  const normalizedLayout = normalizePaneLayout(layout, {
84
85
  resolveOrphanSymbol: (instanceId) => resolveTickerForPane(currentState, instanceId),
86
+ pinOrphan: pinFollowingPane,
85
87
  });
86
88
  if (options?.pushHistory !== false) {
87
89
  dispatch({ type: "PUSH_LAYOUT_HISTORY" });
@@ -8,6 +8,7 @@ import {
8
8
  import type { SyncBrokerInstanceResult } from "../../brokers/sync-broker-instance";
9
9
  import type { AppTickerRepositoryPort } from "../../core/app-service-ports";
10
10
  import type { PluginRegistry } from "../../plugins/registry";
11
+ import { adoptHeldPortfolioCurrencies } from "../../plugins/builtin/portfolio-list/mutations";
11
12
  import { saveConfigImmediately } from "../../state/config-save-scheduler";
12
13
  import type { AppAction, AppState } from "../../state/app/context";
13
14
  import type { AppConfig } from "../../types/config";
@@ -133,11 +134,12 @@ export function useBrokerImportRuntime({
133
134
  }, [applyBrokerImportResult, pluginRegistry.brokers, pluginRegistry.persistence.resources, stateRef, tickerRepository]);
134
135
 
135
136
  const autoImportBrokerPositions = useCallback(async (tickerMap: Map<string, TickerRecord>) => {
136
- const restoredConfig = restoreBrokerPortfoliosFromTickerPositions(
137
+ // Once per install, manual portfolios held in one other currency take it (see adoptHeldPortfolioCurrencies).
138
+ const restoredConfig = adoptHeldPortfolioCurrencies(restoreBrokerPortfoliosFromTickerPositions(
137
139
  stateRef.current.config,
138
140
  tickerMap.values(),
139
141
  pluginRegistry.brokers,
140
- );
142
+ ), tickerMap.values());
141
143
  if (restoredConfig !== stateRef.current.config) {
142
144
  dispatch({ type: "SET_CONFIG", config: restoredConfig });
143
145
  await saveConfigImmediately(restoredConfig);
@@ -922,6 +922,13 @@ export const FUNCTION_HELP: Readonly<Record<string, FunctionHelp>> = {
922
922
  data: null,
923
923
  bloomberg: ["BLP"],
924
924
  },
925
+ DESK: {
926
+ summary: "Adds a ready-made desk as a new layout tab: equities, options, futures and commodities, rates and credit, FX and macro, or active trading.",
927
+ usage: ["DESK", "DESK options"],
928
+ keys: [],
929
+ data: null,
930
+ bloomberg: [],
931
+ },
925
932
  LMA: {
926
933
  summary: "Float, dock, swap, remove and save panes and layouts.",
927
934
  usage: ["LMA", "LMA float"],
@@ -19,6 +19,7 @@ import type { ContextMenuItem } from "../../../types/context-menu";
19
19
  import { useOptionalPaneInstanceId, usePaneSettingValue } from "../../../state/app/context";
20
20
  import { colors as themeColors, hoverBg } from "../../../theme/colors";
21
21
  import { useThemeColors } from "../../../theme/theme-context";
22
+ import { chartSurfaceBackground, usePaneSurface } from "../../layout/pane/surface";
22
23
  import { CANONICAL_EXCHANGE_ALIASES } from "../../../utils/exchanges";
23
24
  import { displayWidth, formatPercentRaw, truncateToDisplayWidth } from "../../../utils/format";
24
25
  import { isPlainKey } from "../../../utils/keyboard";
@@ -2171,14 +2172,15 @@ export function CompositeChart({
2171
2172
  return `${entry.id}:${last.date.getTime()}:${last.close ?? ""}:${last.value ?? ""}:${last.high ?? ""}:${last.low ?? ""}:${last.volume ?? ""}:${entry.latestChangePercent ?? ""}`;
2172
2173
  }).join("|");
2173
2174
  const plotHeight = Math.max(panelCount, totalHeight - legendRows - timeAxisRows - xMarkerRows);
2175
+ const paneSurface = usePaneSurface();
2174
2176
  const resolvedColors = useMemo<CompositeChartColors>(() => ({
2175
- background: colors?.background ?? activeThemeColors.bg,
2177
+ background: chartSurfaceBackground(colors?.background, activeThemeColors.bg, paneSurface),
2176
2178
  grid: colors?.grid ?? activeThemeColors.border,
2177
2179
  crosshair: colors?.crosshair ?? activeThemeColors.borderFocused,
2178
2180
  text: colors?.text ?? activeThemeColors.text,
2179
2181
  textDim: colors?.textDim ?? activeThemeColors.textDim,
2180
2182
  negative: colors?.negative ?? activeThemeColors.negative,
2181
- }), [activeThemeColors, colors]);
2183
+ }), [activeThemeColors, colors, paneSurface]);
2182
2184
  // A custom x axis means x is not time: a tenor, a fraction, a return. Its
2183
2185
  // last observation belongs at the right edge, under its own label.
2184
2186
  const rightOffsetRatio = xAxis ? 0 : COMPOSITE_RIGHT_OFFSET_RATIO;
@@ -1,5 +1,6 @@
1
1
  import type { NativeChartBitmap } from "../native/chart-rasterizer";
2
2
  import {
3
+ blendPixel,
3
4
  drawCircle,
4
5
  drawLine,
5
6
  fillOpaque,
@@ -51,6 +52,72 @@ function pixelY(value: number, domain: CompositeAxisDomain, height: number): num
51
52
  return ratio === null ? null : clamp(ratio * Math.max(height - 1, 0), 0, Math.max(height - 1, 0));
52
53
  }
53
54
 
55
+ const AREA_FILL_MAX_OPACITY = 0.3;
56
+ const AREA_FILL_MIN_OPACITY = 0.02;
57
+
58
+ /**
59
+ * The area under a line, filled once per pixel column with a vertical
60
+ * gradient: strongest at the series' furthest point from the baseline, fading
61
+ * to almost nothing at the baseline. Filling per segment instead stacked
62
+ * alpha wherever neighbouring segments shared a column, which on dense
63
+ * series (years of daily closes in a few hundred pixels) read as a barcode.
64
+ */
65
+ function fillAreaGradient(
66
+ data: Uint8Array,
67
+ width: number,
68
+ height: number,
69
+ series: CompositeProjectedSeries,
70
+ points: readonly { x: number; y: number }[],
71
+ baseline: number,
72
+ step: boolean,
73
+ color: RgbaColor,
74
+ ): void {
75
+ // Per column, the line's furthest point from the baseline, on either side.
76
+ const above = new Float64Array(width).fill(Number.NaN);
77
+ const below = new Float64Array(width).fill(Number.NaN);
78
+ for (let index = 1; index < points.length; index += 1) {
79
+ if (series.points[index]?.breakBefore) continue;
80
+ const previous = points[index - 1]!;
81
+ const current = points[index]!;
82
+ const from = previous.x <= current.x ? previous : current;
83
+ const to = previous.x <= current.x ? current : previous;
84
+ const start = Math.round(from.x);
85
+ const span = Math.round(to.x) - start;
86
+ for (let x = Math.max(0, start); x <= Math.min(width - 1, start + span); x += 1) {
87
+ const y = step ? previous.y : span === 0 ? to.y : from.y + (to.y - from.y) * ((x - start) / span);
88
+ if (y <= baseline) above[x] = Number.isNaN(above[x]!) ? y : Math.min(above[x]!, y);
89
+ if (y >= baseline) below[x] = Number.isNaN(below[x]!) ? y : Math.max(below[x]!, y);
90
+ }
91
+ }
92
+
93
+ const floor = Math.min(Math.max(baseline, 0), height - 1);
94
+ let peakAbove = floor;
95
+ let peakBelow = floor;
96
+ for (let x = 0; x < width; x += 1) {
97
+ if (!Number.isNaN(above[x]!)) peakAbove = Math.min(peakAbove, above[x]!);
98
+ if (!Number.isNaN(below[x]!)) peakBelow = Math.max(peakBelow, below[x]!);
99
+ }
100
+ const reachAbove = Math.max(1, floor - Math.max(0, peakAbove));
101
+ const reachBelow = Math.max(1, Math.min(height - 1, peakBelow) - floor);
102
+
103
+ for (let x = 0; x < width; x += 1) {
104
+ const top = above[x]!;
105
+ if (!Number.isNaN(top)) {
106
+ for (let y = Math.max(0, Math.ceil(top)); y <= floor; y += 1) {
107
+ const strength = (floor - y) / reachAbove;
108
+ blendPixel(data, width, height, x, y, color, AREA_FILL_MIN_OPACITY + (AREA_FILL_MAX_OPACITY - AREA_FILL_MIN_OPACITY) * strength);
109
+ }
110
+ }
111
+ const bottom = below[x]!;
112
+ if (!Number.isNaN(bottom)) {
113
+ for (let y = floor + 1; y <= Math.min(height - 1, Math.floor(bottom)); y += 1) {
114
+ const strength = (y - floor) / reachBelow;
115
+ blendPixel(data, width, height, x, y, color, AREA_FILL_MIN_OPACITY + (AREA_FILL_MAX_OPACITY - AREA_FILL_MIN_OPACITY) * strength);
116
+ }
117
+ }
118
+ }
119
+ }
120
+
54
121
  function drawConnectedSeries(
55
122
  data: Uint8Array,
56
123
  width: number,
@@ -65,19 +132,12 @@ function drawConnectedSeries(
65
132
  const baseline = pixelY(0, domain, height) ?? height - 1;
66
133
  const step = series.source.style === "step" || series.source.interpolation === "step-after";
67
134
 
135
+ if (area) fillAreaGradient(data, width, height, series, points, baseline, step, color);
136
+
68
137
  for (let index = 1; index < points.length; index += 1) {
69
138
  const previous = points[index - 1]!;
70
139
  const current = points[index]!;
71
140
  if (series.points[index]?.breakBefore) continue;
72
- if (area) {
73
- const left = Math.round(Math.min(previous.x, current.x));
74
- const right = Math.round(Math.max(previous.x, current.x));
75
- for (let x = left; x <= right; x += 1) {
76
- const ratio = right === left ? 0 : (x - left) / (right - left);
77
- const y = step ? previous.y : previous.y + (current.y - previous.y) * ratio;
78
- fillRect(data, width, height, x, Math.min(y, baseline), x, Math.max(y, baseline), color, 0.14);
79
- }
80
- }
81
141
  if (step) {
82
142
  drawLine(data, width, height, previous.x, previous.y, current.x, previous.y, color, 1.4);
83
143
  drawLine(data, width, height, current.x, previous.y, current.x, current.y, color, 1.4);
@@ -195,5 +195,15 @@ export function buildCurrentLayoutItems({
195
195
  kind: "action",
196
196
  action: () => openBuiltInWorkflow("new-layout"),
197
197
  },
198
+ {
199
+ id: "layout-add-desk",
200
+ label: "Add a Desk",
201
+ detail: "A ready-made layout for equities, options, futures, rates, FX or trading",
202
+ searchText: "desk add workspace template starter",
203
+ category: "Current Layout",
204
+ kind: "action",
205
+ right: "DESK",
206
+ action: () => dispatch({ type: "SET_COMMAND_BAR", open: true, query: "DESK " }),
207
+ },
198
208
  ];
199
209
  }
@@ -17,8 +17,10 @@ import {
17
17
  } from "../../../brokers/signed-in/connect";
18
18
  import {
19
19
  addTickerToPortfolio,
20
+ adoptFirstPositionCurrency,
20
21
  createManualPortfolio as createManualPortfolioConfig,
21
22
  deleteManualPortfolio,
23
+ hasOpenPortfolioPositions,
22
24
  isManualPortfolio,
23
25
  resolveManualPositionCurrency,
24
26
  setManualPortfolioPosition,
@@ -253,8 +255,14 @@ export function createCommandBarCollectionWorkflowActions(options: {
253
255
  avgCost,
254
256
  currency,
255
257
  });
258
+ const firstPosition = !hasOpenPortfolioPositions(portfolio.id, getState().tickers.values());
256
259
  await tickerRepository.saveTicker(result.ticker);
257
260
  dispatch({ type: "UPDATE_TICKER", ticker: result.ticker });
261
+ const adopted = firstPosition ? adoptFirstPositionCurrency(getState().config, portfolio.id, currency) : null;
262
+ if (adopted) {
263
+ dispatch({ type: "SET_CONFIG", config: adopted });
264
+ persistConfig(adopted);
265
+ }
258
266
  pluginRegistry.events.emit("host:portfolio-ticker-saved", {
259
267
  symbol: result.ticker.metadata.ticker,
260
268
  portfolioId: portfolio.id,
@@ -98,12 +98,53 @@ function stableKey(value: unknown): string {
98
98
  ));
99
99
  }
100
100
 
101
+ /**
102
+ * A ticker pane's id names the ticker it opened on (`options:AAPL`), but a pane linked to a list
103
+ * since then, or unlinked on another ticker, shows something else. Typing `OMON AAPL` must not
104
+ * retarget it: a linked pane would silently stop following, and an unlinked one would be taken over.
105
+ */
106
+ function showsOtherTicker(owner: PaneInstanceConfig, spec: PaneTemplateInstanceConfig): boolean {
107
+ return spec.binding?.kind === "fixed" && (
108
+ owner.binding?.kind === "follow"
109
+ || (owner.binding?.kind === "fixed" && owner.binding.symbol !== spec.binding.symbol)
110
+ );
111
+ }
112
+
113
+ /**
114
+ * When the pane holding a ticker template's id shows another ticker, the command opens its own
115
+ * pinned pane under a further stable id (`options:AAPL:pinned`, then `:pinned-2`), so typing it
116
+ * again lands on that pane rather than a new one (OMON keeps rewriting its settings, so the
117
+ * whole-spec match below would miss it). A pinned pane that was linked or moved on since is
118
+ * skipped the same way, so the ids never run out into unkeyed duplicates.
119
+ */
120
+ function withFreeTickerInstanceId(
121
+ instances: PaneInstanceConfig[],
122
+ paneId: string,
123
+ spec: PaneTemplateInstanceConfig,
124
+ ): PaneTemplateInstanceConfig {
125
+ const baseId = spec.instanceId;
126
+ if (!baseId) return spec;
127
+ const byId = new Map(instances.map((instance) => [instance.instanceId, instance]));
128
+ const owner = byId.get(baseId);
129
+ if (owner?.paneId !== paneId || !showsOtherTicker(owner, spec)) return spec;
130
+ let free: string | null = null;
131
+ // One more id than there are panes is always enough to find a free one.
132
+ for (let index = 1; index <= instances.length + 1; index += 1) {
133
+ const instanceId = index === 1 ? `${baseId}:pinned` : `${baseId}:pinned-${index}`;
134
+ const holder = byId.get(instanceId);
135
+ if (!holder) free ??= instanceId;
136
+ else if (holder.paneId === paneId && !showsOtherTicker(holder, spec)) return { ...spec, instanceId };
137
+ }
138
+ return { ...spec, instanceId: free ?? undefined };
139
+ }
140
+
101
141
  /**
102
142
  * Find the pane a template would otherwise duplicate. A template that owns a
103
143
  * stable instance id (Chat keys one pane per channel) claims that instance even
104
144
  * after its settings drifted at runtime, as long as the id still belongs to the
105
- * same kind of pane; everything else has to match the whole create spec, so a
106
- * different ticker, collection or setting still opens its own pane.
145
+ * same kind of pane and, for a ticker pane, still shows that ticker; everything
146
+ * else has to match the whole create spec, so a different ticker, collection or
147
+ * setting still opens its own pane.
107
148
  */
108
149
  function findReusablePaneInstance(
109
150
  instances: PaneInstanceConfig[],
@@ -112,7 +153,8 @@ function findReusablePaneInstance(
112
153
  ): PaneInstanceConfig | null {
113
154
  if (spec.instanceId) {
114
155
  const owner = instances.find((instance) => instance.instanceId === spec.instanceId);
115
- return owner?.paneId === paneId ? owner : null;
156
+ if (!owner || owner.paneId !== paneId) return null;
157
+ if (!showsOtherTicker(owner, spec)) return owner;
116
158
  }
117
159
  const specKey = stableKey([spec.binding ?? { kind: "none" }, spec.params ?? {}, spec.settings ?? {}]);
118
160
  return instances.find((instance) => (
@@ -277,6 +319,7 @@ export async function createPaneTemplateOrThrow(
277
319
  }
278
320
 
279
321
  const instances = deps.getState().config.layout.instances;
322
+ spec = withFreeTickerInstanceId(instances, template.paneId, spec);
280
323
  const existing = findReusablePaneInstance(instances, template.paneId, spec);
281
324
  if (existing) {
282
325
  const retargeted = retargetPaneInstance(existing, spec);
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { Box } from "../../../ui";
3
3
  import { colors } from "../../../theme/colors";
4
+ import { PaneSurfaceContext } from "./surface";
4
5
 
5
6
  export function getPaneWindowAttributes({
6
7
  enabled = true,
@@ -49,7 +50,7 @@ export function PaneBodyFrame({
49
50
  }) {
50
51
  return (
51
52
  <Box {...layoutProps} overflow="hidden" backgroundColor={backgroundColor} data-gloom-role="pane-body">
52
- {children}
53
+ <PaneSurfaceContext.Provider value={backgroundColor}>{children}</PaneSurfaceContext.Provider>
53
54
  </Box>
54
55
  );
55
56
  }
@@ -0,0 +1,26 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ /**
4
+ * The body color of the pane a component renders in. Charts draw on it, so a
5
+ * plot reads as part of the pane instead of a darker box inside it. Null
6
+ * outside a pane (dialogs, screenshots of a lone chart).
7
+ */
8
+ export const PaneSurfaceContext = createContext<string | null>(null);
9
+
10
+ export function usePaneSurface(): string | null {
11
+ return useContext(PaneSurfaceContext);
12
+ }
13
+
14
+ /**
15
+ * A chart's background: an explicit color wins, except the theme's own
16
+ * background, which callers pass only because it was the default. That one,
17
+ * or none at all, becomes the pane surface when there is one.
18
+ */
19
+ export function chartSurfaceBackground(
20
+ requested: string | null | undefined,
21
+ themeBackground: string,
22
+ paneSurface: string | null,
23
+ ): string {
24
+ if (requested && requested.toLowerCase() !== themeBackground.toLowerCase()) return requested;
25
+ return paneSurface ?? requested ?? themeBackground;
26
+ }
@@ -6,14 +6,18 @@ import {
6
6
  type PaneInstanceConfig,
7
7
  } from "../../../types/config";
8
8
  import type { PaneDef } from "../../../types/plugin";
9
+ import { paneTitleMnemonic } from "../../../layout/pane-follow";
10
+ import { canFollowTickerSource } from "../../../layout/ticker-navigation";
9
11
 
10
12
  /** Single-cell link glyph: emoji link icons render double-width in terminals. */
11
13
  const LINK_GLYPH = "\u29c9";
12
14
 
13
- function getBasePaneDisplayTitle(
15
+ /** The title without the link suffix; a menu row naming a source uses it as is. */
16
+ export function getBasePaneDisplayTitle(
14
17
  state: Pick<AppState, "config" | "paneState">,
15
18
  instance: PaneInstanceConfig,
16
19
  paneDef: PaneDef,
20
+ panes?: ReadonlyMap<string, PaneDef>,
17
21
  ): string {
18
22
  if (instance.paneId === "chat") {
19
23
  const channelId = typeof instance.settings?.channelId === "string" && instance.settings.channelId.trim()
@@ -36,6 +40,15 @@ function getBasePaneDisplayTitle(
36
40
  ?? t(paneDef.name);
37
41
  }
38
42
 
43
+ // A linked pane stores only its command ("OPX") and shows the ticker its source has selected.
44
+ const mnemonic = instance.binding?.kind === "follow" && panes && canFollowTickerSource(instance, panes)
45
+ ? paneTitleMnemonic(instance.title)
46
+ : null;
47
+ if (mnemonic) {
48
+ const ticker = resolveTickerForPane(state as AppState, instance.instanceId);
49
+ return ticker ? `${mnemonic} ${ticker}` : mnemonic;
50
+ }
51
+
39
52
  if (instance.title) return instance.title;
40
53
 
41
54
  if (instance.paneId === "portfolio-list") {
@@ -58,14 +71,17 @@ export function getPaneDisplayTitle(
58
71
  paneDef: PaneDef,
59
72
  panes?: ReadonlyMap<string, PaneDef>,
60
73
  ): string {
61
- const title = getBasePaneDisplayTitle(state, instance, paneDef);
62
- if (instance.paneId !== TICKER_RESEARCH_PANE_ID || instance.binding?.kind !== "follow" || !panes) {
63
- return title;
64
- }
74
+ const title = getBasePaneDisplayTitle(state, instance, paneDef, panes);
75
+ if (instance.binding?.kind !== "follow" || !panes) return title;
76
+ const research = instance.paneId === TICKER_RESEARCH_PANE_ID;
77
+ if (!research && !canFollowTickerSource(instance, panes)) return title;
65
78
 
66
79
  const source = findPaneInstance(state.config.layout, instance.binding.sourceInstanceId);
67
80
  const sourceDef = source ? panes.get(source.paneId) : null;
68
81
  if (!source || !sourceDef) return title;
82
+ // Other panes name only the list or scanner they follow; one chained to a fixed pane (a desk's
83
+ // OMON following OVDV) reads as it always has.
84
+ if (!research && !sourceDef.tickerSource) return title;
69
85
  const sourceTitle = getBasePaneDisplayTitle(state, source, sourceDef);
70
86
  return `${title} ${LINK_GLYPH} ${tf("Linked to {source}", { source: sourceTitle })}`;
71
87
  }
@@ -598,7 +598,7 @@ export function Shell({
598
598
  instance: pane.instance,
599
599
  layout: visibleLayout,
600
600
  panes: pluginRegistry.panes,
601
- state: titleState,
601
+ state: { ...titleState, tickers: stateRef.current.tickers },
602
602
  persistLayout,
603
603
  }),
604
604
  canExportPaneCsv(paneId) ? exportPaneCsv : undefined,
@@ -1,6 +1,9 @@
1
+ import { tf } from "../../../i18n";
1
2
  import { updatePaneInstance } from "../../../pane-settings";
2
- import { listVisibleTickerSourcePanes } from "../../../layout/ticker-navigation";
3
+ import { canFollowTickerSource, listVisibleTickerSourcePanes } from "../../../layout/ticker-navigation";
3
4
  import { resolveTickerForPane, type AppState } from "../../../state/app/context";
5
+ import { resolveInstrumentForPane } from "../../../core/state/app/instrument";
6
+ import { paneTitleMnemonic, pinFollowingPane } from "../../../layout/pane-follow";
4
7
  import {
5
8
  TICKER_RESEARCH_PANE_ID,
6
9
  type LayoutConfig,
@@ -8,11 +11,13 @@ import {
8
11
  } from "../../../types/config";
9
12
  import type { ContextMenuItem } from "../../../types/context-menu";
10
13
  import type { PaneDef } from "../../../types/plugin";
11
- import { getPaneDisplayTitle } from "../pane/title";
14
+ import { getBasePaneDisplayTitle } from "../pane/title";
12
15
 
13
16
  /**
14
- * Flat link controls for a Ticker Research pane: one entry per visible ticker source, plus an
15
- * unlink entry that pins the pane on the symbol it currently shows.
17
+ * Flat link controls for a pane that shows one ticker (Ticker Research, or a pane that sets
18
+ * `PaneDef.tickerFollower`): one "Link to" entry per visible ticker source, plus an unlink entry
19
+ * that pins the pane on the symbol it currently shows. Only list panes and scanners are targets;
20
+ * a pane following anything else (a desk's OMON following OVDV) gets no rows.
16
21
  */
17
22
  export function tickerLinkMenuItems({
18
23
  instance,
@@ -24,54 +29,72 @@ export function tickerLinkMenuItems({
24
29
  instance: PaneInstanceConfig;
25
30
  layout: LayoutConfig;
26
31
  panes: ReadonlyMap<string, PaneDef>;
27
- state: Pick<AppState, "config" | "paneState">;
32
+ state: Pick<AppState, "config" | "paneState" | "tickers">;
28
33
  persistLayout: (nextLayout: LayoutConfig) => void;
29
34
  }): ContextMenuItem[] {
30
- if (instance.paneId !== TICKER_RESEARCH_PANE_ID) return [];
35
+ const research = instance.paneId === TICKER_RESEARCH_PANE_ID;
36
+ // A chart that gained a comparison while linked can still be unlinked, but not linked again.
37
+ const followerType = research || !!panes.get(instance.paneId)?.tickerFollower;
38
+ if (!followerType) return [];
39
+ const linkable = research || canFollowTickerSource(instance, panes);
31
40
 
32
41
  const sourceInstanceId = instance.binding?.kind === "follow" ? instance.binding.sourceInstanceId : null;
33
- const rebind = (binding: PaneInstanceConfig["binding"]) => {
34
- persistLayout(updatePaneInstance(layout, instance.instanceId, (current) => ({ ...current, binding })));
42
+ const unlink = (symbol: string) => {
43
+ const contract = resolveInstrumentForPane(state, instance.instanceId)?.instrument;
44
+ persistLayout(updatePaneInstance(layout, instance.instanceId, (current) => {
45
+ const mnemonic = research ? null : paneTitleMnemonic(current.title);
46
+ return pinFollowingPane(mnemonic ? { ...current, title: mnemonic } : current, symbol, contract);
47
+ }));
35
48
  };
36
49
  const linkToSource = (nextSourceId: string) => {
37
50
  persistLayout({
38
51
  ...layout,
39
52
  instances: layout.instances.map((current) => {
40
53
  if (current.instanceId === instance.instanceId) {
41
- return { ...current, binding: { kind: "follow" as const, sourceInstanceId: nextSourceId } };
54
+ // The stored title drops its ticker ("OMON AAPL" to "OMON"); the header adds the live one.
55
+ const mnemonic = research ? null : paneTitleMnemonic(current.title);
56
+ return {
57
+ ...current,
58
+ ...(mnemonic ? { title: mnemonic } : {}),
59
+ binding: { kind: "follow" as const, sourceInstanceId: nextSourceId },
60
+ };
42
61
  }
62
+ // Ticker Research keeps one follower per source, so Enter on a source row has one target.
63
+ // Any number of other panes may follow the same source.
43
64
  if (
44
- current.paneId !== TICKER_RESEARCH_PANE_ID
65
+ !research
66
+ || current.paneId !== TICKER_RESEARCH_PANE_ID
45
67
  || current.binding?.kind !== "follow"
46
68
  || current.binding.sourceInstanceId !== nextSourceId
47
69
  ) return current;
48
-
49
- const symbol = resolveTickerForPane(state as AppState, current.instanceId);
50
- return { ...current, binding: symbol ? { kind: "fixed" as const, symbol } : { kind: "none" as const } };
70
+ return pinFollowingPane(current, resolveTickerForPane(state as AppState, current.instanceId));
51
71
  }),
52
72
  });
53
73
  };
54
74
 
55
75
  return listVisibleTickerSourcePanes(layout, panes).flatMap((source): ContextMenuItem[] => {
56
76
  const paneDef = panes.get(source.paneId);
57
- if (!paneDef) return [];
58
- const title = getPaneDisplayTitle(state, source, paneDef, panes);
77
+ if (!paneDef || source.instanceId === instance.instanceId) return [];
78
+ const title = getBasePaneDisplayTitle(state, source, paneDef);
59
79
 
60
80
  if (source.instanceId !== sourceInstanceId) {
61
- return [{
62
- id: `link:${source.instanceId}`,
63
- label: `Link to ${title}`,
64
- onSelect: () => linkToSource(source.instanceId),
65
- }];
81
+ return linkable
82
+ ? [{
83
+ id: `link:${source.instanceId}`,
84
+ label: tf("Link to {source}", { source: title }),
85
+ onSelect: () => linkToSource(source.instanceId),
86
+ }]
87
+ : [];
66
88
  }
67
89
 
68
- // Unlinking keeps the pane alive, so it only works once a symbol has resolved.
90
+ // Unlinking keeps the pane alive, so it only works once a symbol has resolved: pinning a
91
+ // ticker pane to nothing would let the next layout pass remove it.
69
92
  const symbol = resolveTickerForPane(state as AppState, instance.instanceId);
70
93
  return symbol
71
94
  ? [{
72
95
  id: `unlink:${source.instanceId}`,
73
- label: `Unlink from ${title}`,
74
- onSelect: () => rebind({ kind: "fixed", symbol }),
96
+ label: tf("Unlink from {source}", { source: title }),
97
+ onSelect: () => unlink(symbol),
75
98
  }]
76
99
  : [];
77
100
  });