gloomberb 0.7.0 → 0.7.2

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 (126) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +85 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +101 -5
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +14 -2
  118. package/src/ui/index.tsx +1 -0
  119. package/src/utils/api-client.test.ts +32 -0
  120. package/src/utils/api-client.ts +119 -18
  121. package/src/utils/double-escape-close.test.ts +33 -0
  122. package/src/utils/double-escape-close.ts +40 -0
  123. package/src/utils/throttled-fetch.test.ts +22 -0
  124. package/src/utils/throttled-fetch.ts +3 -1
  125. package/src/utils/ticker-search.ts +53 -9
  126. package/src/version.ts +1 -1
@@ -7,7 +7,7 @@ import {
7
7
  type DataTableColumn,
8
8
  type DataTableKeyEvent,
9
9
  } from "../../../components";
10
- import type { AnalystResearchData, CorporateActionsData, TickerFinancials } from "../../../types/financials";
10
+ import type { AnalystRatingRecord, AnalystResearchData, CorporateActionsData, TickerFinancials } from "../../../types/financials";
11
11
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
12
12
  import { usePaneInstance, usePaneTicker } from "../../../state/app-context";
13
13
  import { blendHex, colors, priceColor } from "../../../theme/colors";
@@ -110,6 +110,32 @@ function ratingActionColor(action: string | undefined): string {
110
110
  return colors.textDim;
111
111
  }
112
112
 
113
+ function formatPriceTarget(value: number | undefined, currency: string): string {
114
+ if (value == null) return "-";
115
+ return formatCurrency(value, currency)
116
+ .replace(/\.00\b/, "")
117
+ .replace(/(\.\d)0\b/, "$1");
118
+ }
119
+
120
+ function formatRatingTarget(row: AnalystResearchData["ratings"][number], currency: string): string {
121
+ const current = row.currentPriceTarget;
122
+ const prior = row.priorPriceTarget;
123
+ if (current == null && prior == null) return "-";
124
+ if (current == null) return ` ${formatPriceTarget(prior, currency)}`;
125
+ if (prior == null) return ` ${formatPriceTarget(current, currency)}`;
126
+ return ` ${formatPriceTarget(prior, currency)} → ${formatPriceTarget(current, currency)}`;
127
+ }
128
+
129
+ function ratingTargetDelta(row: AnalystResearchData["ratings"][number]): number | null {
130
+ if (row.currentPriceTarget == null || row.priorPriceTarget == null) return null;
131
+ return row.currentPriceTarget - row.priorPriceTarget;
132
+ }
133
+
134
+ function ratingTargetBackground(delta: number | null): string | undefined {
135
+ if (delta == null || delta === 0) return undefined;
136
+ return blendHex(colors.bg, delta > 0 ? colors.positive : colors.negative, 0.42);
137
+ }
138
+
113
139
  function AnalystSummary({ data, width }: { data: AnalystResearchData | null; width: number }) {
114
140
  const target = data?.priceTarget;
115
141
  const upside = targetUpside(target);
@@ -170,26 +196,135 @@ function AnalystSummary({ data, width }: { data: AnalystResearchData | null; wid
170
196
  );
171
197
  }
172
198
 
173
- type RatingColumnId = "date" | "firm" | "action" | "current" | "prior";
199
+ export type RatingColumnId = "date" | "firm" | "action" | "current" | "target" | "prior";
174
200
  type RatingColumn = DataTableColumn & { id: RatingColumnId };
201
+ export type SortDirection = "asc" | "desc";
202
+
203
+ export interface RatingSortPreference {
204
+ columnId: RatingColumnId;
205
+ direction: SortDirection;
206
+ }
207
+
208
+ const DEFAULT_RATING_SORT: RatingSortPreference = {
209
+ columnId: "date",
210
+ direction: "desc",
211
+ };
212
+
213
+ const DEFAULT_RATING_SORT_DIRECTIONS: Record<RatingColumnId, SortDirection> = {
214
+ date: "desc",
215
+ firm: "asc",
216
+ action: "asc",
217
+ current: "asc",
218
+ target: "desc",
219
+ prior: "asc",
220
+ };
175
221
 
176
222
  const RATING_COLUMNS: RatingColumn[] = [
177
223
  { id: "date", label: "DATE", width: 10, align: "left" },
178
- { id: "firm", label: "FIRM", width: 24, align: "left" },
179
- { id: "action", label: "ACTION", width: 11, align: "left" },
180
- { id: "current", label: "RATING", width: 14, align: "left" },
181
- { id: "prior", label: "PRIOR", width: 14, align: "left" },
224
+ { id: "firm", label: "FIRM", width: 20, align: "left" },
225
+ { id: "action", label: "ACTION", width: 10, align: "left" },
226
+ { id: "current", label: "RATING", width: 13, align: "left" },
227
+ { id: "target", label: "TARGET", width: 13, align: "left" },
228
+ { id: "prior", label: "PRIOR", width: 13, align: "left" },
182
229
  ];
183
230
 
231
+ function normalizedText(value: string | undefined): string | null {
232
+ const trimmed = value?.trim();
233
+ return trimmed ? trimmed.toLocaleLowerCase() : null;
234
+ }
235
+
236
+ function ratingTargetSortValue(row: AnalystRatingRecord): number | null {
237
+ return row.currentPriceTarget ?? row.priorPriceTarget ?? null;
238
+ }
239
+
240
+ function ratingSortValue(row: AnalystRatingRecord, columnId: RatingColumnId): string | number | null {
241
+ switch (columnId) {
242
+ case "date":
243
+ return normalizedText(row.date);
244
+ case "firm":
245
+ return normalizedText(row.firm);
246
+ case "action":
247
+ return normalizedText(row.action);
248
+ case "current":
249
+ return normalizedText(row.current);
250
+ case "target":
251
+ return ratingTargetSortValue(row);
252
+ case "prior":
253
+ return normalizedText(row.prior);
254
+ }
255
+ }
256
+
257
+ function compareSortValues(
258
+ left: string | number | null,
259
+ right: string | number | null,
260
+ direction: SortDirection,
261
+ ): number {
262
+ if (left == null && right == null) return 0;
263
+ if (left == null) return 1;
264
+ if (right == null) return -1;
265
+
266
+ const comparison = typeof left === "string" && typeof right === "string"
267
+ ? left.localeCompare(right)
268
+ : Number(left) - Number(right);
269
+ return direction === "asc" ? comparison : -comparison;
270
+ }
271
+
272
+ export function sortRatingRows<T extends AnalystRatingRecord>(
273
+ rows: readonly T[],
274
+ preference: RatingSortPreference,
275
+ ): T[] {
276
+ return rows
277
+ .map((row, index) => ({ row, index }))
278
+ .sort((left, right) => {
279
+ const primary = compareSortValues(
280
+ ratingSortValue(left.row, preference.columnId),
281
+ ratingSortValue(right.row, preference.columnId),
282
+ preference.direction,
283
+ );
284
+ if (primary !== 0) return primary;
285
+
286
+ const dateTieBreak = preference.columnId === "date"
287
+ ? 0
288
+ : compareSortValues(
289
+ ratingSortValue(left.row, "date"),
290
+ ratingSortValue(right.row, "date"),
291
+ "desc",
292
+ );
293
+ if (dateTieBreak !== 0) return dateTieBreak;
294
+
295
+ return left.index - right.index;
296
+ })
297
+ .map((entry) => entry.row);
298
+ }
299
+
300
+ export function nextRatingSortPreference(
301
+ current: RatingSortPreference,
302
+ columnId: string,
303
+ ): RatingSortPreference {
304
+ const typedColumnId = columnId as RatingColumnId;
305
+ if (current.columnId !== typedColumnId) {
306
+ return {
307
+ columnId: typedColumnId,
308
+ direction: DEFAULT_RATING_SORT_DIRECTIONS[typedColumnId] ?? "asc",
309
+ };
310
+ }
311
+ return {
312
+ columnId: typedColumnId,
313
+ direction: current.direction === "asc" ? "desc" : "asc",
314
+ };
315
+ }
316
+
184
317
  function AnalystResearchView({ focused, width, height }: { focused: boolean; width: number; height: number }) {
185
318
  const dataProvider = useAssetData();
186
319
  const { symbol, exchange } = useSymbolBinding();
320
+ const [sortPreference, setSortPreference] = useState<RatingSortPreference>(DEFAULT_RATING_SORT);
187
321
  const loader = useCallback((nextSymbol: string, nextExchange: string, forceRefresh: boolean) => {
188
322
  if (!dataProvider?.getAnalystResearch) throw new Error("Analyst data unavailable");
189
323
  return dataProvider.getAnalystResearch(nextSymbol, nextExchange, forceRefresh ? { cacheMode: "refresh" } : undefined);
190
324
  }, [dataProvider]);
191
325
  const { data, loading, error, reload } = useTickerRequest<AnalystResearchData>(loader, symbol, exchange);
192
- const rows = data?.ratings ?? [];
326
+ const rows = useMemo(() => sortRatingRows(data?.ratings ?? [], sortPreference), [data?.ratings, sortPreference]);
327
+ const ratingCurrency = data?.priceTarget?.currency ?? data?.currency ?? "USD";
193
328
 
194
329
  const renderCell = useCallback((
195
330
  row: AnalystResearchData["ratings"][number],
@@ -207,10 +342,20 @@ function AnalystResearchView({ focused, width, height }: { focused: boolean; wid
207
342
  return { text: row.action ?? "-", color: selectedColor ?? ratingActionColor(row.action) };
208
343
  case "current":
209
344
  return { text: row.current ?? "-", color: selectedColor ?? colors.text };
345
+ case "target": {
346
+ const delta = ratingTargetDelta(row);
347
+ const hasTarget = row.currentPriceTarget != null || row.priorPriceTarget != null;
348
+ return {
349
+ text: formatRatingTarget(row, ratingCurrency),
350
+ color: selectedColor ?? (hasTarget ? colors.textBright : colors.textDim),
351
+ backgroundColor: rowState.selected ? undefined : ratingTargetBackground(delta),
352
+ attributes: hasTarget ? TextAttributes.BOLD : undefined,
353
+ };
354
+ }
210
355
  case "prior":
211
356
  return { text: row.prior ?? "-", color: selectedColor ?? colors.textDim };
212
357
  }
213
- }, []);
358
+ }, [ratingCurrency]);
214
359
 
215
360
  const handleKeyDown = useCallback((event: DataTableKeyEvent) => {
216
361
  if (event.name !== "r") return false;
@@ -219,6 +364,9 @@ function AnalystResearchView({ focused, width, height }: { focused: boolean; wid
219
364
  reload();
220
365
  return true;
221
366
  }, [reload]);
367
+ const handleHeaderClick = useCallback((columnId: string) => {
368
+ setSortPreference((current) => nextRatingSortPreference(current, columnId));
369
+ }, []);
222
370
 
223
371
  usePaneFooter("analyst-research", () => ({
224
372
  info: [
@@ -237,15 +385,14 @@ function AnalystResearchView({ focused, width, height }: { focused: boolean; wid
237
385
  onRootKeyDown={handleKeyDown}
238
386
  columns={RATING_COLUMNS}
239
387
  items={rows}
240
- sortColumnId={null}
241
- sortDirection="desc"
242
- onHeaderClick={() => {}}
388
+ sortColumnId={sortPreference.columnId}
389
+ sortDirection={sortPreference.direction}
390
+ onHeaderClick={handleHeaderClick}
243
391
  getItemKey={(row, index) => `${row.date}:${row.firm}:${index}`}
244
392
  isSelected={() => false}
245
393
  onSelect={() => {}}
246
394
  renderCell={renderCell}
247
395
  emptyStateTitle={loading ? "Loading analyst data..." : error ?? "No analyst data"}
248
- showHorizontalScrollbar={false}
249
396
  />
250
397
  );
251
398
  }
@@ -1,6 +1,6 @@
1
1
  import { Box } from "../../../ui";
2
2
  import { useShortcut } from "../../../react/input";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
4
  import type { DetailTabDef, PaneProps } from "../../../types/plugin";
5
5
  import { quoteSubscriptionTargetFromTicker } from "../../../market-data/request-types";
6
6
  import {
@@ -33,6 +33,29 @@ function sameStringSet(left: Set<string>, right: Set<string>): boolean {
33
33
  return true;
34
34
  }
35
35
 
36
+ function registryDetailTabsSnapshot(registry: ReturnType<typeof getSharedRegistry>): string {
37
+ if (!registry) return "";
38
+ return [...registry.detailTabs.values()]
39
+ .map((tab) => `${tab.id}:${tab.name}:${tab.order}:${registry.getDetailTabPluginId?.(tab.id) ?? ""}`)
40
+ .join("\0");
41
+ }
42
+
43
+ function useRegistryDetailTabsSnapshot(registry: ReturnType<typeof getSharedRegistry>): string {
44
+ const subscribe = useCallback((onStoreChange: () => void) => {
45
+ const events = registry?.events;
46
+ if (!events) return () => {};
47
+ const unregisterRegistered = events.on("plugin:registered", onStoreChange);
48
+ const unregisterUnregistered = events.on("plugin:unregistered", onStoreChange);
49
+ return () => {
50
+ unregisterRegistered();
51
+ unregisterUnregistered();
52
+ };
53
+ }, [registry]);
54
+
55
+ const getSnapshot = useCallback(() => registryDetailTabsSnapshot(registry), [registry]);
56
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
57
+ }
58
+
36
59
  export function TickerDetailPane({ focused, width, height }: PaneProps) {
37
60
  const dispatch = useAppDispatch();
38
61
  const config = useAppSelector((state) => state.config);
@@ -54,6 +77,7 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
54
77
 
55
78
  const disabledPlugins = config.disabledPlugins;
56
79
  const registry = getSharedRegistry();
80
+ const detailTabsSnapshot = useRegistryDetailTabsSnapshot(registry);
57
81
  const pluginTabs = useMemo<DetailTabDef[]>(() => (
58
82
  registry
59
83
  ? [...registry.detailTabs.values()].filter((tab) => {
@@ -61,7 +85,7 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
61
85
  return !ownerId || !disabledPlugins.includes(ownerId);
62
86
  })
63
87
  : []
64
- ), [disabledPlugins, registry]);
88
+ ), [disabledPlugins, registry, detailTabsSnapshot]);
65
89
  const allTabs = buildVisibleDetailTabs(pluginTabs, ticker, financials, {
66
90
  config,
67
91
  hasOptionsChain,
@@ -84,26 +108,17 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
84
108
  return next;
85
109
  }, [mountedTabIds, resolvedTabId, visibleTabIds]);
86
110
 
87
- const allTabsRef = useRef(allTabs);
88
- allTabsRef.current = allTabs;
89
-
90
111
  const stateRef = useRef({
91
112
  focused,
92
113
  chartInteractive,
93
114
  pluginCaptured,
94
115
  activeTabId: resolvedTabId,
95
- tabIdx: Math.max(0, allTabs.findIndex((tab) => tab.id === resolvedTabId)),
96
- allTabCount: allTabs.length,
97
- hideTabs: paneSettings.hideTabs,
98
116
  });
99
117
  stateRef.current = {
100
118
  focused,
101
119
  chartInteractive,
102
120
  pluginCaptured,
103
121
  activeTabId: resolvedTabId,
104
- tabIdx: Math.max(0, allTabs.findIndex((tab) => tab.id === resolvedTabId)),
105
- allTabCount: allTabs.length,
106
- hideTabs: paneSettings.hideTabs,
107
122
  };
108
123
 
109
124
  const setChartInteractiveEager = useCallback((value: boolean) => {
@@ -162,17 +177,7 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
162
177
  if (currentState.chartInteractive) return;
163
178
  }
164
179
 
165
- if (currentState.hideTabs) return;
166
-
167
- const tabs = allTabsRef.current;
168
- if (event.name === "h" || event.name === "left") {
169
- const nextIndex = Math.max(currentState.tabIdx - 1, 0);
170
- setActiveTabId(tabs[nextIndex]!.id);
171
- } else if (event.name === "l" || event.name === "right") {
172
- const nextIndex = Math.min(currentState.tabIdx + 1, currentState.allTabCount - 1);
173
- setActiveTabId(tabs[nextIndex]!.id);
174
- }
175
- }, [setActiveTabId, setChartInteractiveEager]);
180
+ }, [setChartInteractiveEager]);
176
181
 
177
182
  useShortcut(handleKeyboard);
178
183
 
@@ -196,6 +201,7 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
196
201
  tabs={allTabs.map((tab) => ({ label: tab.name, value: tab.id }))}
197
202
  activeValue={resolvedTabId}
198
203
  onSelect={setActiveTabId}
204
+ focused={focused && !pluginCaptured && !(resolvedTabId === "chart" && chartInteractive)}
199
205
  />
200
206
  )}
201
207
 
@@ -19,6 +19,7 @@ import {
19
19
  type BrokerInstanceConfig,
20
20
  } from "../../types/config";
21
21
  import { createTestPluginRuntime } from "../../test-support/plugin-runtime";
22
+ import { EventBus } from "../event-bus";
22
23
  import type { DataProvider } from "../../types/data-provider";
23
24
  import type { TickerFinancials } from "../../types/financials";
24
25
  import type { DetailTabDef } from "../../types/plugin";
@@ -543,6 +544,43 @@ describe("TickerDetailPane", () => {
543
544
  expect(frame).not.toContain("SEC");
544
545
  });
545
546
 
547
+ test("refreshes plugin tabs when registration completes after the pane mounted", async () => {
548
+ const detailTabs = new Map<string, DetailTabDef>();
549
+ const events = new EventBus();
550
+ setSharedRegistryForTests({
551
+ detailTabs,
552
+ events,
553
+ getDetailTabPluginId: () => "company-research",
554
+ } as unknown as PluginRegistry);
555
+ setOptionsProvider(createProvider(false));
556
+
557
+ testSetup = await testRender(
558
+ <DetailHarness
559
+ config={createDetailConfig("AAPL")}
560
+ ticker={makeTicker("AAPL")}
561
+ financials={null}
562
+ />,
563
+ { width: 90, height: 24 },
564
+ );
565
+
566
+ await flushFrame();
567
+ expect(testSetup.captureCharFrame()).not.toContain("Analyst");
568
+
569
+ await act(async () => {
570
+ detailTabs.set("analyst-research", {
571
+ id: "analyst-research",
572
+ name: "Analyst",
573
+ order: 32,
574
+ component: () => <text>Analyst body</text>,
575
+ isVisible: ({ ticker }) => !!ticker,
576
+ });
577
+ events.emit("plugin:registered", { pluginId: "company-research" });
578
+ });
579
+ await flushFrame();
580
+
581
+ expect(testSetup.captureCharFrame()).toContain("Analyst");
582
+ });
583
+
546
584
  test("passes visible tab content height to plugin tabs", async () => {
547
585
  let receivedHeight: number | null = null;
548
586
  const probeTab: DetailTabDef["component"] = ({ height }) => {
@@ -20,6 +20,7 @@ import {
20
20
  usePluginPaneActions,
21
21
  usePluginPaneState,
22
22
  usePluginState,
23
+ usePluginTickerActions,
23
24
  useSetPluginConfigStates,
24
25
  } from "./plugin-runtime";
25
26
 
@@ -244,6 +245,39 @@ describe("plugin runtime hooks", () => {
244
245
  ]);
245
246
  });
246
247
 
248
+ test("scopes ticker navigation to the rendering pane", async () => {
249
+ const calls: string[] = [];
250
+ let actions: ReturnType<typeof usePluginTickerActions> | null = null;
251
+
252
+ const runtime = createTestPluginRuntime({
253
+ navigateTicker(symbol: string, options?: { sourcePaneId?: string | null }) {
254
+ calls.push(`${symbol}:${options?.sourcePaneId ?? ""}`);
255
+ },
256
+ });
257
+
258
+ function HookProbe() {
259
+ actions = usePluginTickerActions();
260
+ return <text>ticker-actions</text>;
261
+ }
262
+
263
+ testSetup = await testRender(
264
+ <PaneInstanceProvider paneId="comparison-chart:main">
265
+ <PluginRenderProvider pluginId="comparison-chart" runtime={runtime}>
266
+ <HookProbe />
267
+ </PluginRenderProvider>
268
+ </PaneInstanceProvider>,
269
+ { width: 40, height: 5 },
270
+ );
271
+
272
+ await act(async () => {
273
+ await testSetup!.renderOnce();
274
+ });
275
+
276
+ actions?.navigateTicker("MSFT");
277
+
278
+ expect(calls).toEqual(["MSFT:comparison-chart:main"]);
279
+ });
280
+
247
281
  test("exposes renderer pane actions through the plugin hook", async () => {
248
282
  const calls: string[] = [];
249
283
  let actions: ReturnType<typeof usePluginPaneActions> | null = null;
@@ -13,6 +13,7 @@ import {
13
13
  useAppDispatch,
14
14
  useAppSelector,
15
15
  useAppStateRef,
16
+ useOptionalPaneInstanceId,
16
17
  usePaneInstanceId,
17
18
  type PaneRuntimeState,
18
19
  } from "../state/app-context";
@@ -30,7 +31,7 @@ export interface PluginRuntimeAccess {
30
31
  syncBrokerInstance(instanceId: string): Promise<void>;
31
32
  removeBrokerInstance(instanceId: string): Promise<void>;
32
33
  pinTicker(symbol: string, options?: { floating?: boolean; paneType?: string }): void;
33
- navigateTicker(symbol: string): void;
34
+ navigateTicker(symbol: string, options?: { sourcePaneId?: string | null }): void;
34
35
  selectTicker(symbol: string, paneId?: string): void;
35
36
  switchTab(tabId: string, paneId?: string): void;
36
37
  switchPanel(panel: "left" | "right"): void;
@@ -85,9 +86,13 @@ function usePluginRenderContext(): PluginRenderContextValue {
85
86
 
86
87
  export function usePluginTickerActions() {
87
88
  const { runtime } = usePluginRenderContext();
89
+ const sourcePaneId = useOptionalPaneInstanceId();
90
+ const navigateTicker = useCallback((symbol: string) => {
91
+ runtime.navigateTicker(symbol, { sourcePaneId });
92
+ }, [runtime, sourcePaneId]);
88
93
  return {
89
94
  pinTicker: runtime.pinTicker,
90
- navigateTicker: runtime.navigateTicker,
95
+ navigateTicker,
91
96
  };
92
97
  }
93
98
 
@@ -11,7 +11,6 @@ import { getAdjacentPredictionCategoryId } from "./categories";
11
11
  import { usePredictionMarketsDataState } from "./controller-data";
12
12
  import { resolvePredictionKeyboardCommand } from "./keyboard";
13
13
  import {
14
- getAdjacentPredictionDetailTab,
15
14
  getAdjacentPredictionVenueScope,
16
15
  parsePredictionSearchShortcut,
17
16
  parsePredictionVenueScope,
@@ -520,20 +519,6 @@ export function usePredictionMarketsController({
520
519
  return;
521
520
  }
522
521
 
523
- if (command === "previous-category") {
524
- event.stopPropagation?.();
525
- event.preventDefault?.();
526
- setDetailTab(getAdjacentPredictionDetailTab(detailTab, "previous"));
527
- return;
528
- }
529
-
530
- if (command === "next-category") {
531
- event.stopPropagation?.();
532
- event.preventDefault?.();
533
- setDetailTab(getAdjacentPredictionDetailTab(detailTab, "next"));
534
- return;
535
- }
536
-
537
522
  return;
538
523
  }
539
524
 
@@ -243,6 +243,7 @@ export function PredictionMarketDetailPane({
243
243
  activeValue={detailTab}
244
244
  onSelect={(value) => onDetailTabChange(value as PredictionDetailTab)}
245
245
  compact
246
+ focused={focused}
246
247
  />
247
248
  </Box>
248
249
 
@@ -141,7 +141,7 @@ export class PluginRegistry implements PluginRuntimeAccess {
141
141
  hidePaneFn: ((paneId: string) => void) = () => {};
142
142
  focusPaneFn: ((paneId: string) => void) = () => {};
143
143
  pinTickerFn: ((symbol: string, options?: { floating?: boolean; paneType?: string }) => void) = () => {};
144
- navigateTickerFn: ((symbol: string) => void) = () => {};
144
+ navigateTickerFn: ((symbol: string, options?: { sourcePaneId?: string | null }) => void) = () => {};
145
145
  getMarketData = () => this.marketData;
146
146
  getCapability = (capabilityId: string) => this.capabilities.get(capabilityId)?.capability ?? null;
147
147
  getBrokerAdapter = (brokerType: string) => this.brokersMap.get(brokerType) ?? null;
@@ -154,8 +154,8 @@ export class PluginRegistry implements PluginRuntimeAccess {
154
154
  pinTicker = (symbol: string, options?: { floating?: boolean; paneType?: string }) => {
155
155
  this.pinTickerFn(symbol, options);
156
156
  };
157
- navigateTicker = (symbol: string) => {
158
- this.navigateTickerFn(symbol);
157
+ navigateTicker = (symbol: string, options?: { sourcePaneId?: string | null }) => {
158
+ this.navigateTickerFn(symbol, options);
159
159
  };
160
160
  selectTicker = (symbol: string, paneId?: string) => {
161
161
  this.selectTickerFn(symbol, paneId);
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createPaneInstance, type LayoutConfig, type PaneInstanceConfig } from "../types/config";
3
+ import {
4
+ resolveTickerNavigationDetailPane,
5
+ shouldFocusTickerNavigationTarget,
6
+ } from "./ticker-navigation";
7
+
8
+ function createLayout(instances: PaneInstanceConfig[]): LayoutConfig {
9
+ return {
10
+ dockRoot: { kind: "pane", instanceId: instances[0]?.instanceId ?? "" },
11
+ instances,
12
+ floating: instances.slice(1).map((instance, index) => ({
13
+ instanceId: instance.instanceId,
14
+ x: 2 + index,
15
+ y: 2 + index,
16
+ width: 40,
17
+ height: 12,
18
+ })),
19
+ detached: [],
20
+ };
21
+ }
22
+
23
+ describe("resolveTickerNavigationDetailPane", () => {
24
+ test("prefers the detail pane following the pane that opened ticker navigation", () => {
25
+ const layout = createLayout([
26
+ createPaneInstance("portfolio-list", {
27
+ instanceId: "portfolio-list:main",
28
+ binding: { kind: "none" },
29
+ }),
30
+ createPaneInstance("comparison-chart", {
31
+ instanceId: "comparison-chart:main",
32
+ binding: { kind: "none" },
33
+ }),
34
+ createPaneInstance("ticker-detail", {
35
+ instanceId: "ticker-detail:portfolio",
36
+ binding: { kind: "follow", sourceInstanceId: "portfolio-list:main" },
37
+ }),
38
+ createPaneInstance("ticker-detail", {
39
+ instanceId: "ticker-detail:compare",
40
+ binding: { kind: "follow", sourceInstanceId: "comparison-chart:main" },
41
+ }),
42
+ ]);
43
+
44
+ expect(
45
+ resolveTickerNavigationDetailPane(layout, "comparison-chart:main")?.instanceId,
46
+ ).toBe("ticker-detail:compare");
47
+ });
48
+ });
49
+
50
+ describe("shouldFocusTickerNavigationTarget", () => {
51
+ test("does not focus stale async ticker navigation after the user moves to another pane", () => {
52
+ expect(shouldFocusTickerNavigationTarget({
53
+ sourcePaneId: "comparison-chart:main",
54
+ currentFocusedPaneId: "portfolio-list:main",
55
+ targetPaneId: "ticker-detail:compare",
56
+ })).toBe(false);
57
+ });
58
+
59
+ test("keeps focusing when the source pane still owns the latest interaction", () => {
60
+ expect(shouldFocusTickerNavigationTarget({
61
+ sourcePaneId: "comparison-chart:main",
62
+ currentFocusedPaneId: "comparison-chart:main",
63
+ targetPaneId: "ticker-detail:compare",
64
+ })).toBe(true);
65
+ });
66
+ });
@@ -0,0 +1,44 @@
1
+ import { findPaneInstance, type LayoutConfig, type PaneInstanceConfig } from "../types/config";
2
+ import { isPaneInLayout } from "./pane-manager";
3
+
4
+ export function resolveTickerNavigationDetailPane(
5
+ layout: LayoutConfig,
6
+ sourcePaneId: string | null,
7
+ ): PaneInstanceConfig | null {
8
+ const sourceInstance = sourcePaneId ? findPaneInstance(layout, sourcePaneId) : null;
9
+
10
+ return (
11
+ sourceInstance?.paneId === "ticker-detail" && isPaneInLayout(layout, sourceInstance.instanceId)
12
+ ? sourceInstance
13
+ : null
14
+ )
15
+ ?? layout.instances.find((instance) =>
16
+ instance.paneId === "ticker-detail"
17
+ && instance.binding?.kind === "follow"
18
+ && instance.binding.sourceInstanceId === sourcePaneId
19
+ && isPaneInLayout(layout, instance.instanceId)
20
+ )
21
+ ?? layout.instances.find((instance) =>
22
+ instance.paneId === "ticker-detail"
23
+ && instance.binding?.kind === "follow"
24
+ && isPaneInLayout(layout, instance.instanceId)
25
+ )
26
+ ?? layout.instances.find((instance) =>
27
+ instance.paneId === "ticker-detail"
28
+ && isPaneInLayout(layout, instance.instanceId)
29
+ )
30
+ ?? null;
31
+ }
32
+
33
+ export function shouldFocusTickerNavigationTarget({
34
+ sourcePaneId,
35
+ currentFocusedPaneId,
36
+ targetPaneId,
37
+ }: {
38
+ sourcePaneId: string | null;
39
+ currentFocusedPaneId: string | null;
40
+ targetPaneId: string | null;
41
+ }): boolean {
42
+ if (!sourcePaneId) return true;
43
+ return currentFocusedPaneId === sourcePaneId || currentFocusedPaneId === targetPaneId;
44
+ }
@@ -19,7 +19,7 @@ export interface KeyEventLike {
19
19
  export interface ShortcutOptions {
20
20
  enabled?: boolean;
21
21
  scope?: string;
22
- phase?: "normal" | "after";
22
+ phase?: "before" | "normal" | "after";
23
23
  }
24
24
 
25
25
  export interface InputHost {