gloomberb 0.8.0 → 0.8.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 (122) hide show
  1. package/README.md +102 -52
  2. package/package.json +1 -1
  3. package/src/api-client/chat.ts +8 -0
  4. package/src/api-client/index.test.ts +30 -0
  5. package/src/api-client/index.ts +4 -0
  6. package/src/api-client/normalizers.ts +1 -0
  7. package/src/api-client/types.ts +1 -0
  8. package/src/capabilities/factories.ts +4 -0
  9. package/src/components/chart/comparison/data.test.ts +54 -0
  10. package/src/components/chart/comparison/data.ts +47 -24
  11. package/src/components/chart/core/projection.ts +51 -27
  12. package/src/components/data-table/stack-view.test.tsx +14 -11
  13. package/src/components/data-table/stack-view.tsx +1 -2
  14. package/src/components/data-table/view.test.tsx +24 -18
  15. package/src/components/data-table/view.tsx +313 -83
  16. package/src/components/feed-data-table/stack-view.tsx +6 -8
  17. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  18. package/src/components/layout/pane/footer/index.tsx +14 -2
  19. package/src/components/layout/pane/title.ts +5 -1
  20. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  21. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  22. package/src/components/ticker/list-table-view.tsx +11 -25
  23. package/src/components/ui/choice-dialog.tsx +12 -2
  24. package/src/components/ui/multi-select/dialog.tsx +17 -5
  25. package/src/components/ui/shortcut-hint.tsx +14 -2
  26. package/src/components/ui/table-layout.test.ts +2 -2
  27. package/src/components/ui/table-layout.ts +8 -2
  28. package/src/components/ui/tabs.tsx +20 -6
  29. package/src/components/ui/ui.test.tsx +30 -5
  30. package/src/market-data/coordinator/auxiliary.ts +29 -1
  31. package/src/market-data/coordinator/index.ts +17 -1
  32. package/src/market-data/hooks.tsx +17 -1
  33. package/src/market-data/selectors.ts +4 -0
  34. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  35. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  36. package/src/plugins/builtin/account-management/model.ts +13 -3
  37. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  38. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  39. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  40. package/src/plugins/builtin/analytics/index.tsx +0 -3
  41. package/src/plugins/builtin/analytics/view.tsx +6 -5
  42. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  43. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  44. package/src/plugins/builtin/changelog/index.tsx +40 -27
  45. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  46. package/src/plugins/builtin/changelog/model.ts +98 -0
  47. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  48. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  49. package/src/plugins/builtin/chat/channels.ts +36 -19
  50. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  51. package/src/plugins/builtin/chat/content/channel-navigation.ts +6 -0
  52. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  53. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  54. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  55. package/src/plugins/builtin/chat/content/index.tsx +61 -0
  56. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +20 -1
  58. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  59. package/src/plugins/builtin/chat/content/types.ts +1 -0
  60. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  61. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  62. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  63. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  64. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  65. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  66. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  67. package/src/plugins/builtin/chat/layout.ts +94 -31
  68. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  69. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  70. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  71. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  72. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  73. package/src/plugins/builtin/chat/message/types.ts +2 -0
  74. package/src/plugins/builtin/chat/pane.tsx +35 -7
  75. package/src/plugins/builtin/chat/sidebar.test.tsx +1 -0
  76. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  77. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  78. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  79. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  80. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  81. package/src/plugins/builtin/earnings/index.tsx +8 -7
  82. package/src/plugins/builtin/econ/index.tsx +6 -8
  83. package/src/plugins/builtin/help/index.tsx +6 -0
  84. package/src/plugins/builtin/holders/pane.tsx +7 -6
  85. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  86. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  87. package/src/plugins/builtin/options/view.tsx +16 -8
  88. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  89. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  90. package/src/plugins/builtin/research/index.test.ts +30 -1
  91. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  92. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  93. package/src/plugins/builtin/sec/index.tsx +132 -20
  94. package/src/plugins/builtin/sectors/index.tsx +13 -23
  95. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  96. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  97. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  98. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  99. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  100. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  101. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  102. package/src/plugins/prediction-markets/pane.tsx +10 -9
  103. package/src/plugins/registry/index.ts +3 -0
  104. package/src/plugins/runtime/context.tsx +1 -0
  105. package/src/plugins/runtime/index.tsx +4 -0
  106. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  107. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  108. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  109. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  110. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  111. package/src/sources/gloomberb-cloud/index.ts +4 -0
  112. package/src/sources/provider-router/cache.ts +1 -0
  113. package/src/sources/provider-router/document-routes.ts +45 -0
  114. package/src/sources/provider-router/index.ts +4 -0
  115. package/src/sources/sec-edgar/content.ts +9 -3
  116. package/src/sources/sec-edgar.test.ts +108 -1
  117. package/src/sources/sec-edgar.ts +105 -1
  118. package/src/sources/yahoo-finance.ts +4 -0
  119. package/src/test-support/plugin-runtime.ts +1 -0
  120. package/src/types/data-provider.ts +11 -0
  121. package/src/ui/host.tsx +1 -0
  122. package/src/version.ts +1 -1
@@ -0,0 +1,66 @@
1
+ import type { SecFilingDocument, SecFilingItem } from "../../../types/data-provider";
2
+
3
+ const SEC_EXHIBIT_TYPE_RE = /^EX[-\s]?\d/i;
4
+ const SEC_XBRL_EXHIBIT_TYPE_RE = /^EX[-\s]?101(?:\.|$)/i;
5
+ const SEC_NON_READABLE_DOCUMENT_RE = /\.(?:cal|def|gif|jpg|jpeg|json|lab|png|pre|sch|xsd|zip)(?:$|[?#])/i;
6
+ const SEC_SUPPORT_DOCUMENT_TYPE_RE = /^(?:GRAPHIC|XML|ZIP|EX[-\s]?101(?:\.|$)|EX-FILING FEES)/i;
7
+
8
+ export function documentContentKey(filing: SecFilingItem, document: SecFilingDocument): string {
9
+ return `${filing.accessionNumber}:${document.document || document.url}`;
10
+ }
11
+
12
+ export function documentContentTarget(filing: SecFilingItem, document: SecFilingDocument): SecFilingItem {
13
+ return {
14
+ ...filing,
15
+ accessionNumber: documentContentKey(filing, document),
16
+ form: document.type || filing.form,
17
+ primaryDocument: document.document,
18
+ primaryDocDescription: document.description,
19
+ primaryDocumentUrl: document.url,
20
+ filingUrl: document.url,
21
+ };
22
+ }
23
+
24
+ export function formatDocumentLabel(document: SecFilingDocument): string {
25
+ const label = document.isPrimary ? "PRIMARY" : document.type || "DOCUMENT";
26
+ const description = document.description
27
+ && document.description !== document.document
28
+ && document.description !== document.type
29
+ ? ` | ${document.description}`
30
+ : "";
31
+ const size = document.size ? ` | ${document.size}` : "";
32
+ return `${label.padEnd(8)} ${document.document}${description}${size}`;
33
+ }
34
+
35
+ export function formatCompactDocumentLabel(document: SecFilingDocument): string {
36
+ const label = document.isPrimary ? "PRIMARY" : document.type || "DOCUMENT";
37
+ const description = document.description
38
+ && document.description !== document.document
39
+ && document.description !== document.type
40
+ ? ` | ${document.description}`
41
+ : "";
42
+ return `${label} ${document.document}${description}`;
43
+ }
44
+
45
+ export function documentHeading(document: SecFilingDocument): string {
46
+ const label = document.type || "DOCUMENT";
47
+ const description = document.description
48
+ && document.description !== document.document
49
+ && document.description !== document.type
50
+ ? ` | ${document.description}`
51
+ : "";
52
+ return `${label} | ${document.document}${description}`;
53
+ }
54
+
55
+ export function isInlineExhibitDocument(document: SecFilingDocument): boolean {
56
+ const type = document.type.trim();
57
+ if (!SEC_EXHIBIT_TYPE_RE.test(type)) return false;
58
+ if (SEC_XBRL_EXHIBIT_TYPE_RE.test(type)) return false;
59
+ return !SEC_NON_READABLE_DOCUMENT_RE.test(document.document) && !SEC_NON_READABLE_DOCUMENT_RE.test(document.url);
60
+ }
61
+
62
+ export function isDefaultVisibleFilingDocument(document: SecFilingDocument): boolean {
63
+ if (document.isPrimary) return true;
64
+ if (isInlineExhibitDocument(document)) return true;
65
+ return !SEC_SUPPORT_DOCUMENT_TYPE_RE.test(document.type.trim());
66
+ }
@@ -1,10 +1,10 @@
1
1
  import { Text } from "../../../ui";
2
2
  import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import type { GloomPlugin } from "../../../types/plugin";
4
- import type { SecFilingItem } from "../../../types/data-provider";
5
- import { useResolvedEntryValue, useSecFilingContent, useSecFilingsQuery } from "../../../market-data/hooks";
4
+ import type { SecFilingDocument, SecFilingItem } from "../../../types/data-provider";
5
+ import { useResolvedEntryValue, useSecFilingContent, useSecFilingDocuments, useSecFilingsQuery } from "../../../market-data/hooks";
6
6
  import { instrumentFromTicker } from "../../../market-data/request-types";
7
- import { usePluginPaneState } from "../../runtime";
7
+ import { useDebouncedPluginPaneState } from "../../runtime";
8
8
  import { usePaneTicker } from "../../../state/app/context";
9
9
  import { colors } from "../../../theme/colors";
10
10
  import { FeedDataTableStackView, Spinner, useExternalLinkFooter, type FeedDataTableItem } from "../../../components";
@@ -17,6 +17,14 @@ import {
17
17
  formatFilingMetaDate,
18
18
  renderFilingNotice,
19
19
  } from "./filing-display";
20
+ import {
21
+ documentContentKey,
22
+ documentContentTarget,
23
+ documentHeading,
24
+ formatCompactDocumentLabel,
25
+ isDefaultVisibleFilingDocument,
26
+ isInlineExhibitDocument,
27
+ } from "./filing-documents";
20
28
 
21
29
  const SEC_FILING_LIMIT = 50;
22
30
  const OWNERSHIP_FORMS = new Set(["3", "4", "5"]);
@@ -80,6 +88,50 @@ function buildDetailBody(filing: SecFilingItem): string {
80
88
  : "No additional SEC filing description is available for this entry.";
81
89
  }
82
90
 
91
+ function buildDetailBodyWithDocuments({
92
+ filing,
93
+ documents,
94
+ documentsLoading,
95
+ contentCache,
96
+ primaryContent,
97
+ }: {
98
+ filing: SecFilingItem;
99
+ documents: SecFilingDocument[];
100
+ documentsLoading: boolean;
101
+ contentCache: Map<string, string | null>;
102
+ primaryContent: string;
103
+ }): string {
104
+ const lines: string[] = [];
105
+ lines.push("Documents");
106
+ if (documentsLoading && documents.length === 0) {
107
+ lines.push("Loading filing documents...");
108
+ } else if (documents.length === 0) {
109
+ lines.push("No filing documents were listed for this filing.");
110
+ } else {
111
+ const visibleDocuments = documents.filter(isDefaultVisibleFilingDocument);
112
+ lines.push(...visibleDocuments.map(formatCompactDocumentLabel));
113
+ const hiddenCount = documents.length - visibleDocuments.length;
114
+ if (hiddenCount > 0) lines.push(`+ ${hiddenCount} support documents hidden`);
115
+ }
116
+
117
+ const exhibits = documents.filter(isInlineExhibitDocument);
118
+ if (exhibits.length > 0) {
119
+ lines.push("", "Inline Exhibits");
120
+ for (const document of exhibits) {
121
+ const key = documentContentKey(filing, document);
122
+ const hasContent = contentCache.has(key);
123
+ const content = contentCache.get(key);
124
+ lines.push("", documentHeading(document));
125
+ lines.push(hasContent
126
+ ? content || "Readable document content was not available for this exhibit."
127
+ : "Loading exhibit content...");
128
+ }
129
+ }
130
+
131
+ lines.push("", "Primary Filing Content", primaryContent);
132
+ return lines.join("\n");
133
+ }
134
+
83
135
  function buildForm4Preview(content: string | null): string | null {
84
136
  if (!content) return null;
85
137
  const tx = parseForm4Xml(content);
@@ -134,6 +186,8 @@ function toFeedItems(
134
186
  selectedAccessionNumber: string | undefined,
135
187
  contentCache: Map<string, string | null>,
136
188
  loadingContent: boolean,
189
+ selectedDocuments: SecFilingDocument[],
190
+ loadingDocuments: boolean,
137
191
  ): FeedDataTableItem[] {
138
192
  return filings.map((filing) => {
139
193
  const displayTitle = getFilingDisplayTitle(filing);
@@ -152,6 +206,19 @@ function toFeedItems(
152
206
  const form4Detail = isOwnership && hasFetchedContent
153
207
  ? buildForm4Detail(fetchedContent ?? null, filing)
154
208
  : null;
209
+ const selected = filing.accessionNumber === selectedAccessionNumber;
210
+ const primaryDetailBody = loadingContent && selected
211
+ ? "Loading filing content..."
212
+ : form4Detail ?? fetchedContent ?? fallbackBody;
213
+ const detailBody = selected
214
+ ? buildDetailBodyWithDocuments({
215
+ filing,
216
+ documents: selectedDocuments,
217
+ documentsLoading: loadingDocuments,
218
+ contentCache,
219
+ primaryContent: primaryDetailBody,
220
+ })
221
+ : form4Detail ?? fallbackBody;
155
222
 
156
223
  const enrichedTitle = formDesc
157
224
  ? `${displayTitle} — ${formDesc}`
@@ -170,13 +237,7 @@ function toFeedItems(
170
237
  `Accession ${filing.accessionNumber}`,
171
238
  ...(filing.items ? [`Items ${filing.items}`] : []),
172
239
  ],
173
- detailBody: filing.accessionNumber === selectedAccessionNumber
174
- ? (
175
- loadingContent
176
- ? "Loading filing content..."
177
- : form4Detail ?? fetchedContent ?? fallbackBody
178
- )
179
- : form4Detail ?? fallbackBody,
240
+ detailBody,
180
241
  };
181
242
  });
182
243
  }
@@ -184,7 +245,7 @@ function toFeedItems(
184
245
  function SecView({ width, height, focused }: { width: number; height: number; focused: boolean }) {
185
246
  const { ticker } = usePaneTicker();
186
247
  const selectionKey = `selectedIdx:${ticker?.metadata.ticker ?? "none"}`;
187
- const [selectedIdx, setSelectedIdx] = usePluginPaneState<number>(selectionKey, 0);
248
+ const [selectedIdx, setSelectedIdx] = useDebouncedPluginPaneState<number>(selectionKey, 0);
188
249
  const [contentCache, setContentCache] = useState<Map<string, string | null>>(new Map());
189
250
  const [openItemId, setOpenItemId] = useState<string | null>(null);
190
251
  const contentFetchRef = useRef(0);
@@ -204,28 +265,67 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
204
265
  contentFetchRef.current += 1;
205
266
  }, [eligibleTicker, ticker?.metadata.exchange, ticker?.metadata.ticker]);
206
267
 
207
- const selected = filings[selectedIdx];
208
268
  const openFiling = openItemId
209
269
  ? filings.find((filing) => filing.accessionNumber === openItemId) ?? null
210
270
  : null;
211
- const cachedSelectedContent = selected ? contentCache.get(selected.accessionNumber) : undefined;
271
+ const cachedOpenContent = openFiling ? contentCache.get(openFiling.accessionNumber) : undefined;
212
272
  const filingContentEntry = useSecFilingContent(
213
- selected && cachedSelectedContent === undefined ? selected : null,
273
+ openFiling && cachedOpenContent === undefined ? openFiling : null,
214
274
  );
215
275
  const filingContent = useResolvedEntryValue(filingContentEntry);
216
276
  const loadingContent = filingContentEntry?.phase === "loading" || filingContentEntry?.phase === "refreshing";
277
+ const documentsEntry = useSecFilingDocuments(openFiling ?? null);
278
+ const openDocuments = useResolvedEntryValue(documentsEntry) ?? [];
279
+ const loadingDocuments = !!openFiling && (
280
+ documentsEntry?.phase === "idle"
281
+ || documentsEntry?.phase === "loading"
282
+ || documentsEntry?.phase === "refreshing"
283
+ );
217
284
 
218
285
  useEffect(() => {
219
- if (!selected) return;
220
- if (cachedSelectedContent !== undefined) return;
286
+ if (!openFiling) return;
287
+ if (cachedOpenContent !== undefined) return;
221
288
  if (filingContentEntry?.phase === "error") {
222
- setContentCache((prev) => new Map(prev).set(selected.accessionNumber, null));
289
+ setContentCache((prev) => new Map(prev).set(openFiling.accessionNumber, null));
223
290
  return;
224
291
  }
225
292
  if (filingContent !== null) {
226
- setContentCache((prev) => new Map(prev).set(selected.accessionNumber, filingContent));
293
+ setContentCache((prev) => new Map(prev).set(openFiling.accessionNumber, filingContent));
294
+ }
295
+ }, [cachedOpenContent, filingContent, filingContentEntry?.phase, openFiling]);
296
+
297
+ const documentFetchStartedRef = useRef(new Set<string>());
298
+ useEffect(() => {
299
+ if (!openFiling || openDocuments.length === 0) return;
300
+ const coordinator = getSharedMarketDataCoordinator();
301
+ if (!coordinator) return;
302
+ const gen = contentFetchRef.current;
303
+ const documentsToFetch = openDocuments.filter((document) => {
304
+ if (!isInlineExhibitDocument(document)) return false;
305
+ const key = documentContentKey(openFiling, document);
306
+ return !contentCache.has(key) && !documentFetchStartedRef.current.has(key);
307
+ });
308
+ if (documentsToFetch.length === 0) return;
309
+
310
+ for (const document of documentsToFetch) {
311
+ documentFetchStartedRef.current.add(documentContentKey(openFiling, document));
227
312
  }
228
- }, [cachedSelectedContent, filingContent, filingContentEntry?.phase, selected]);
313
+
314
+ (async () => {
315
+ for (const document of documentsToFetch) {
316
+ if (contentFetchRef.current !== gen) return;
317
+ const key = documentContentKey(openFiling, document);
318
+ try {
319
+ const entry = await coordinator.loadSecFilingContent(documentContentTarget(openFiling, document));
320
+ if (contentFetchRef.current !== gen) return;
321
+ setContentCache((prev) => new Map(prev).set(key, entry.data ?? entry.lastGoodData ?? null));
322
+ } catch {
323
+ if (contentFetchRef.current !== gen) return;
324
+ setContentCache((prev) => new Map(prev).set(key, null));
325
+ }
326
+ }
327
+ })();
328
+ }, [contentCache, openFiling, openDocuments]);
229
329
 
230
330
  useEffect(() => {
231
331
  if (filings.length > 0 && selectedIdx >= filings.length) {
@@ -235,6 +335,11 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
235
335
 
236
336
  // Background prefetch Form 4 content for enriched previews
237
337
  const prefetchStartedRef = useRef(new Set<string>());
338
+ useEffect(() => {
339
+ prefetchStartedRef.current.clear();
340
+ documentFetchStartedRef.current.clear();
341
+ }, [eligibleTicker, ticker?.metadata.exchange, ticker?.metadata.ticker]);
342
+
238
343
  useEffect(() => {
239
344
  if (filings.length === 0) return;
240
345
  const coordinator = getSharedMarketDataCoordinator();
@@ -288,7 +393,14 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
288
393
  width={width}
289
394
  height={height}
290
395
  focused={focused}
291
- items={toFeedItems(filings, selected?.accessionNumber, contentCache, loadingContent)}
396
+ items={toFeedItems(
397
+ filings,
398
+ openFiling?.accessionNumber,
399
+ contentCache,
400
+ loadingContent,
401
+ openDocuments,
402
+ loadingDocuments,
403
+ )}
292
404
  selectedIdx={selectedIdx}
293
405
  onSelect={setSelectedIdx}
294
406
  onOpenItemIdChange={setOpenItemId}
@@ -71,11 +71,6 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
71
71
  label: collection.label,
72
72
  value: collection.id,
73
73
  })), []);
74
- const selectedIdx = selectedEtf
75
- ? sortedRows.findIndex((row) => row.etf === selectedEtf)
76
- : -1;
77
- const activeIdx = selectedIdx >= 0 ? selectedIdx : (sortedRows.length > 0 ? 0 : -1);
78
-
79
74
  const fetchAll = useCallback(() => {
80
75
  fetchGenRef.current += 1;
81
76
  const gen = fetchGenRef.current;
@@ -189,19 +184,6 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
189
184
  setSortPreference((current) => nextSortPreference(current, columnId));
190
185
  }, [setSortPreference]);
191
186
 
192
- const selectIndex = useCallback((index: number) => {
193
- const row = sortedRows[index];
194
- if (row) setSelectedEtf(row.etf);
195
- }, [setSelectedEtf, sortedRows]);
196
-
197
- const selectRow = useCallback((row: SectorRow) => {
198
- if (row.etf === selectedEtf) {
199
- openRow(row);
200
- return;
201
- }
202
- setSelectedEtf(row.etf);
203
- }, [openRow, selectedEtf, setSelectedEtf]);
204
-
205
187
  const handleTableKeyDown = useCallback((event: DataTableKeyEvent) => {
206
188
  if (event.name === "r") {
207
189
  event.preventDefault?.();
@@ -291,9 +273,19 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
291
273
  return (
292
274
  <DataTableView<SectorRow, SectorColumn>
293
275
  focused={focused}
294
- selectedIndex={activeIdx}
295
- onSelectIndex={selectIndex}
296
- onActivateIndex={(_index, row) => openRow(row)}
276
+ selection={{
277
+ kind: "id",
278
+ selectedId: selectedEtf,
279
+ getId: (row) => row.etf,
280
+ onChange: (id, row, _index, reason) => {
281
+ if (reason === "pointer" && id === selectedEtf) {
282
+ openRow(row);
283
+ return;
284
+ }
285
+ setSelectedEtf(id);
286
+ },
287
+ }}
288
+ onActivate={openRow}
297
289
  onRootKeyDown={handleTableKeyDown}
298
290
  rootBefore={rootBefore}
299
291
  rootWidth={width}
@@ -305,8 +297,6 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
305
297
  sortDirection={sortPreference.direction}
306
298
  onHeaderClick={handleHeaderClick}
307
299
  getItemKey={(row) => row.etf}
308
- isSelected={(row) => row.etf === selectedEtf}
309
- onSelect={selectRow}
310
300
  renderCell={renderCell}
311
301
  emptyStateTitle="No sector data available"
312
302
  showHorizontalScrollbar={false}
@@ -11,6 +11,7 @@ export function useBoundTicker() {
11
11
  const { symbol, ticker } = usePaneTicker();
12
12
  return {
13
13
  symbol,
14
+ ticker,
14
15
  exchange: ticker?.metadata.exchange ?? "",
15
16
  currency: ticker?.metadata.currency ?? "USD",
16
17
  };
@@ -344,9 +344,13 @@ export function ThirteenFPane({ focused, width, height }: PaneProps) {
344
344
  ) : (
345
345
  <Box flexGrow={1} />
346
346
  )}
347
- selectedIndex={selectedIndex}
348
- onSelectIndex={(_index, row) => setSelectedId(row.id)}
349
- onActivateIndex={(_index, row) => openDetail(row)}
347
+ selection={{
348
+ kind: "id",
349
+ selectedId,
350
+ getId: (row) => row.id,
351
+ onChange: (id) => setSelectedId(id),
352
+ }}
353
+ onActivate={openDetail}
350
354
  onRootKeyDown={handleRootKeyDown}
351
355
  rootWidth={width}
352
356
  rootBefore={rootBefore}
@@ -365,9 +369,6 @@ export function ThirteenFPane({ focused, width, height }: PaneProps) {
365
369
  ));
366
370
  }}
367
371
  getItemKey={(row) => row.id}
368
- isSelected={(row) => row.id === selectedId}
369
- onSelect={(row) => setSelectedId(row.id)}
370
- onActivate={openDetail}
371
372
  renderCell={renderBrowserCell}
372
373
  emptyStateTitle={emptyTitle}
373
374
  showHorizontalScrollbar={false}
@@ -625,11 +626,13 @@ function FundDetailView({
625
626
  ) : (
626
627
  <Box flexGrow={1} />
627
628
  )}
628
- selectedIndex={selectedFilingIndex}
629
- onSelectIndex={(_index, row) => setFilingSelectedId(row.id)}
630
- onActivateIndex={(_index, row) => {
631
- openFilingInPane(row);
629
+ selection={{
630
+ kind: "id",
631
+ selectedId: filingSelectedId,
632
+ getId: (row) => row.id,
633
+ onChange: (id) => setFilingSelectedId(id),
632
634
  }}
635
+ onActivate={openFilingInPane}
633
636
  onDetailKeyDown={(event) => {
634
637
  if (event.name !== "o" || !openFiling?.url) return false;
635
638
  event.preventDefault?.();
@@ -648,11 +651,6 @@ function FundDetailView({
648
651
  columnId === "period" || columnId === "filed" ? "desc" : "desc",
649
652
  ))}
650
653
  getItemKey={(row) => row.id}
651
- isSelected={(row) => row.id === filingSelectedId}
652
- onSelect={(row) => setFilingSelectedId(row.id)}
653
- onActivate={(row) => {
654
- openFilingInPane(row);
655
- }}
656
654
  renderCell={renderTimelineCell}
657
655
  emptyStateTitle="No 13F filings."
658
656
  showHorizontalScrollbar={false}
@@ -660,8 +658,12 @@ function FundDetailView({
660
658
  ) : (
661
659
  <DataTableView<FundHoldingRow, FundHoldingColumn>
662
660
  focused={focused}
663
- selectedIndex={selectedHoldingIndex}
664
- onSelectIndex={(_index, row) => setHoldingSelectedId(row.id)}
661
+ selection={{
662
+ kind: "id",
663
+ selectedId: holdingSelectedId,
664
+ getId: (row) => row.id,
665
+ onChange: (id) => setHoldingSelectedId(id),
666
+ }}
665
667
  rootWidth={width}
666
668
  columns={buildHoldingColumns(width)}
667
669
  items={visibleHoldingRows}
@@ -675,8 +677,6 @@ function FundDetailView({
675
677
  ));
676
678
  }}
677
679
  getItemKey={(row) => row.id}
678
- isSelected={(row) => row.id === holdingSelectedId}
679
- onSelect={(row) => setHoldingSelectedId(row.id)}
680
680
  onActivate={(row) => {
681
681
  if (row.ticker) pinTicker(row.ticker, { floating: true, paneType: TICKER_RESEARCH_PANE_ID });
682
682
  }}
@@ -795,9 +795,13 @@ function FilingDetailView({
795
795
  <Box flexDirection="column" width={width} flexGrow={1} overflow="hidden">
796
796
  <DataTableView<FilingPositionRow, FilingPositionColumn>
797
797
  focused={focused}
798
- selectedIndex={selectedPositionIndex}
799
- onSelectIndex={(_index, row) => setSelectedPositionId(row.id)}
800
- onActivateIndex={(_index, row) => openPositionTicker(row)}
798
+ selection={{
799
+ kind: "id",
800
+ selectedId: selectedPositionId,
801
+ getId: (row) => row.id,
802
+ onChange: (id) => setSelectedPositionId(id),
803
+ }}
804
+ onActivate={openPositionTicker}
801
805
  onRootKeyDown={handlePositionKeyDown}
802
806
  rootWidth={width}
803
807
  rootBefore={summary}
@@ -814,9 +818,6 @@ function FilingDetailView({
814
818
  ));
815
819
  }}
816
820
  getItemKey={(row) => row.id}
817
- isSelected={(row) => row.id === selectedPositionId}
818
- onSelect={(row) => setSelectedPositionId(row.id)}
819
- onActivate={openPositionTicker}
820
821
  renderCell={renderFilingPositionCell}
821
822
  emptyStateTitle={emptyTitle}
822
823
  showHorizontalScrollbar={false}
@@ -137,8 +137,11 @@ export function FundamentalGraphContent({
137
137
  <StaticBarChartSurface width={width} height={chartHeight} series={chartSeries} />
138
138
  <DataTableView<FundamentalGraphRow, FundamentalColumn>
139
139
  focused={focused}
140
- selectedIndex={boundedSelectedIdx}
141
- onSelectIndex={(index) => setSelectedIdx(index)}
140
+ selection={{
141
+ kind: "index",
142
+ selectedIndex: boundedSelectedIdx,
143
+ onChange: (index) => setSelectedIdx(index),
144
+ }}
142
145
  onRootKeyDown={handleKeyDown}
143
146
  rootWidth={width}
144
147
  rootHeight={tableHeight}
@@ -148,8 +151,6 @@ export function FundamentalGraphContent({
148
151
  sortDirection="asc"
149
152
  onHeaderClick={() => {}}
150
153
  getItemKey={(row) => row.key}
151
- isSelected={(_row, index) => index === boundedSelectedIdx}
152
- onSelect={(_row, index) => setSelectedIdx(index)}
153
154
  renderCell={renderCell}
154
155
  emptyStateTitle={loading ? "Loading fundamentals..." : "No graph data"}
155
156
  />
@@ -172,8 +172,11 @@ export function HistoricalPricesPane({ focused, width, height }: PaneProps) {
172
172
  return (
173
173
  <DataTableView<HistoricalPriceRow, HistoryColumn>
174
174
  focused={focused}
175
- selectedIndex={boundedSelectedIdx}
176
- onSelectIndex={(index) => setSelectedIdx(index)}
175
+ selection={{
176
+ kind: "index",
177
+ selectedIndex: boundedSelectedIdx,
178
+ onChange: (index) => setSelectedIdx(index),
179
+ }}
177
180
  onRootKeyDown={handleKeyDown}
178
181
  rootWidth={width}
179
182
  rootHeight={height}
@@ -183,8 +186,6 @@ export function HistoricalPricesPane({ focused, width, height }: PaneProps) {
183
186
  sortDirection="desc"
184
187
  onHeaderClick={() => {}}
185
188
  getItemKey={(row) => row.key}
186
- isSelected={(_row, index) => index === boundedSelectedIdx}
187
- onSelect={(_row, index) => setSelectedIdx(index)}
188
189
  renderCell={renderCell}
189
190
  emptyStateTitle={loading ? "Loading historical prices..." : "No historical prices"}
190
191
  />
@@ -121,9 +121,12 @@ export function ProviderSearchPane({ focused, width, height }: PaneProps) {
121
121
  return (
122
122
  <DataTableView<InstrumentSearchResult, DataTableColumn & { id: "symbol" | "name" | "exchange" | "type" }>
123
123
  focused={focused}
124
- selectedIndex={boundedSelectedIdx}
125
- onSelectIndex={(index) => setSelectedIdx(index)}
126
- onActivateIndex={(_index, row) => openResult(row)}
124
+ selection={{
125
+ kind: "index",
126
+ selectedIndex: boundedSelectedIdx,
127
+ onChange: (index) => setSelectedIdx(index),
128
+ }}
129
+ onActivate={(row) => openResult(row)}
127
130
  onRootKeyDown={handleKeyDown}
128
131
  rootWidth={width}
129
132
  rootHeight={height}
@@ -133,8 +136,6 @@ export function ProviderSearchPane({ focused, width, height }: PaneProps) {
133
136
  sortDirection="asc"
134
137
  onHeaderClick={() => {}}
135
138
  getItemKey={(row, index) => `${row.providerId}:${row.symbol}:${row.exchange}:${row.type}:${index}`}
136
- isSelected={(_row, index) => index === boundedSelectedIdx}
137
- onSelect={(_row, index) => setSelectedIdx(index)}
138
139
  renderCell={renderCell}
139
140
  emptyStateTitle={state.loading ? "Searching..." : query ? "No search results" : "No search query"}
140
141
  />
@@ -380,19 +380,21 @@ export function ResolvedFinancialsTab({
380
380
  bodyScrollId={bodyScrollId}
381
381
  columns={columns}
382
382
  items={rows}
383
- selectedIndex={effectiveSelectedIndex}
384
- onSelectIndex={(_index, row) => {
385
- setSelectedRowId(row.id);
383
+ selection={{
384
+ kind: "id",
385
+ selectedId: selectedRowId,
386
+ getId: (row) => row.id,
387
+ onChange: (_id, row, _index, reason) => {
388
+ setSelectedRowId(row.id);
389
+ if (reason === "pointer" && row.kind === "group" && row.toggleable) {
390
+ toggleGroup(row.id);
391
+ }
392
+ },
386
393
  }}
387
394
  sortColumnId={null}
388
395
  sortDirection="desc"
389
396
  onHeaderClick={() => {}}
390
397
  getItemKey={(row) => row.id}
391
- isSelected={(_row, index) => index === effectiveSelectedIndex}
392
- onSelect={(row) => {
393
- setSelectedRowId(row.id);
394
- if (row.kind === "group" && row.toggleable) toggleGroup(row.id);
395
- }}
396
398
  onActivate={(row) => {
397
399
  if (row.kind === "group" && row.toggleable) toggleGroup(row.id);
398
400
  }}
@@ -162,10 +162,16 @@ function WorldIndicesPane({ focused, width, height }: PaneProps) {
162
162
  return (
163
163
  <DataTableView<WorldIndexTableRow, WorldIndexColumn>
164
164
  focused={focused}
165
- selectedIndex={activeFlatIdx}
165
+ selection={{
166
+ kind: "id",
167
+ selectedId: selectedSymbol,
168
+ getId: (row) => row.type === "row" ? row.entry.symbol : `header-${row.region}`,
169
+ onChange: (_id, row, index) => {
170
+ if (row.type === "row") selectFlatIndex(index);
171
+ },
172
+ }}
166
173
  isNavigable={(row) => row.type === "row"}
167
- onSelectIndex={selectFlatIndex}
168
- onActivateIndex={(index) => openSelected(index)}
174
+ onActivate={(_row, index) => openSelected(index)}
169
175
  rootWidth={width}
170
176
  rootHeight={height}
171
177
  columns={columns}
@@ -174,9 +180,6 @@ function WorldIndicesPane({ focused, width, height }: PaneProps) {
174
180
  sortDirection={sortPreference.direction}
175
181
  onHeaderClick={handleHeaderClick}
176
182
  getItemKey={(row) => row.type === "header" ? `header-${row.region}` : row.entry.symbol}
177
- isSelected={(row) => row.type === "row" && row.entry.symbol === selectedSymbol}
178
- onSelect={(_row, index) => selectFlatIndex(index)}
179
- onActivate={(_row, index) => openSelected(index)}
180
183
  renderSectionHeader={(row) => row.type === "header"
181
184
  ? { text: REGION_LABELS[row.region] }
182
185
  : null}
@@ -260,12 +260,16 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
260
260
  rootWidth={width}
261
261
  rootHeight={height}
262
262
  rootBackgroundColor={colors.panel}
263
- selectedIndex={controller.selectedIndex}
264
- onSelectIndex={(_index, row) =>
265
- controller.actions.setBrowseSelection(row.key, {
266
- debounceDetail: true,
267
- })}
268
- onActivateIndex={(_index, row) =>
263
+ selection={{
264
+ kind: "id",
265
+ selectedId: controller.selectedRow?.key ?? null,
266
+ getId: (row) => row.key,
267
+ onChange: (key, _row, _index, reason) =>
268
+ controller.actions.setBrowseSelection(key, {
269
+ debounceDetail: reason === "keyboard",
270
+ }),
271
+ }}
272
+ onActivate={(row) =>
269
273
  controller.actions.openSelectedRow(row.key)}
270
274
  columns={controller.visibleColumns}
271
275
  items={controller.visibleRows}
@@ -275,9 +279,6 @@ export function PredictionMarketsPane({ focused, width, height }: PaneProps) {
275
279
  headerScrollRef={controller.headerScrollRef}
276
280
  scrollRef={controller.scrollRef}
277
281
  getItemKey={(row) => row.key}
278
- isSelected={(row) => controller.selectedRow?.key === row.key}
279
- onSelect={(row) => controller.actions.setBrowseSelection(row.key)}
280
- onActivate={(row) => controller.actions.openSelectedRow(row.key)}
281
282
  virtualize
282
283
  renderCell={renderCell}
283
284
  emptyStateTitle="No markets matched."
@@ -147,6 +147,9 @@ export class PluginRegistry implements PluginRuntimeAccess {
147
147
  hidePane = (paneId: string) => {
148
148
  this.hidePaneFn(paneId);
149
149
  };
150
+ focusPane = (paneId: string) => {
151
+ this.focusPaneFn(paneId);
152
+ };
150
153
 
151
154
  getLayoutFn: (() => LayoutConfig) = () => ({ dockRoot: null, instances: [], floating: [], detached: [] });
152
155
  updateLayoutFn: ((layout: LayoutConfig) => void) = () => {};
@@ -33,6 +33,7 @@ export interface PluginRuntimeAccess {
33
33
  showPane(paneId: string): void;
34
34
  createPaneFromTemplate(templateId: string, options?: PaneTemplateCreateOptions): void;
35
35
  hidePane(paneId: string): void;
36
+ focusPane(paneId: string): void;
36
37
  openPaneSettings(paneId?: string): void;
37
38
  openPluginCommandWorkflow(commandId: string): void;
38
39
  notify(notification: AppNotificationRequest): void;