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
@@ -328,12 +328,12 @@ function EconCalendarPane({ focused, width, height }: PaneProps) {
328
328
  rootWidth={width}
329
329
  rootHeight={height}
330
330
  onRootKeyDown={handleRootKeyDown}
331
- selectedIndex={eventIdxToRowIdx.get(selectedIdx) ?? selectedIdx}
332
- onSelectIndex={(_index, row) => {
333
- if (row.kind === "event") setSelectedIdx(row.eventIdx);
334
- }}
335
- onActivateIndex={(_index, row) => {
336
- if (row.kind === "event") setDetailEvent(row.event);
331
+ selection={{
332
+ kind: "index",
333
+ selectedIndex: eventIdxToRowIdx.get(selectedIdx) ?? selectedIdx,
334
+ onChange: (_index, row) => {
335
+ if (row.kind === "event") setSelectedIdx(row.eventIdx);
336
+ },
337
337
  }}
338
338
  columns={columns}
339
339
  items={rows}
@@ -344,8 +344,6 @@ function EconCalendarPane({ focused, width, height }: PaneProps) {
344
344
  headerScrollRef={headerScrollRef}
345
345
  scrollRef={scrollRef}
346
346
  getItemKey={(row) => row.key}
347
- isSelected={(row) => row.kind === "event" && row.eventIdx === selectedIdx}
348
- onSelect={selectDisplayRow}
349
347
  onActivate={openDisplayRow}
350
348
  renderSectionHeader={renderSectionHeader}
351
349
  renderCell={renderCell}
@@ -200,6 +200,12 @@ export function HelpPane({ width, height }: PaneProps) {
200
200
  description="Pop the focused pane out to a desktop window."
201
201
  />
202
202
  )}
203
+ {isDesktopWeb && (
204
+ <ShortcutRow
205
+ badges={[platformShortcut(["Shift", "C"])]}
206
+ description="Copy a screenshot of the focused pane."
207
+ />
208
+ )}
203
209
  <ShortcutRow
204
210
  badges={[platformShortcut(["Shift", "L"])]}
205
211
  description="Open layout actions."
@@ -296,9 +296,13 @@ export function HoldersView({ focused, width, height }: { focused: boolean; widt
296
296
  {viewMode === "table" ? (
297
297
  <DataTableView<HolderRow, HolderColumn>
298
298
  focused={focused}
299
- selectedIndex={activeIdx}
300
- onSelectIndex={selectIndex}
301
- onActivateIndex={(_index, row) => openFundDetail(row)}
299
+ selection={{
300
+ kind: "id",
301
+ selectedId,
302
+ getId: (row) => row.id,
303
+ onChange: (id) => setSelectedId(id),
304
+ }}
305
+ onActivate={openFundDetail}
302
306
  onRootKeyDown={handleKeyDown}
303
307
  resetScrollKey={data?.symbol}
304
308
  rootWidth={width}
@@ -308,9 +312,6 @@ export function HoldersView({ focused, width, height }: { focused: boolean; widt
308
312
  sortDirection={sortPreference.direction}
309
313
  onHeaderClick={handleHeaderClick}
310
314
  getItemKey={(row) => row.id}
311
- isSelected={(row) => row.id === selectedId}
312
- onSelect={(row) => setSelectedId(row.id)}
313
- onActivate={(row) => openFundDetail(row)}
314
315
  renderCell={renderCell}
315
316
  emptyStateTitle={emptyTitle}
316
317
  />
@@ -227,7 +227,12 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
227
227
 
228
228
  <DataTableView<MarketMoverRow, MarketMoverColumn>
229
229
  focused={focused}
230
- selectedIndex={activeIdx}
230
+ selection={{
231
+ kind: "id",
232
+ selectedId: selectedSymbol,
233
+ getId: (row) => row.symbol,
234
+ onChange: (symbol) => setSelectedSymbol(symbol),
235
+ }}
231
236
  onRootKeyDown={handleTableKeyDown}
232
237
  resetScrollKey={activeTab}
233
238
  columns={columns}
@@ -236,8 +241,6 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
236
241
  sortDirection={sortPreference.direction}
237
242
  onHeaderClick={handleHeaderClick}
238
243
  getItemKey={(row) => `${row.symbol}-${row.rank}`}
239
- isSelected={(row) => row.symbol === selectedSymbol}
240
- onSelect={(row) => setSelectedSymbol(row.symbol)}
241
244
  onActivate={(row) => openSymbol(row.symbol)}
242
245
  renderCell={renderMarketMoverCell}
243
246
  emptyStateTitle={loading ? "Loading movers..." : loadError ?? "No data"}
@@ -245,9 +245,13 @@ export function NewsArticleStackView({
245
245
  onBack={onBack}
246
246
  detailContent={detailContent}
247
247
  detailTitle={detailTitle}
248
- selectedIndex={activeIdx}
249
- onSelectIndex={selectIndex}
250
- onActivateIndex={openIndex}
248
+ selection={{
249
+ kind: "id",
250
+ selectedId: selectedArticleId,
251
+ getId: (article) => article.id,
252
+ onChange: (id) => setSelectedArticleId(id),
253
+ }}
254
+ onActivate={openArticle}
251
255
  rootBefore={rootBefore}
252
256
  rootHeight={rootHeight}
253
257
  onRootKeyDown={onRootKeyDown}
@@ -257,9 +261,6 @@ export function NewsArticleStackView({
257
261
  sortDirection={sortPreference.direction}
258
262
  onHeaderClick={(columnId) => setSortPreference(nextSortPreference(sortPreference, columnId as NewsColumnId))}
259
263
  getItemKey={(item) => item.id}
260
- isSelected={(item, index) => item.id === selectedArticleId || (selectedArticleId === null && index === 0)}
261
- onSelect={(article) => setSelectedArticleId(article.id)}
262
- onActivate={openArticle}
263
264
  renderCell={renderCell}
264
265
  emptyContent={emptyContent}
265
266
  emptyStateTitle={emptyStateTitle}
@@ -238,7 +238,22 @@ export function OptionsView({ width, focused, onCapture = () => {} }: OptionsVie
238
238
 
239
239
  <DataTableView<OptionTableRow, OptionColumn>
240
240
  focused={focused}
241
- selectedIndex={strikeIdx}
241
+ selection={{
242
+ kind: "index",
243
+ selectedIndex: strikeIdx,
244
+ onChange: (index) => {
245
+ userSelectedStrikeRef.current = true;
246
+ setScrollToIndexAlign("nearest");
247
+ enterInteractive();
248
+ setStrikeIdx(index);
249
+ },
250
+ }}
251
+ onCursorChange={(_row, index) => {
252
+ userSelectedStrikeRef.current = true;
253
+ setScrollToIndexAlign("nearest");
254
+ enterInteractive();
255
+ setStrikeIdx(index);
256
+ }}
242
257
  onRootKeyDown={handleTableKeyDown}
243
258
  columns={optionColumns}
244
259
  items={rows}
@@ -246,13 +261,6 @@ export function OptionsView({ width, focused, onCapture = () => {} }: OptionsVie
246
261
  sortDirection="asc"
247
262
  onHeaderClick={() => {}}
248
263
  getItemKey={(row) => String(row.strike)}
249
- isSelected={(_row, index) => index === strikeIdx}
250
- onSelect={(_row, index) => {
251
- userSelectedStrikeRef.current = true;
252
- setScrollToIndexAlign("nearest");
253
- enterInteractive();
254
- setStrikeIdx(index);
255
- }}
256
264
  renderCell={renderOptionCell}
257
265
  emptyStateTitle="No strikes available."
258
266
  scrollToIndex={strikeIdx}
@@ -364,6 +364,7 @@ export function AnalystResearchView({ focused, width, height }: { focused: boole
364
364
  return (
365
365
  <DataTableView<AnalystResearchData["ratings"][number], RatingColumn>
366
366
  focused={focused}
367
+ selection={{ kind: "none" }}
367
368
  rootWidth={width}
368
369
  rootHeight={height}
369
370
  rootBefore={<AnalystSummary data={data} />}
@@ -374,8 +375,6 @@ export function AnalystResearchView({ focused, width, height }: { focused: boole
374
375
  sortDirection={sortPreference.direction}
375
376
  onHeaderClick={handleHeaderClick}
376
377
  getItemKey={(row, index) => `${row.date}:${row.firm}:${index}`}
377
- isSelected={() => false}
378
- onSelect={() => {}}
379
378
  renderCell={renderCell}
380
379
  emptyStateTitle={loading ? "Loading analyst data..." : error ?? "No analyst data"}
381
380
  />
@@ -1,12 +1,13 @@
1
- import { useCallback, useMemo } from "react";
2
- import { TextAttributes } from "../../../ui";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, ScrollBox, Text, TextAttributes, type ScrollBoxRenderable } from "../../../ui";
3
3
  import {
4
- DataTableView,
4
+ DataTableStackView,
5
5
  usePaneFooter,
6
6
  type DataTableCell,
7
7
  type DataTableColumn,
8
8
  type DataTableKeyEvent,
9
9
  } from "../../../components";
10
+ import type { SecFilingDocument, SecFilingItem } from "../../../types/data-provider";
10
11
  import type {
11
12
  AnalystEstimateRecord,
12
13
  AnalystResearchData,
@@ -16,9 +17,23 @@ import type {
16
17
  } from "../../../types/financials";
17
18
  import { blendHex, colors } from "../../../theme/colors";
18
19
  import { formatCompact, formatCurrency, formatNumber, formatPercent, formatPercentRaw } from "../../../utils/format";
20
+ import { isPlainKey } from "../../../utils/keyboard";
21
+ import { wrapTextLines } from "../../../utils/text-wrap";
22
+ import { useResolvedEntryValue, useSecFilingContent, useSecFilingDocuments, useSecFilingsQuery } from "../../../market-data/hooks";
23
+ import { getSharedMarketDataCoordinator } from "../../../market-data/coordinator";
24
+ import { instrumentFromTicker } from "../../../market-data/request-types";
25
+ import { isUsEquityTicker } from "../../../utils/sec";
19
26
  import { useAssetData } from "../../runtime";
20
27
  import { handleRefreshKey, loadingErrorFooterInfo, refreshFooterHint } from "../shared/table-pane";
21
28
  import { useBoundTicker as useSymbolBinding, useTickerRequest } from "../shared/ticker-request";
29
+ import {
30
+ documentContentKey,
31
+ documentContentTarget,
32
+ documentHeading,
33
+ formatCompactDocumentLabel,
34
+ isDefaultVisibleFilingDocument,
35
+ isInlineExhibitDocument,
36
+ } from "../sec/filing-documents";
22
37
 
23
38
  type EventStatus = "Earnings" | "Q Est" | "FY Est" | "TTM" | "Dividend" | "Split";
24
39
 
@@ -46,6 +61,9 @@ type EstimatePair = {
46
61
  revenue?: AnalystEstimateRecord;
47
62
  };
48
63
 
64
+ const SEC_EVENT_FILING_LIMIT = 50;
65
+ const SEC_EVENT_MATCH_WINDOW_DAYS = 7;
66
+
49
67
  function todayDateKey(): string {
50
68
  const now = new Date();
51
69
  const year = now.getFullYear();
@@ -84,6 +102,75 @@ function daysBetween(leftDate: string, rightDate: string): number {
84
102
  return Math.abs(left - right) / 86_400_000;
85
103
  }
86
104
 
105
+ function dateKeyToEpochDay(dateKey: string): number | null {
106
+ const timestamp = new Date(`${dateKey}T00:00:00Z`).getTime();
107
+ if (!Number.isFinite(timestamp)) return null;
108
+ return Math.floor(timestamp / 86_400_000);
109
+ }
110
+
111
+ function signedDaysBetween(leftDate: string, rightDate: string): number {
112
+ const left = dateKeyToEpochDay(leftDate);
113
+ const right = dateKeyToEpochDay(rightDate);
114
+ if (left == null || right == null) return Number.POSITIVE_INFINITY;
115
+ return right - left;
116
+ }
117
+
118
+ function filingDateKey(filing: SecFilingItem): string {
119
+ const value = filing.filingDate as Date | string | number;
120
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
121
+ const date = new Date(value);
122
+ if (!Number.isNaN(date.getTime())) return date.toISOString().slice(0, 10);
123
+ return String(value).slice(0, 10);
124
+ }
125
+
126
+ function isSecEarningsFilingCandidate(filing: SecFilingItem): boolean {
127
+ const form = filing.form.trim().toUpperCase();
128
+ return form === "8-K"
129
+ || form === "8-K/A"
130
+ || form === "10-Q"
131
+ || form === "10-Q/A"
132
+ || form === "10-K"
133
+ || form === "10-K/A";
134
+ }
135
+
136
+ function filingSearchText(filing: SecFilingItem): string {
137
+ return [
138
+ filing.form,
139
+ filing.items,
140
+ filing.primaryDocument,
141
+ filing.primaryDocDescription,
142
+ ].filter(Boolean).join(" ").toUpperCase();
143
+ }
144
+
145
+ function scoreFilingForEarnings(filing: SecFilingItem, earningsDate: string): number | null {
146
+ if (!isSecEarningsFilingCandidate(filing)) return null;
147
+ const delta = signedDaysBetween(earningsDate, filingDateKey(filing));
148
+ if (!Number.isFinite(delta) || delta < -1 || delta > SEC_EVENT_MATCH_WINDOW_DAYS) return null;
149
+
150
+ const form = filing.form.trim().toUpperCase();
151
+ const text = filingSearchText(filing);
152
+ let score = Math.abs(delta) * 10;
153
+ if (delta < 0) score += 12;
154
+ if (form.startsWith("10-")) score += 30;
155
+ if (text.includes("2.02")) score -= 18;
156
+ if (text.includes("9.01")) score -= 5;
157
+ if (/RESULTS OF OPERATIONS|FINANCIAL CONDITION|EARNINGS/i.test(text)) score -= 6;
158
+ return score;
159
+ }
160
+
161
+ export function matchEarningsSecFiling(row: { status: string; date: string } | null | undefined, filings: readonly SecFilingItem[]): SecFilingItem | null {
162
+ if (!row || row.status !== "Earnings") return null;
163
+ let best: { filing: SecFilingItem; score: number } | null = null;
164
+ for (const filing of filings) {
165
+ const score = scoreFilingForEarnings(filing, row.date);
166
+ if (score == null) continue;
167
+ if (!best || score < best.score) {
168
+ best = { filing, score };
169
+ }
170
+ }
171
+ return best?.filing ?? null;
172
+ }
173
+
87
174
  function statementForEarningsDate(
88
175
  quarterlyStatements: readonly FinancialStatement[],
89
176
  earningsDate: string,
@@ -293,6 +380,98 @@ function toneColor(tone: EventRow["tone"]): string {
293
380
  return colors.text;
294
381
  }
295
382
 
383
+ function eventDetailTitle(row: EventRow): string {
384
+ return `${row.status} | ${row.date}`;
385
+ }
386
+
387
+ function eventSummaryLine(row: EventRow): string {
388
+ return [
389
+ row.date,
390
+ row.period,
391
+ row.qEps != null ? `EPS ${formatNumber(row.qEps, 2)}` : null,
392
+ row.qRevenue != null ? `Rev ${formatCompact(row.qRevenue)}` : null,
393
+ row.annualEps != null ? `Ann EPS ${formatNumber(row.annualEps, 2)}` : null,
394
+ row.annualRevenue != null ? `Ann Rev ${formatCompact(row.annualRevenue)}` : null,
395
+ row.value !== "-" ? row.value : null,
396
+ row.detail || null,
397
+ ].filter((line): line is string => !!line).join(" | ");
398
+ }
399
+
400
+ function buildEventDetailBody({
401
+ row,
402
+ secFilingsLoading,
403
+ filing,
404
+ documents,
405
+ documentsLoading,
406
+ inlineContent,
407
+ primaryContent,
408
+ primaryContentLoading,
409
+ }: {
410
+ row: EventRow;
411
+ secFilingsLoading: boolean;
412
+ filing: SecFilingItem | null;
413
+ documents: SecFilingDocument[];
414
+ documentsLoading: boolean;
415
+ inlineContent: Map<string, string | null>;
416
+ primaryContent: string | null | undefined;
417
+ primaryContentLoading: boolean;
418
+ }): string {
419
+ const lines: string[] = ["Summary", eventSummaryLine(row)];
420
+ if (row.status !== "Earnings") {
421
+ return lines.join("\n");
422
+ }
423
+
424
+ lines.push("", "SEC Filing");
425
+ if (secFilingsLoading && !filing) {
426
+ lines.push("Loading recent SEC filings...");
427
+ return lines.join("\n");
428
+ }
429
+ if (!filing) {
430
+ lines.push("No related SEC filing found in recent filings.");
431
+ return lines.join("\n");
432
+ }
433
+
434
+ lines.push([
435
+ `${filing.form} filed ${filingDateKey(filing)}`,
436
+ filing.items ? `Items ${filing.items}` : null,
437
+ `Accession ${filing.accessionNumber}`,
438
+ ].filter(Boolean).join(" | "));
439
+
440
+ lines.push("", "Documents");
441
+ if (documentsLoading && documents.length === 0) {
442
+ lines.push("Loading filing documents...");
443
+ } else if (documents.length === 0) {
444
+ lines.push("No filing documents were listed for this filing.");
445
+ } else {
446
+ const visibleDocuments = documents.filter(isDefaultVisibleFilingDocument);
447
+ lines.push(...visibleDocuments.map(formatCompactDocumentLabel));
448
+ const hiddenCount = documents.length - visibleDocuments.length;
449
+ if (hiddenCount > 0) lines.push(`+ ${hiddenCount} support documents hidden`);
450
+ }
451
+
452
+ const exhibits = documents.filter(isInlineExhibitDocument);
453
+ if (exhibits.length > 0) {
454
+ lines.push("", "Inline Exhibits");
455
+ for (const document of exhibits) {
456
+ const key = documentContentKey(filing, document);
457
+ const hasContent = inlineContent.has(key);
458
+ const content = inlineContent.get(key);
459
+ lines.push("", documentHeading(document));
460
+ lines.push(hasContent
461
+ ? content || "Readable document content was not available for this exhibit."
462
+ : "Loading exhibit content...");
463
+ }
464
+ }
465
+
466
+ if (!documentsLoading && exhibits.length === 0) {
467
+ lines.push("", "Primary Filing Content");
468
+ lines.push(primaryContentLoading
469
+ ? "Loading filing content..."
470
+ : primaryContent || "Readable filing content was not available.");
471
+ }
472
+ return lines.join("\n");
473
+ }
474
+
296
475
  export function CorporateActionsView({
297
476
  focused,
298
477
  width,
@@ -305,7 +484,7 @@ export function CorporateActionsView({
305
484
  footerPaneId?: string;
306
485
  }) {
307
486
  const dataProvider = useAssetData();
308
- const { symbol, exchange, currency } = useSymbolBinding();
487
+ const { symbol, ticker, exchange, currency } = useSymbolBinding();
309
488
  const actionsLoader = useCallback((nextSymbol: string, nextExchange: string, forceRefresh: boolean) => {
310
489
  if (!dataProvider?.getCorporateActions) throw new Error("Corporate actions source unavailable");
311
490
  return dataProvider.getCorporateActions(nextSymbol, nextExchange, forceRefresh ? { cacheMode: "refresh" } : undefined);
@@ -341,6 +520,12 @@ export function CorporateActionsView({
341
520
  buildEventRows(actionsData, analystData, financialsData, displayCurrency)
342
521
  ), [actionsData, analystData, displayCurrency, financialsData]);
343
522
  const columns = useMemo(() => buildEventColumns(), []);
523
+ const [selectedIdx, setSelectedIdx] = useState(0);
524
+ const [openRowId, setOpenRowId] = useState<string | null>(null);
525
+ const detailScrollRef = useRef<ScrollBoxRenderable>(null);
526
+ const [inlineContent, setInlineContent] = useState<Map<string, string | null>>(new Map());
527
+ const documentFetchStartedRef = useRef(new Set<string>());
528
+ const documentFetchGenRef = useRef(0);
344
529
  const todayKey = todayDateKey();
345
530
  const futureRowBackground = blendHex(colors.bg, colors.positive, 0.16);
346
531
  const loading = actionsLoading || analystLoading || financialsLoading;
@@ -350,6 +535,163 @@ export function CorporateActionsView({
350
535
  reloadAnalyst();
351
536
  reloadFinancials();
352
537
  }, [reloadActions, reloadAnalyst, reloadFinancials]);
538
+ const openRow = openRowId
539
+ ? rows.find((row) => row.id === openRowId) ?? null
540
+ : null;
541
+ const instrument = useMemo(() => instrumentFromTicker(ticker, symbol), [symbol, ticker]);
542
+ const secFilingsEntry = useSecFilingsQuery(
543
+ openRow?.status === "Earnings" && instrument && isUsEquityTicker(ticker)
544
+ ? { instrument, count: SEC_EVENT_FILING_LIMIT }
545
+ : null,
546
+ );
547
+ const secFilings = useResolvedEntryValue(secFilingsEntry) ?? [];
548
+ const secFilingsLoading = openRow?.status === "Earnings" && (
549
+ secFilingsEntry?.phase === "idle"
550
+ || secFilingsEntry?.phase === "loading"
551
+ || secFilingsEntry?.phase === "refreshing"
552
+ );
553
+ const matchedFiling = useMemo(
554
+ () => matchEarningsSecFiling(openRow, secFilings),
555
+ [openRow, secFilings],
556
+ );
557
+ const documentsEntry = useSecFilingDocuments(matchedFiling);
558
+ const documents = useResolvedEntryValue(documentsEntry) ?? [];
559
+ const documentsLoading = !!matchedFiling && (
560
+ documentsEntry?.phase === "idle"
561
+ || documentsEntry?.phase === "loading"
562
+ || documentsEntry?.phase === "refreshing"
563
+ );
564
+ const hasInlineExhibits = documents.some(isInlineExhibitDocument);
565
+ const filingContentEntry = useSecFilingContent(
566
+ matchedFiling && !documentsLoading && !hasInlineExhibits ? matchedFiling : null,
567
+ );
568
+ const primaryContent = useResolvedEntryValue(filingContentEntry);
569
+ const primaryContentLoading = !!matchedFiling && (
570
+ filingContentEntry?.phase === "idle"
571
+ || filingContentEntry?.phase === "loading"
572
+ || filingContentEntry?.phase === "refreshing"
573
+ );
574
+
575
+ useEffect(() => {
576
+ if (rows.length > 0 && selectedIdx >= rows.length) {
577
+ setSelectedIdx(Math.max(0, rows.length - 1));
578
+ }
579
+ }, [rows.length, selectedIdx]);
580
+
581
+ useEffect(() => {
582
+ if (openRowId && !rows.some((row) => row.id === openRowId)) {
583
+ setOpenRowId(null);
584
+ }
585
+ }, [openRowId, rows]);
586
+
587
+ useEffect(() => {
588
+ if (!openRowId) return;
589
+ const scrollBox = detailScrollRef.current;
590
+ if (scrollBox) scrollBox.scrollTop = 0;
591
+ }, [openRowId]);
592
+
593
+ useEffect(() => {
594
+ setInlineContent(new Map());
595
+ documentFetchStartedRef.current.clear();
596
+ documentFetchGenRef.current += 1;
597
+ }, [exchange, symbol]);
598
+
599
+ useEffect(() => {
600
+ if (!matchedFiling || documents.length === 0) return;
601
+ const coordinator = getSharedMarketDataCoordinator();
602
+ if (!coordinator) return;
603
+ const gen = documentFetchGenRef.current;
604
+ const documentsToFetch = documents.filter((document) => {
605
+ if (!isInlineExhibitDocument(document)) return false;
606
+ const key = documentContentKey(matchedFiling, document);
607
+ return !inlineContent.has(key) && !documentFetchStartedRef.current.has(key);
608
+ });
609
+ if (documentsToFetch.length === 0) return;
610
+
611
+ for (const document of documentsToFetch) {
612
+ documentFetchStartedRef.current.add(documentContentKey(matchedFiling, document));
613
+ }
614
+
615
+ (async () => {
616
+ for (const document of documentsToFetch) {
617
+ if (documentFetchGenRef.current !== gen) return;
618
+ const key = documentContentKey(matchedFiling, document);
619
+ try {
620
+ const entry = await coordinator.loadSecFilingContent(documentContentTarget(matchedFiling, document));
621
+ if (documentFetchGenRef.current !== gen) return;
622
+ setInlineContent((prev) => new Map(prev).set(key, entry.data ?? entry.lastGoodData ?? null));
623
+ } catch {
624
+ if (documentFetchGenRef.current !== gen) return;
625
+ setInlineContent((prev) => new Map(prev).set(key, null));
626
+ }
627
+ }
628
+ })();
629
+ }, [documents, inlineContent, matchedFiling]);
630
+ const detailBody = openRow
631
+ ? buildEventDetailBody({
632
+ row: openRow,
633
+ secFilingsLoading,
634
+ filing: matchedFiling,
635
+ documents,
636
+ documentsLoading,
637
+ inlineContent,
638
+ primaryContent,
639
+ primaryContentLoading,
640
+ })
641
+ : "";
642
+ const detailTextWidth = Math.max(width - 2, 12);
643
+ const scrollDetailBy = useCallback((delta: number) => {
644
+ const scrollBox = detailScrollRef.current;
645
+ if (!scrollBox?.viewport) return;
646
+ const maxScrollTop = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height);
647
+ scrollBox.scrollTop = Math.max(0, Math.min(maxScrollTop, scrollBox.scrollTop + delta));
648
+ }, []);
649
+
650
+ const handleDetailKeyDown = useCallback((event: DataTableKeyEvent) => {
651
+ if (isPlainKey(event, "j", "down")) {
652
+ event.stopPropagation?.();
653
+ event.preventDefault?.();
654
+ scrollDetailBy(1);
655
+ return true;
656
+ }
657
+ if (isPlainKey(event, "k", "up")) {
658
+ event.stopPropagation?.();
659
+ event.preventDefault?.();
660
+ scrollDetailBy(-1);
661
+ return true;
662
+ }
663
+ return false;
664
+ }, [scrollDetailBy]);
665
+ const detailContent = openRow ? (
666
+ <Box
667
+ flexDirection="column"
668
+ flexGrow={1}
669
+ flexBasis={0}
670
+ minHeight={0}
671
+ overflow="hidden"
672
+ paddingX={1}
673
+ paddingY={1}
674
+ >
675
+ <ScrollBox
676
+ ref={detailScrollRef}
677
+ flexGrow={1}
678
+ flexBasis={0}
679
+ minHeight={0}
680
+ scrollY
681
+ focusable={false}
682
+ >
683
+ <Box flexDirection="column">
684
+ {wrapTextLines(detailBody, detailTextWidth).map((line, index) => (
685
+ <Box key={`event-detail-${index}`} height={1}>
686
+ <Text fg={colors.text}>{line}</Text>
687
+ </Box>
688
+ ))}
689
+ </Box>
690
+ </ScrollBox>
691
+ </Box>
692
+ ) : (
693
+ <Box flexGrow={1} />
694
+ );
353
695
 
354
696
  const renderCell = useCallback((
355
697
  row: EventRow,
@@ -390,8 +732,19 @@ export function CorporateActionsView({
390
732
  }), [error, footerPaneId, loading, reload]);
391
733
 
392
734
  return (
393
- <DataTableView<EventRow, EventColumn>
735
+ <DataTableStackView<EventRow, EventColumn>
394
736
  focused={focused}
737
+ detailOpen={!!openRow}
738
+ onBack={() => setOpenRowId(null)}
739
+ detailContent={detailContent}
740
+ detailTitle={openRow ? eventDetailTitle(openRow) : undefined}
741
+ selection={{
742
+ kind: "index",
743
+ selectedIndex: selectedIdx,
744
+ onChange: (index) => setSelectedIdx(index),
745
+ }}
746
+ onActivate={(row) => setOpenRowId(row.id)}
747
+ onDetailKeyDown={handleDetailKeyDown}
395
748
  rootWidth={width}
396
749
  rootHeight={height}
397
750
  onRootKeyDown={handleKeyDown}
@@ -401,8 +754,6 @@ export function CorporateActionsView({
401
754
  sortDirection="desc"
402
755
  onHeaderClick={() => {}}
403
756
  getItemKey={(row) => row.id}
404
- isSelected={() => false}
405
- onSelect={() => {}}
406
757
  renderCell={renderCell}
407
758
  getRowBackgroundColor={(row) => (
408
759
  row.date > todayKey ? futureRowBackground : undefined
@@ -8,7 +8,7 @@ import {
8
8
  sortRatingRows,
9
9
  type RatingSortPreference,
10
10
  } from "./analyst-pane";
11
- import { buildEventRows } from "./corporate-actions-pane";
11
+ import { buildEventRows, matchEarningsSecFiling } from "./corporate-actions-pane";
12
12
 
13
13
  const ratings: AnalystRatingRecord[] = [
14
14
  {
@@ -337,4 +337,33 @@ describe("event rows", () => {
337
337
  expect(rows[2]?.qEps).toBeUndefined();
338
338
  expect(rows[2]?.annualEps).toBeUndefined();
339
339
  });
340
+
341
+ test("matches reported earnings to nearby SEC earnings-release filings", () => {
342
+ const row = buildEventRows({
343
+ symbol: "AAPL",
344
+ dividends: [],
345
+ splits: [],
346
+ earnings: [{ date: "2026-01-30", epsActual: 2.4 }],
347
+ }, null, null, "USD").find((candidate) => candidate.status === "Earnings");
348
+ const filings = [
349
+ {
350
+ accessionNumber: "0000320193-26-000010",
351
+ form: "10-Q",
352
+ filingDate: new Date("2026-02-04T00:00:00Z"),
353
+ cik: "0000320193",
354
+ filingUrl: "https://www.sec.gov/Archives/edgar/data/320193/0000320193-26-000010-index.htm",
355
+ },
356
+ {
357
+ accessionNumber: "0000320193-26-000009",
358
+ form: "8-K",
359
+ filingDate: "2026-01-31T00:00:00.000Z" as unknown as Date,
360
+ items: "2.02,9.01",
361
+ primaryDocDescription: "Results of Operations and Financial Condition",
362
+ cik: "0000320193",
363
+ filingUrl: "https://www.sec.gov/Archives/edgar/data/320193/0000320193-26-000009-index.htm",
364
+ },
365
+ ];
366
+
367
+ expect(matchEarningsSecFiling(row, filings)?.accessionNumber).toBe("0000320193-26-000009");
368
+ });
340
369
  });
@@ -165,9 +165,12 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
165
165
  return (
166
166
  <DataTableView<RelativeRow, RelativeColumn>
167
167
  focused={focused}
168
- selectedIndex={rows.length > 0 ? selectedIdx : -1}
169
- onSelectIndex={(index) => setSelectedIdx(index)}
170
- onActivateIndex={(_index, row) => navigateTicker(row.symbol)}
168
+ selection={{
169
+ kind: "index",
170
+ selectedIndex: rows.length > 0 ? selectedIdx : -1,
171
+ onChange: (index) => setSelectedIdx(index),
172
+ }}
173
+ onActivate={(row) => navigateTicker(row.symbol)}
171
174
  onRootKeyDown={handleKeyDown}
172
175
  rootWidth={width}
173
176
  rootHeight={height}
@@ -177,11 +180,6 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
177
180
  sortDirection="asc"
178
181
  onHeaderClick={() => {}}
179
182
  getItemKey={(row) => row.symbol}
180
- isSelected={(row) => rows[selectedIdx]?.symbol === row.symbol}
181
- onSelect={(row, index) => {
182
- setSelectedIdx(index);
183
- navigateTicker(row.symbol);
184
- }}
185
183
  renderCell={renderCell}
186
184
  emptyStateTitle={loading ? "Loading peers..." : error ?? "No peers"}
187
185
  />