gloomberb 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +23 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app.tsx +10 -7
  5. package/src/capabilities/registry.ts +1 -0
  6. package/src/capabilities/types.ts +23 -0
  7. package/src/cli/commands/api.ts +119 -0
  8. package/src/cli/commands/automation.ts +173 -0
  9. package/src/cli/commands/command-utils.ts +53 -0
  10. package/src/cli/commands/market.ts +527 -0
  11. package/src/cli/commands/overview.ts +222 -0
  12. package/src/cli/commands/search.ts +39 -0
  13. package/src/cli/commands/system.ts +553 -0
  14. package/src/cli/commands/ticker.ts +104 -12
  15. package/src/cli/context.ts +35 -2
  16. package/src/cli/entry.ts +51 -0
  17. package/src/cli/errors.ts +56 -5
  18. package/src/cli/index.ts +73 -11
  19. package/src/cli/options.test.ts +38 -0
  20. package/src/cli/options.ts +95 -0
  21. package/src/cli/pane-functions/index.ts +10 -1
  22. package/src/cli/registry.test.ts +40 -0
  23. package/src/cli/registry.ts +46 -5
  24. package/src/cli/result.test.ts +82 -0
  25. package/src/cli/result.ts +149 -0
  26. package/src/cli/types.ts +11 -0
  27. package/src/cli.test.ts +11 -11
  28. package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
  29. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  30. package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
  31. package/src/components/chart/comparison-stock-chart/legend.tsx +45 -44
  32. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  33. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  34. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  35. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  36. package/src/components/chart/comparison-stock-chart/view.tsx +56 -6
  37. package/src/components/chart/core/pane-settings.test.ts +27 -0
  38. package/src/components/chart/core/pane-settings.ts +60 -1
  39. package/src/components/chart/core/pointer.ts +2 -0
  40. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  41. package/src/components/chart/stock-chart/settings.ts +7 -3
  42. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  43. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  44. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  45. package/src/components/data-table/view.tsx +13 -2
  46. package/src/components/feed-data-table/stack-view.tsx +9 -1
  47. package/src/components/index.ts +1 -1
  48. package/src/components/input-search-bar.test.tsx +103 -0
  49. package/src/components/input-search-bar.tsx +16 -0
  50. package/src/components/layout/detached-pane-shell.tsx +5 -2
  51. package/src/components/layout/header.tsx +13 -5
  52. package/src/components/layout/shell/active-drag.ts +5 -1
  53. package/src/components/layout/shell/drag/runtime.ts +21 -3
  54. package/src/components/layout/shell/fullscreen.ts +10 -0
  55. package/src/components/layout/shell/index.test.tsx +395 -2
  56. package/src/components/layout/shell/index.tsx +128 -2
  57. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  58. package/src/components/layout/shell/pane/layers.tsx +26 -14
  59. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  60. package/src/components/layout/shell/shortcuts.ts +4 -0
  61. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  62. package/src/components/layout/shell/window-mode/index.ts +11 -3
  63. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  64. package/src/components/layout/status-bar.test.tsx +105 -0
  65. package/src/components/layout/status-bar.tsx +32 -6
  66. package/src/components/layout/transient-layout.tsx +57 -0
  67. package/src/components/layout/window-controls.tsx +19 -10
  68. package/src/components/layout/window-edit/mode.test.ts +76 -5
  69. package/src/components/layout/window-edit/mode.ts +47 -2
  70. package/src/components/layout/window-edit/presentation.ts +2 -1
  71. package/src/components/price-performance.tsx +183 -0
  72. package/src/core/app-services.ts +7 -4
  73. package/src/data/config/store/index.ts +1 -2
  74. package/src/market-data/performance.test.ts +54 -0
  75. package/src/market-data/performance.ts +109 -0
  76. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  77. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  78. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  79. package/src/plugins/builtin/comparison-chart/index.test.tsx +218 -15
  80. package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
  81. package/src/plugins/builtin/congress-trades/footer.ts +14 -8
  82. package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
  83. package/src/plugins/builtin/congress-trades/model.ts +29 -0
  84. package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
  85. package/src/plugins/builtin/congress-trades/table.tsx +10 -1
  86. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  87. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  88. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  89. package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
  90. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  91. package/src/plugins/builtin/shared/read-state.ts +43 -0
  92. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  93. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  94. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  95. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  96. package/src/plugins/builtin/substack/pane.tsx +8 -3
  97. package/src/plugins/builtin/substack/read-state.ts +55 -0
  98. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  99. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  100. package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
  101. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
  102. package/src/plugins/pane-manager/docking.ts +9 -2
  103. package/src/plugins/pane-manager/queries.ts +8 -2
  104. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  105. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  106. package/src/plugins/prediction-markets/pane.test.tsx +32 -0
  107. package/src/plugins/prediction-markets/pane.tsx +21 -1
  108. package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
  109. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  110. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  111. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  112. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  113. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +9 -2
  114. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  115. package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
  116. package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
  117. package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
  118. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
  119. package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
  120. package/src/renderers/electrobun/bun/index.ts +22 -2
  121. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  122. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  123. package/src/renderers/electrobun/shared/protocol.ts +1 -0
  124. package/src/renderers/electrobun/view/app-services.ts +6 -4
  125. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  126. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  127. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  128. package/src/renderers/electrobun/view/main.tsx +2 -1
  129. package/src/renderers/electrobun/view/styles.css +9 -8
  130. package/src/renderers/electrobun/view/ui-host.tsx +102 -77
  131. package/src/renderers/opentui/start.tsx +13 -5
  132. package/src/state/app/input-capture.ts +12 -0
  133. package/src/types/plugin.ts +28 -0
  134. package/src/utils/cli-output.ts +6 -0
  135. package/src/utils/search-focus-navigation.ts +24 -0
  136. package/src/version.ts +1 -1
@@ -0,0 +1,109 @@
1
+ import type { PricePoint, Quote } from "../types/financials";
2
+
3
+ export interface PriceReturnHorizon {
4
+ id: string;
5
+ label: string;
6
+ amount: number;
7
+ unit: "month" | "year";
8
+ }
9
+
10
+ export interface PriceReturnField {
11
+ id: string;
12
+ label: string;
13
+ value: number | null;
14
+ }
15
+
16
+ export const PRICE_RETURN_HORIZONS: PriceReturnHorizon[] = [
17
+ { id: "1M", label: "1M", amount: 1, unit: "month" },
18
+ { id: "3M", label: "3M", amount: 3, unit: "month" },
19
+ { id: "6M", label: "6M", amount: 6, unit: "month" },
20
+ { id: "1Y", label: "1Y", amount: 1, unit: "year" },
21
+ { id: "3Y", label: "3Y", amount: 3, unit: "year" },
22
+ { id: "5Y", label: "5Y", amount: 5, unit: "year" },
23
+ ];
24
+
25
+ function coercePointDate(value: Date | string | number): Date | null {
26
+ const date = value instanceof Date ? value : new Date(value);
27
+ return Number.isFinite(date.getTime()) ? date : null;
28
+ }
29
+
30
+ export function normalizePriceReturnHistory(points: readonly PricePoint[]): PricePoint[] {
31
+ const byTimestamp = new Map<number, PricePoint>();
32
+ for (const point of points) {
33
+ const date = coercePointDate(point.date as Date | string | number);
34
+ if (!date || !Number.isFinite(point.close)) continue;
35
+ byTimestamp.set(date.getTime(), { ...point, date });
36
+ }
37
+ return [...byTimestamp.entries()]
38
+ .sort((left, right) => left[0] - right[0])
39
+ .map(([, point]) => point);
40
+ }
41
+
42
+ export function appendQuoteToPriceReturnHistory(
43
+ points: readonly PricePoint[],
44
+ quote: Pick<Quote, "price" | "lastUpdated"> | null | undefined,
45
+ ): PricePoint[] {
46
+ const history = normalizePriceReturnHistory(points);
47
+ if (!quote || !Number.isFinite(quote.price) || !Number.isFinite(quote.lastUpdated)) {
48
+ return history;
49
+ }
50
+
51
+ const quoteDate = new Date(quote.lastUpdated);
52
+ if (!Number.isFinite(quoteDate.getTime())) return history;
53
+
54
+ const latestHistoryTime = history.at(-1)?.date.getTime() ?? Number.NEGATIVE_INFINITY;
55
+ if (quoteDate.getTime() <= latestHistoryTime) return history;
56
+
57
+ return [
58
+ ...history,
59
+ {
60
+ date: quoteDate,
61
+ close: quote.price,
62
+ },
63
+ ];
64
+ }
65
+
66
+ function subtractHorizon(date: Date, horizon: PriceReturnHorizon): Date {
67
+ const target = new Date(date);
68
+ if (horizon.unit === "month") {
69
+ target.setMonth(target.getMonth() - horizon.amount);
70
+ } else {
71
+ target.setFullYear(target.getFullYear() - horizon.amount);
72
+ }
73
+ return target;
74
+ }
75
+
76
+ export function computePriceReturnForHorizon(
77
+ points: readonly PricePoint[],
78
+ horizon: PriceReturnHorizon,
79
+ ): number | null {
80
+ const history = normalizePriceReturnHistory(points);
81
+ if (history.length < 2) return null;
82
+
83
+ const latest = history.at(-1)!;
84
+ const cutoff = subtractHorizon(latest.date, horizon);
85
+ let baseline: PricePoint | null = null;
86
+
87
+ for (const point of history) {
88
+ if (point.date.getTime() <= cutoff.getTime()) {
89
+ baseline = point;
90
+ } else {
91
+ break;
92
+ }
93
+ }
94
+
95
+ if (!baseline || !Number.isFinite(baseline.close) || baseline.close === 0) return null;
96
+ if (!Number.isFinite(latest.close)) return null;
97
+ return (latest.close - baseline.close) / baseline.close;
98
+ }
99
+
100
+ export function buildPriceReturnFields(
101
+ points: readonly PricePoint[],
102
+ horizons: readonly PriceReturnHorizon[] = PRICE_RETURN_HORIZONS,
103
+ ): PriceReturnField[] {
104
+ return horizons.map((horizon) => ({
105
+ id: horizon.id,
106
+ label: horizon.label,
107
+ value: computePriceReturnForHorizon(points, horizon),
108
+ }));
109
+ }
@@ -267,6 +267,7 @@ export function TwitterFeedPane({ focused, width, height }: PaneProps) {
267
267
  inputRef={searchInputRef}
268
268
  onFocus={focusSearch}
269
269
  onBlur={blurSearch}
270
+ onNavigateDown={blurSearch}
270
271
  onQueryChange={updateFeedQuery}
271
272
  />
272
273
  ) : null;
@@ -309,6 +310,7 @@ export function TwitterFeedPane({ focused, width, height }: PaneProps) {
309
310
  load={loadActiveFeed}
310
311
  onResult={markFeedResult}
311
312
  onError={markFeedError}
313
+ onFocusSearch={focusSearch}
312
314
  emptyStateTitle={searchEnabled ? "No tweets" : "Enter a search query"}
313
315
  emptyStateHint={searchEnabled ? activeFeedQuery : undefined}
314
316
  />
@@ -15,6 +15,7 @@ export function TwitterFeedSearchBar({
15
15
  inputRef,
16
16
  onFocus,
17
17
  onBlur,
18
+ onNavigateDown,
18
19
  onQueryChange,
19
20
  }: {
20
21
  feed: TwitterFeed;
@@ -25,6 +26,7 @@ export function TwitterFeedSearchBar({
25
26
  inputRef: RefObject<InputRenderable | null>;
26
27
  onFocus: () => void;
27
28
  onBlur: () => void;
29
+ onNavigateDown?: () => void;
28
30
  onQueryChange: (feedId: string, query: string) => void;
29
31
  }) {
30
32
  const updateQuery = useCallback((value: string) => {
@@ -43,6 +45,7 @@ export function TwitterFeedSearchBar({
43
45
  debounceMs={TWEET_SEARCH_DEBOUNCE_MS}
44
46
  onFocus={onFocus}
45
47
  onBlur={onBlur}
48
+ onNavigateDown={onNavigateDown}
46
49
  onQueryChange={updateQuery}
47
50
  />
48
51
  );
@@ -6,6 +6,7 @@ import {
6
6
  usePaneFooter,
7
7
  type DataTableCell,
8
8
  type DataTableKeyEvent,
9
+ type DataTableRootKeyContext,
9
10
  } from "../../../components";
10
11
  import { TickerBadgeText } from "../../../components/ticker/badge/text";
11
12
  import { RemoteImage } from "../../../components/ui";
@@ -15,6 +16,7 @@ import type { CloudTweetPayload, CloudTweetSearchResponse } from "../../../api-c
15
16
  import { formatTimeAgo } from "../../../utils/format";
16
17
  import { colors } from "../../../theme/colors";
17
18
  import { CloudAuthNotice } from "../cloud/auth-actions";
19
+ import { isPlainArrowUp, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation";
18
20
  import {
19
21
  buildTweetColumns,
20
22
  formatMetric,
@@ -152,6 +154,7 @@ export function TweetSearchTable({
152
154
  load,
153
155
  onResult,
154
156
  onError,
157
+ onFocusSearch,
155
158
  emptyStateTitle,
156
159
  emptyStateHint,
157
160
  }: {
@@ -165,6 +168,7 @@ export function TweetSearchTable({
165
168
  load: () => Promise<CloudTweetSearchResponse>;
166
169
  onResult?: (result: CloudTweetSearchResponse) => void;
167
170
  onError?: (message: string) => void;
171
+ onFocusSearch?: () => void;
168
172
  emptyStateTitle?: string;
169
173
  emptyStateHint?: string;
170
174
  }) {
@@ -219,13 +223,21 @@ export function TweetSearchTable({
219
223
  ));
220
224
  }, []);
221
225
 
222
- const handleRootKeyDown = useCallback((event: DataTableKeyEvent) => {
226
+ const handleRootKeyDown = useCallback((
227
+ event: DataTableKeyEvent,
228
+ context: DataTableRootKeyContext,
229
+ ) => {
230
+ if (onFocusSearch && context.selectedIndex <= 0 && isPlainArrowUp(event)) {
231
+ stopSearchFocusNavigation(event);
232
+ onFocusSearch();
233
+ return true;
234
+ }
223
235
  if (event.name !== "r") return false;
224
236
  event.preventDefault?.();
225
237
  event.stopPropagation?.();
226
238
  reload();
227
239
  return true;
228
- }, [reload]);
240
+ }, [onFocusSearch, reload]);
229
241
 
230
242
  const handleDetailKeyDown = useCallback((event: DataTableKeyEvent) => {
231
243
  if (event.name !== "o") return false;
@@ -6,6 +6,7 @@ import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../../
6
6
  import { PaneFooterBar, PaneFooterProvider } from "../../../components/layout/pane/footer";
7
7
  import {
8
8
  AppContext,
9
+ type AppAction,
9
10
  createInitialState,
10
11
  PaneInstanceProvider,
11
12
  } from "../../../state/app/context";
@@ -13,7 +14,7 @@ import { Box } from "../../../ui";
13
14
  import { cloneLayout, createDefaultConfig } from "../../../types/config";
14
15
  import { createTestPluginRuntime } from "../../../test-support/plugin-runtime";
15
16
  import type { DataProvider } from "../../../types/data-provider";
16
- import type { TickerFinancials } from "../../../types/financials";
17
+ import type { PricePoint, TickerFinancials } from "../../../types/financials";
17
18
  import type { PluginRegistry } from "../../registry";
18
19
  import { PluginRenderProvider, type PluginRuntimeAccess } from "../../runtime";
19
20
  import { setSharedMarketDataForTests, setSharedRegistryForTests } from "../../registry";
@@ -30,6 +31,12 @@ let root: ReturnType<typeof createRoot> | undefined;
30
31
  let sharedCoordinator: MarketDataCoordinator | null = null;
31
32
  const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
32
33
 
34
+ interface ComparisonHarnessOptions {
35
+ dispatch?: (action: AppAction) => void;
36
+ focused?: boolean;
37
+ focusedPaneId?: string | null;
38
+ }
39
+
33
40
  function makeTicker(symbol: string, currency: string): TickerRecord {
34
41
  return {
35
42
  metadata: {
@@ -68,6 +75,35 @@ function makeFinancials(symbol: string, currency: string, closes: number[]): Tic
68
75
  };
69
76
  }
70
77
 
78
+ function makeDatedFinancials(symbol: string, currency: string, closes: Array<[string, number]>): TickerFinancials {
79
+ const latest = closes.at(-1);
80
+ const latestClose = latest?.[1] ?? 0;
81
+ const latestDate = latest ? Date.parse(`${latest[0]}T00:00:00Z`) : Date.now();
82
+ return {
83
+ annualStatements: [],
84
+ quarterlyStatements: [],
85
+ quote: {
86
+ symbol,
87
+ price: latestClose,
88
+ currency,
89
+ change: latestClose - (closes[0]?.[1] ?? latestClose),
90
+ changePercent: closes[0]?.[1] ? ((latestClose - closes[0]![1]) / closes[0]![1]) * 100 : 0,
91
+ lastUpdated: latestDate,
92
+ },
93
+ priceHistory: closes.map(([date, close]) => ({
94
+ date: new Date(`${date}T00:00:00Z`),
95
+ close,
96
+ })),
97
+ };
98
+ }
99
+
100
+ function makeDatedPriceHistory(closes: Array<[string, number]>): PricePoint[] {
101
+ return closes.map(([date, close]) => ({
102
+ date: new Date(`${date}T00:00:00Z`),
103
+ close,
104
+ }));
105
+ }
106
+
71
107
  function createProvider(historyBySymbol: Record<string, number[]>, currencyBySymbol: Record<string, string>): DataProvider {
72
108
  return {
73
109
  id: "test-provider",
@@ -101,6 +137,46 @@ function createProvider(historyBySymbol: Record<string, number[]>, currencyBySym
101
137
  };
102
138
  }
103
139
 
140
+ function createDatedProvider(financialsBySymbol: Record<string, TickerFinancials>): DataProvider {
141
+ const provider = createProvider({}, {});
142
+ provider.getTickerFinancials = async (symbol) => financialsBySymbol[symbol] ?? makeDatedFinancials(symbol, "USD", []);
143
+ provider.getQuote = async (symbol) => financialsBySymbol[symbol]?.quote ?? {
144
+ symbol,
145
+ price: 0,
146
+ currency: "USD",
147
+ change: 0,
148
+ changePercent: 0,
149
+ lastUpdated: Date.now(),
150
+ };
151
+ provider.getPriceHistory = async (symbol) => financialsBySymbol[symbol]?.priceHistory ?? [];
152
+ provider.getPriceHistoryForResolution = async (symbol) => financialsBySymbol[symbol]?.priceHistory ?? [];
153
+ return provider;
154
+ }
155
+
156
+ function createRangeAwareDatedProvider({
157
+ fullHistoryBySymbol,
158
+ shortHistoryBySymbol,
159
+ }: {
160
+ fullHistoryBySymbol: Record<string, Array<[string, number]>>;
161
+ shortHistoryBySymbol: Record<string, Array<[string, number]>>;
162
+ }): DataProvider {
163
+ const financialsBySymbol = Object.fromEntries(
164
+ Object.entries(shortHistoryBySymbol).map(([symbol, history]) => [
165
+ symbol,
166
+ makeDatedFinancials(symbol, "USD", history),
167
+ ]),
168
+ );
169
+ const provider = createDatedProvider(financialsBySymbol);
170
+ const resolveHistory = (symbol: string, range: string) => makeDatedPriceHistory(
171
+ range === "5Y"
172
+ ? fullHistoryBySymbol[symbol] ?? shortHistoryBySymbol[symbol] ?? []
173
+ : shortHistoryBySymbol[symbol] ?? [],
174
+ );
175
+ provider.getPriceHistory = async (symbol, _exchange, range) => resolveHistory(symbol, range);
176
+ provider.getPriceHistoryForResolution = async (symbol, _exchange, range) => resolveHistory(symbol, range);
177
+ return provider;
178
+ }
179
+
104
180
  function createRegistrySpy(spy: { selected: string[]; focused: string[] }): PluginRegistry {
105
181
  return {
106
182
  selectTicker: (symbol: string) => { spy.selected.push(symbol); },
@@ -133,7 +209,13 @@ function createComparisonHarness(
133
209
  tickers: TickerRecord[],
134
210
  financials: Array<[string, TickerFinancials]>,
135
211
  runtime: PluginRuntimeAccess,
212
+ options: ComparisonHarnessOptions = {},
136
213
  ) {
214
+ const {
215
+ dispatch = () => {},
216
+ focused = true,
217
+ focusedPaneId = focused ? TEST_PANE_ID : null,
218
+ } = options;
137
219
  const config = createDefaultConfig("/tmp/gloomberb-compare");
138
220
  const layout = {
139
221
  dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
@@ -151,7 +233,7 @@ function createComparisonHarness(
151
233
  layouts: [{ name: "Default", layout: cloneLayout(layout) }],
152
234
  };
153
235
  const state = createInitialState(nextConfig);
154
- state.focusedPaneId = TEST_PANE_ID;
236
+ state.focusedPaneId = focusedPaneId;
155
237
  state.tickers = new Map(tickers.map((ticker) => [ticker.metadata.ticker, ticker]));
156
238
  state.financials = new Map(financials);
157
239
 
@@ -164,7 +246,7 @@ function createComparisonHarness(
164
246
  }) => ReactElement;
165
247
 
166
248
  return (
167
- <AppContext value={{ state, dispatch: () => {} }}>
249
+ <AppContext value={{ state, dispatch }}>
168
250
  <PaneInstanceProvider paneId={TEST_PANE_ID}>
169
251
  <PluginRenderProvider pluginId="comparison-chart" runtime={runtime}>
170
252
  <PaneFooterProvider>
@@ -173,11 +255,11 @@ function createComparisonHarness(
173
255
  <ComparisonPane
174
256
  paneId={TEST_PANE_ID}
175
257
  paneType="comparison-chart"
176
- focused
258
+ focused={focused}
177
259
  width={120}
178
260
  height={19}
179
261
  />
180
- <PaneFooterBar footer={footer} focused width={120} />
262
+ <PaneFooterBar footer={footer} focused={focused} width={120} />
181
263
  </Box>
182
264
  )}
183
265
  </PaneFooterProvider>
@@ -203,13 +285,14 @@ async function mountComparisonHarness(
203
285
  tickers: TickerRecord[],
204
286
  financials: Array<[string, TickerFinancials]>,
205
287
  spy: { selected: string[]; focused: string[]; settings?: string[] } = { selected: [], focused: [] },
288
+ options: ComparisonHarnessOptions = {},
206
289
  ) {
207
290
  actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
208
291
  testSetup = await createTestRenderer({ width: 120, height: 20 });
209
292
  root = createRoot(testSetup.renderer);
210
293
  await act(async () => {
211
294
  root!.render(
212
- createComparisonHarness(settings, tickers, financials, createRuntimeSpy(spy)),
295
+ createComparisonHarness(settings, tickers, financials, createRuntimeSpy(spy), options),
213
296
  );
214
297
  await Promise.resolve();
215
298
  await new Promise((resolve) => setTimeout(resolve, 0));
@@ -314,7 +397,7 @@ describe("comparisonChartPlugin", () => {
314
397
  expect(frame).not.toContain("side by side");
315
398
  });
316
399
 
317
- test("updates every legend value from the crosshair position", async () => {
400
+ test("updates every return summary range value from the crosshair position", async () => {
318
401
  const provider = createProvider({
319
402
  AAPL: [100, 102, 104],
320
403
  MSFT: [200, 202, 204],
@@ -343,8 +426,12 @@ describe("comparisonChartPlugin", () => {
343
426
 
344
427
  let frame = testSetup!.captureCharFrame();
345
428
  expect(frame).not.toContain("AAPL - 1D");
346
- expect(frame).toContain("AAPL $104.00 +4.00%");
347
- expect(frame).toContain("MSFT $204.00 +2.00%");
429
+ expect(frame).toContain("Sym");
430
+ expect(frame).toContain("Rng");
431
+ expect(frame).toContain("1Y");
432
+ expect(frame).toContain("5Y");
433
+ expect(frame).toMatch(/> AAPL\s+\+4\.00%/);
434
+ expect(frame).toMatch(/MSFT\s+\+2\.00%/);
348
435
 
349
436
  await act(async () => {
350
437
  await testSetup!.mockMouse.moveTo(2, 4);
@@ -353,8 +440,124 @@ describe("comparisonChartPlugin", () => {
353
440
  await flushFrames();
354
441
 
355
442
  frame = testSetup!.captureCharFrame();
356
- expect(frame).toContain("AAPL $100.00 0.00%");
357
- expect(frame).toContain("MSFT $200.00 0.00%");
443
+ expect(frame).toMatch(/> AAPL\s+0\.00%/);
444
+ expect(frame).toMatch(/MSFT\s+0\.00%/);
445
+ });
446
+
447
+ test("focuses the pane when an unfocused comparison plot is clicked", async () => {
448
+ const dispatched: AppAction[] = [];
449
+ const provider = createProvider({
450
+ AAPL: [100, 102, 104],
451
+ MSFT: [200, 202, 204],
452
+ }, {
453
+ AAPL: "USD",
454
+ MSFT: "USD",
455
+ });
456
+ setSharedMarketDataForTests(provider);
457
+ sharedCoordinator = new MarketDataCoordinator(provider);
458
+ setSharedMarketDataCoordinator(sharedCoordinator);
459
+ setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
460
+
461
+ await mountComparisonHarness({
462
+ axisMode: "percent",
463
+ symbols: ["AAPL", "MSFT"],
464
+ symbolsText: "AAPL, MSFT",
465
+ }, [
466
+ makeTicker("AAPL", "USD"),
467
+ makeTicker("MSFT", "USD"),
468
+ ], [
469
+ ["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
470
+ ["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
471
+ ], { selected: [], focused: [] }, {
472
+ dispatch: (action) => { dispatched.push(action); },
473
+ focused: false,
474
+ focusedPaneId: "ticker-detail:main",
475
+ });
476
+
477
+ await flushFrames();
478
+ await act(async () => {
479
+ await testSetup!.mockMouse.click(20, 8);
480
+ await testSetup!.renderOnce();
481
+ });
482
+
483
+ expect(dispatched).toContainEqual({ type: "FOCUS_PANE", paneId: TEST_PANE_ID });
484
+ });
485
+
486
+ test("keeps fixed return horizons independent from the selected chart range", async () => {
487
+ const shortHistory = {
488
+ AAPL: [["2025-01-02", 180], ["2026-01-02", 200]],
489
+ MSFT: [["2025-01-02", 300], ["2026-01-02", 330]],
490
+ } satisfies Record<string, Array<[string, number]>>;
491
+ const fullHistory = {
492
+ AAPL: [["2021-01-02", 100], ["2023-01-02", 150], ["2025-01-02", 180], ["2026-01-02", 200]],
493
+ MSFT: [["2021-01-02", 220], ["2023-01-02", 270], ["2025-01-02", 300], ["2026-01-02", 330]],
494
+ } satisfies Record<string, Array<[string, number]>>;
495
+ const provider = createRangeAwareDatedProvider({
496
+ fullHistoryBySymbol: fullHistory,
497
+ shortHistoryBySymbol: shortHistory,
498
+ });
499
+ setSharedMarketDataForTests(provider);
500
+ sharedCoordinator = new MarketDataCoordinator(provider);
501
+ setSharedMarketDataCoordinator(sharedCoordinator);
502
+ setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
503
+
504
+ await mountComparisonHarness({
505
+ axisMode: "percent",
506
+ rangePreset: "1Y",
507
+ chartResolution: "1d",
508
+ symbols: ["AAPL", "MSFT"],
509
+ symbolsText: "AAPL, MSFT",
510
+ }, [
511
+ makeTicker("AAPL", "USD"),
512
+ makeTicker("MSFT", "USD"),
513
+ ], [
514
+ ["AAPL", makeDatedFinancials("AAPL", "USD", shortHistory.AAPL)],
515
+ ["MSFT", makeDatedFinancials("MSFT", "USD", shortHistory.MSFT)],
516
+ ]);
517
+
518
+ await flushFrames(8);
519
+
520
+ const frame = testSetup!.captureCharFrame();
521
+ expect(frame).toContain("5Y");
522
+ expect(frame).toMatch(/> AAPL\s+\+11\.11%[^\n]*\+100\.00%/);
523
+ expect(frame).toMatch(/MSFT\s+\+10\.00%[^\n]*\+50\.00%/);
524
+ });
525
+
526
+ test("sorts comparison summary rows by one-year return", async () => {
527
+ const rows = {
528
+ SLOW: makeDatedFinancials("SLOW", "USD", [["2025-01-02", 100], ["2026-01-02", 105]]),
529
+ FAST: makeDatedFinancials("FAST", "USD", [["2025-01-02", 100], ["2026-01-02", 150]]),
530
+ MID: makeDatedFinancials("MID", "USD", [["2025-01-02", 100], ["2026-01-02", 125]]),
531
+ };
532
+ const provider = createDatedProvider(rows);
533
+ setSharedMarketDataForTests(provider);
534
+ sharedCoordinator = new MarketDataCoordinator(provider);
535
+ setSharedMarketDataCoordinator(sharedCoordinator);
536
+ setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
537
+
538
+ await mountComparisonHarness({
539
+ axisMode: "percent",
540
+ symbols: ["SLOW", "FAST", "MID"],
541
+ symbolsText: "SLOW, FAST, MID",
542
+ }, [
543
+ makeTicker("SLOW", "USD"),
544
+ makeTicker("FAST", "USD"),
545
+ makeTicker("MID", "USD"),
546
+ ], Object.entries(rows));
547
+
548
+ await flushFrames();
549
+
550
+ const frame = testSetup!.captureCharFrame();
551
+ const table = frame.slice(frame.indexOf("Sym"));
552
+ const fastIndex = table.indexOf("FAST");
553
+ const midIndex = table.indexOf("MID");
554
+ const slowIndex = table.indexOf("SLOW");
555
+
556
+ expect(fastIndex).toBeGreaterThanOrEqual(0);
557
+ expect(midIndex).toBeGreaterThanOrEqual(0);
558
+ expect(slowIndex).toBeGreaterThanOrEqual(0);
559
+ expect(fastIndex).toBeLessThan(midIndex);
560
+ expect(midIndex).toBeLessThan(slowIndex);
358
561
  });
359
562
 
360
563
  test("extends lagging comparison histories with the latest quote", async () => {
@@ -406,11 +609,11 @@ describe("comparisonChartPlugin", () => {
406
609
  await flushFrames();
407
610
 
408
611
  const frame = testSetup!.captureCharFrame();
409
- expect(frame).toContain("AAPL $150.00 +");
410
- expect(frame).toContain("MSFT $250.00 +");
612
+ expect(frame).toMatch(/> AAPL\s+\+47\.06%/);
613
+ expect(frame).toMatch(/MSFT\s+\+23\.76%/);
411
614
  });
412
615
 
413
- test("updates comparison legends from chart-owned quote streams", async () => {
616
+ test("updates comparison return summary from chart-owned quote streams", async () => {
414
617
  let emitQuote: ((symbol: string, price: number) => void) | null = null;
415
618
  const provider = createProvider({
416
619
  AAPL: [100, 102],
@@ -464,7 +667,7 @@ describe("comparisonChartPlugin", () => {
464
667
  await flushFrames();
465
668
 
466
669
  const frame = testSetup!.captureCharFrame();
467
- expect(frame).toContain("AAPL $155.00 +");
670
+ expect(frame).toMatch(/> AAPL\s+\+51\.96%/);
468
671
  });
469
672
 
470
673
  test("does not refetch resolution support when only comparison prices update", async () => {