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
@@ -69,6 +69,9 @@ export function usePluginAppActions() {
69
69
  const hidePane = useCallback((paneId: string) => {
70
70
  runtime.hidePane(paneId);
71
71
  }, [runtime]);
72
+ const focusPane = useCallback((paneId: string) => {
73
+ runtime.focusPane(paneId);
74
+ }, [runtime]);
72
75
  const openPaneSettings = useCallback((paneId?: string) => {
73
76
  runtime.openPaneSettings(paneId);
74
77
  }, [runtime]);
@@ -84,6 +87,7 @@ export function usePluginAppActions() {
84
87
  showPane,
85
88
  createPaneFromTemplate,
86
89
  hidePane,
90
+ focusPane,
87
91
  openPaneSettings,
88
92
  openPluginCommandWorkflow,
89
93
  notify,
@@ -251,6 +251,7 @@ function createRuntime(payload: CliPaneShotPayload): PluginRuntimeAccess {
251
251
  openCommandBar: () => {},
252
252
  showPane: () => {},
253
253
  hidePane: () => {},
254
+ focusPane: () => {},
254
255
  openPaneSettings: () => {},
255
256
  openPluginCommandWorkflow: () => {},
256
257
  notify: () => {},
@@ -145,15 +145,16 @@ export function WebDataTable<T, C extends DataTableColumn = DataTableColumn>({
145
145
  const nextValue = element ? toCellX(element.clientWidth) : 0;
146
146
  setViewportWidth((current) => current === nextValue ? current : nextValue);
147
147
  }, []);
148
+ const scheduleViewportWidthMeasure = useRafCallback(measureViewportWidth);
148
149
 
149
150
  useEffect(() => {
150
151
  measureViewportWidth();
151
152
  const element = bodyElementRef.current;
152
153
  if (!element || typeof ResizeObserver === "undefined") return;
153
- const observer = new ResizeObserver(measureViewportWidth);
154
+ const observer = new ResizeObserver(scheduleViewportWidthMeasure);
154
155
  observer.observe(element);
155
156
  return () => observer.disconnect();
156
- }, [measureViewportWidth]);
157
+ }, [measureViewportWidth, scheduleViewportWidthMeasure]);
157
158
 
158
159
  useEffect(() => {
159
160
  if (scrollToIndex == null || items.length === 0) return;
@@ -69,7 +69,7 @@ export function WebDataTableHeader<C extends DataTableColumn>({
69
69
  zIndex: 2,
70
70
  display: "grid",
71
71
  gridTemplateColumns,
72
- columnGap: "1ch",
72
+ columnGap: "var(--cell-w)",
73
73
  alignItems: "center",
74
74
  width: "100%",
75
75
  minWidth: 0,
@@ -177,7 +177,7 @@ function WebDataTableRowInner<
177
177
  transform: `translateY(${rowStart}px)`,
178
178
  display: "grid",
179
179
  gridTemplateColumns,
180
- columnGap: "1ch",
180
+ columnGap: "var(--cell-w)",
181
181
  alignItems: "center",
182
182
  width: "100%",
183
183
  minWidth: 0,
@@ -53,6 +53,15 @@ function callTextHandler(handler: unknown, value: string): void {
53
53
  }
54
54
  }
55
55
 
56
+ function applyDomCursorOffset(
57
+ element: HTMLInputElement | HTMLTextAreaElement | null,
58
+ offset: number,
59
+ ) {
60
+ if (!element) return;
61
+ const clampedOffset = Math.max(0, Math.min(offset, element.value.length));
62
+ element.setSelectionRange(clampedOffset, clampedOffset);
63
+ }
64
+
56
65
  function useEditableValue(props: Record<string, unknown>) {
57
66
  const controlledValue = getStringProp(props, "value");
58
67
  const [internalValue, setInternalValue] = useState(getStringProp(props, "initialValue") ?? "");
@@ -142,6 +151,11 @@ export const WebInput = forwardRef<InputRenderable, Record<string, unknown>>(fun
142
151
  const elementRef = useRef<HTMLInputElement | null>(null);
143
152
  const { value, valueRef, setValue } = useEditableValue(props);
144
153
  const [cursorOffset, setCursorOffset] = useState(value.length);
154
+ const applyCursorOffset = (offset: number) => {
155
+ setCursorOffset(offset);
156
+ queueMicrotask(() => applyDomCursorOffset(elementRef.current, offset));
157
+ globalThis.requestAnimationFrame?.(() => applyDomCursorOffset(elementRef.current, offset));
158
+ };
145
159
 
146
160
  useEffect(() => {
147
161
  if (props.focused === true) {
@@ -157,8 +171,9 @@ export const WebInput = forwardRef<InputRenderable, Record<string, unknown>>(fun
157
171
  get cursorOffset() {
158
172
  return cursorOffset;
159
173
  },
174
+ setCursorOffset: applyCursorOffset,
160
175
  focus: () => elementRef.current?.focus(),
161
- }), [cursorOffset, setValue, valueRef]);
176
+ }), [applyCursorOffset, cursorOffset, setValue, valueRef]);
162
177
 
163
178
  const handleValueChange = (nextValue: string) => {
164
179
  setValue(nextValue);
@@ -207,6 +222,11 @@ export const WebTextarea = forwardRef<TextareaRenderable, Record<string, unknown
207
222
  const elementRef = useRef<HTMLTextAreaElement | null>(null);
208
223
  const { value, valueRef, setValue } = useEditableValue(props);
209
224
  const [cursorOffset, setCursorOffset] = useState(value.length);
225
+ const applyCursorOffset = (offset: number) => {
226
+ setCursorOffset(offset);
227
+ queueMicrotask(() => applyDomCursorOffset(elementRef.current, offset));
228
+ globalThis.requestAnimationFrame?.(() => applyDomCursorOffset(elementRef.current, offset));
229
+ };
210
230
 
211
231
  useEffect(() => {
212
232
  if (props.focused === true) {
@@ -222,6 +242,7 @@ export const WebTextarea = forwardRef<TextareaRenderable, Record<string, unknown
222
242
  get cursorOffset() {
223
243
  return cursorOffset;
224
244
  },
245
+ setCursorOffset: applyCursorOffset,
225
246
  get virtualLineCount() {
226
247
  const metrics = textareaMetrics(
227
248
  valueRef.current,
@@ -248,7 +269,7 @@ export const WebTextarea = forwardRef<TextareaRenderable, Record<string, unknown
248
269
  syntaxStyle: null,
249
270
  addHighlight: () => {},
250
271
  clearLineHighlights: () => {},
251
- }), [cursorOffset, props, setValue, valueRef]);
272
+ }), [applyCursorOffset, cursorOffset, props, setValue, valueRef]);
252
273
 
253
274
  const handleValueChange = (nextValue: string) => {
254
275
  setValue(nextValue);
@@ -34,6 +34,7 @@ const assetDataPayloads: Record<string, PayloadBuilder> = {
34
34
  getHolders: (ticker, exchange, context) => ({ ticker, exchange, context }),
35
35
  getAnalystResearch: (ticker, exchange, context) => ({ ticker, exchange, context }),
36
36
  getCorporateActions: (ticker, exchange, context) => ({ ticker, exchange, context }),
37
+ getSecFilingDocuments: (filing) => ({ filing }),
37
38
  getSecFilingContent: (filing) => ({ filing }),
38
39
  getEarningsCalendar: (symbols, context) => ({ symbols, context }),
39
40
  getArticleSummary: (url) => ({ url }),
@@ -279,6 +279,10 @@ export class GloomberbCloudProvider implements AssetDataProvider {
279
279
  throw createProviderMiss("Cloud SEC filings are not available");
280
280
  }
281
281
 
282
+ async getSecFilingDocuments(_filing: SecFilingItem) {
283
+ throw createProviderMiss("Cloud filing documents are not available");
284
+ }
285
+
282
286
  async getSecFilingContent(_filing: SecFilingItem): Promise<string | null> {
283
287
  throw createProviderMiss("Cloud filing content is not available");
284
288
  }
@@ -19,6 +19,7 @@ const DEFAULT_CACHE_POLICIES = {
19
19
  analystResearch: { staleMs: 24 * 60 * 60_000, expireMs: 7 * 24 * 60 * 60_000 },
20
20
  corporateActions: { staleMs: 24 * 60 * 60_000, expireMs: 14 * 24 * 60 * 60_000 },
21
21
  secFilings: { staleMs: 15 * 60_000, expireMs: 2 * 24 * 60 * 60_000 },
22
+ secFilingDocuments: { staleMs: 30 * 24 * 60 * 60_000, expireMs: 365 * 24 * 60 * 60_000 },
22
23
  secFilingContent: { staleMs: 30 * 24 * 60 * 60_000, expireMs: 365 * 24 * 60 * 60_000 },
23
24
  articleSummary: { staleMs: 30 * 24 * 60 * 60_000, expireMs: 90 * 24 * 60 * 60_000 },
24
25
  optionsChain: { staleMs: 5 * 60_000, expireMs: 2 * 24 * 60 * 60_000 },
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  MarketDataRequestContext,
3
+ SecFilingDocument,
3
4
  SecFilingItem,
4
5
  } from "../../types/data-provider";
5
6
  import { canonicalExchange } from "../../utils/exchanges";
@@ -61,6 +62,23 @@ export class ProviderRouterDocumentRoutes {
61
62
  return result.value;
62
63
  }
63
64
 
65
+ async getSecFilingDocuments(filing: SecFilingItem): Promise<SecFilingDocument[]> {
66
+ const entityKey = compactUrl(filing.filingUrl);
67
+ const cached = selectCachedResource<SecFilingDocument[]>(this.deps.resources, "sec-filing-documents", entityKey, [""], this.deps.getProviderSourceKeys(), false);
68
+ if (cached) {
69
+ scheduleRouterRevalidation(this.revalidationInFlight, `sec-filing-documents:${entityKey}`, async () => {
70
+ await this.revalidateSecFilingDocuments(filing);
71
+ });
72
+ return cached.value;
73
+ }
74
+
75
+ const result = await this.fetchProviderSecFilingDocuments(filing);
76
+ if (!result) {
77
+ throw new Error("No SEC filing documents provider available");
78
+ }
79
+ return result.value;
80
+ }
81
+
64
82
  async getArticleSummary(url: string): Promise<string | null> {
65
83
  const entityKey = compactUrl(url);
66
84
  const cached = selectCachedResource<string>(this.deps.resources, "article-summary", entityKey, [""], this.deps.getProviderSourceKeys(), false);
@@ -134,6 +152,29 @@ export class ProviderRouterDocumentRoutes {
134
152
  return null;
135
153
  }
136
154
 
155
+ private async fetchProviderSecFilingDocuments(filing: SecFilingItem): Promise<SourceResult<SecFilingDocument[]> | null> {
156
+ const entityKey = compactUrl(filing.filingUrl);
157
+ let lastError: unknown = null;
158
+
159
+ for (const provider of this.deps.providersInPriorityOrder()) {
160
+ if (!provider.getSecFilingDocuments) continue;
161
+ try {
162
+ const value = await provider.getSecFilingDocuments(filing);
163
+ const sourceKey = this.deps.providerSourceKey(provider);
164
+ this.deps.cacheResource("sec-filing-documents", entityKey, "", sourceKey, value, this.deps.resolveProviderPolicy("secFilingDocuments", provider));
165
+ return { sourceKey, value };
166
+ } catch (error) {
167
+ lastError = error;
168
+ if (shouldLogProviderError(error)) {
169
+ this.deps.logProviderError(`${provider.id} failed: ${error}`);
170
+ }
171
+ }
172
+ }
173
+
174
+ if (lastError) throw lastError;
175
+ return null;
176
+ }
177
+
137
178
  private async revalidateSecFilings(ticker: string, count = 15, exchange?: string, context?: MarketDataRequestContext): Promise<void> {
138
179
  await this.fetchProviderSecFilings(ticker, count, exchange, context);
139
180
  }
@@ -142,6 +183,10 @@ export class ProviderRouterDocumentRoutes {
142
183
  await this.fetchProviderSecFilingContent(filing);
143
184
  }
144
185
 
186
+ private async revalidateSecFilingDocuments(filing: SecFilingItem): Promise<void> {
187
+ await this.fetchProviderSecFilingDocuments(filing);
188
+ }
189
+
145
190
  private async revalidateArticleSummary(url: string): Promise<void> {
146
191
  const result = await firstProviderResult(this.deps, (provider) => provider.getArticleSummary(url));
147
192
  if (!result || !result.value) return;
@@ -219,6 +219,10 @@ export class AssetDataRouter implements DataProvider {
219
219
  return this.documentRoutes.getSecFilings(ticker, count, exchange, context);
220
220
  }
221
221
 
222
+ async getSecFilingDocuments(filing: SecFilingItem) {
223
+ return this.documentRoutes.getSecFilingDocuments(filing);
224
+ }
225
+
222
226
  async getSecFilingContent(filing: SecFilingItem): Promise<string | null> {
223
227
  return this.documentRoutes.getSecFilingContent(filing);
224
228
  }
@@ -1,7 +1,7 @@
1
1
  const MAX_CONTENT_CHARS = 16_000;
2
2
  const OWNERSHIP_FORMS = new Set(["3", "3/A", "4", "4/A", "5", "5/A"]);
3
3
 
4
- export const PDF_FALLBACK_MESSAGE = "This filing's primary document is a PDF. Inline PDF extraction is not supported here; open the filing URL below to view the full document.";
4
+ export const PDF_FALLBACK_MESSAGE = "This SEC document is a PDF. Inline PDF text extraction is not supported here.";
5
5
 
6
6
  export function isPdfDocument(body: string, contentType = "", url = ""): boolean {
7
7
  return /pdf/i.test(contentType)
@@ -43,11 +43,17 @@ function collapseDocumentLines(text: string): string[] {
43
43
  }
44
44
 
45
45
  function trimLeadingNoise(lines: string[]): string[] {
46
- const startIndex = lines.findIndex((line) => /^(UNITED STATES|FORM [0-9A-Z-]+|SCHEDULE 14A|PROXY STATEMENT|NOTICE OF|ANNUAL REPORT|QUARTERLY REPORT|CURRENT REPORT)/i.test(line));
46
+ const startIndex = lines.findIndex((line) => /^(UNITED STATES|FORM [0-9A-Z-]+|SCHEDULE 14A|PROXY STATEMENT|NOTICE OF|ANNUAL REPORT|QUARTERLY REPORT|CURRENT REPORT|EXHIBIT\s+\d)/i.test(line));
47
47
  if (startIndex <= 0) return lines;
48
48
 
49
49
  const prefix = lines.slice(0, startIndex);
50
- const noiseCount = prefix.filter((line) => /^Table of Contents$/i.test(line) || line.length < 5).length;
50
+ const noiseCount = prefix.filter((line) => (
51
+ /^Table of Contents$/i.test(line)
52
+ || /^EX[-\s]?\d/i.test(line)
53
+ || /\.(?:html?|xml|xsd|txt|pdf)$/i.test(line)
54
+ || /^\d+$/.test(line)
55
+ || line.length < 5
56
+ )).length;
51
57
  return noiseCount >= Math.max(1, prefix.length - 1)
52
58
  ? lines.slice(startIndex)
53
59
  : lines;
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import {
3
3
  SecEdgarClient,
4
4
  extractFilingContent,
5
+ parseFilingDocuments,
5
6
  parseRecentFilings,
6
7
  parseTickerLookup,
7
8
  } from "./sec-edgar";
@@ -65,6 +66,59 @@ describe("parseRecentFilings", () => {
65
66
  });
66
67
  });
67
68
 
69
+ describe("parseFilingDocuments", () => {
70
+ test("maps filing index document rows into primary and exhibit documents", () => {
71
+ const documents = parseFilingDocuments(`
72
+ <html>
73
+ <body>
74
+ <table class="tableFile" summary="Document Format Files">
75
+ <tr>
76
+ <th>Seq</th><th>Description</th><th>Document</th><th>Type</th><th>Size</th>
77
+ </tr>
78
+ <tr>
79
+ <td>1</td>
80
+ <td>Current report</td>
81
+ <td><a href="/ixviewer/doc/action?doc=/Archives/edgar/data/320193/000032019324000123/aapl-8k.htm">aapl-8k.htm</a></td>
82
+ <td>8-K</td>
83
+ <td>10000</td>
84
+ </tr>
85
+ <tr>
86
+ <td>2</td>
87
+ <td>Results of Operations and Financial Condition</td>
88
+ <td><a href="/Archives/edgar/data/320193/000032019324000123/aapl-ex991.htm">aapl-ex991.htm</a></td>
89
+ <td>EX-99.1</td>
90
+ <td>42000</td>
91
+ </tr>
92
+ </table>
93
+ </body>
94
+ </html>
95
+ `, {
96
+ accessionNumber: "0000320193-24-000123",
97
+ form: "8-K",
98
+ filingDate: new Date("2024-08-01T00:00:00Z"),
99
+ cik: "0000320193",
100
+ filingUrl: "https://www.sec.gov/Archives/edgar/data/320193/0000320193-24-000123-index.htm",
101
+ primaryDocument: "aapl-8k.htm",
102
+ primaryDocumentUrl: "https://www.sec.gov/Archives/edgar/data/320193/000032019324000123/aapl-8k.htm",
103
+ });
104
+
105
+ expect(documents).toMatchObject([
106
+ {
107
+ type: "8-K",
108
+ document: "aapl-8k.htm",
109
+ isPrimary: true,
110
+ url: "https://www.sec.gov/Archives/edgar/data/320193/000032019324000123/aapl-8k.htm",
111
+ },
112
+ {
113
+ type: "EX-99.1",
114
+ description: "Results of Operations and Financial Condition",
115
+ document: "aapl-ex991.htm",
116
+ isPrimary: false,
117
+ },
118
+ ]);
119
+ });
120
+ });
121
+
68
122
  describe("SecEdgarClient", () => {
69
123
  test("extracts readable text from filing html", () => {
70
124
  const content = extractFilingContent(`
@@ -81,6 +135,30 @@ describe("SecEdgarClient", () => {
81
135
  expect(content).toContain("Revenue increased 12% year over year.");
82
136
  });
83
137
 
138
+ test("trims SEC document wrapper metadata before exhibit content", () => {
139
+ const content = extractFilingContent(`
140
+ <SEC-DOCUMENT>
141
+ <DOCUMENT>
142
+ <TYPE>EX-99.1
143
+ <SEQUENCE>2
144
+ <FILENAME>q42026er-991.htm
145
+ <DESCRIPTION>EX-99.1
146
+ <TEXT>
147
+ <html><body>
148
+ <p>Exhibit 99.1</p>
149
+ <h1>e.l.f. Beauty Announces Fourth Quarter Fiscal 2026 Results</h1>
150
+ <p>Delivered fiscal 2026 net sales growth of 25% year over year.</p>
151
+ </body></html>
152
+ </TEXT>
153
+ </DOCUMENT>
154
+ </SEC-DOCUMENT>
155
+ `, "text/html", { form: "EX-99.1" });
156
+
157
+ expect(content?.startsWith("Exhibit 99.1")).toBe(true);
158
+ expect(content).toContain("Fourth Quarter Fiscal 2026 Results");
159
+ expect(content).not.toContain("q42026er-991.htm");
160
+ });
161
+
84
162
  test("drops hidden inline xbrl boilerplate from filing html", () => {
85
163
  const content = extractFilingContent(`
86
164
  <?xml version="1.0"?>
@@ -119,7 +197,7 @@ describe("SecEdgarClient", () => {
119
197
  sourceUrl: "https://www.sec.gov/Archives/edgar/data/2488/example.pdf",
120
198
  });
121
199
 
122
- expect(content).toContain("primary document is a PDF");
200
+ expect(content).toContain("document is a PDF");
123
201
  expect(content).not.toContain("%PDF-1.6");
124
202
  });
125
203
 
@@ -283,4 +361,33 @@ describe("SecEdgarClient", () => {
283
361
  expect(content).toContain("Proxy Statement");
284
362
  expect(content).toContain("Annual meeting details.");
285
363
  });
364
+
365
+ test("loads filing documents from the filing index", async () => {
366
+ globalThis.fetch = (async () => new Response(`
367
+ <html>
368
+ <body>
369
+ <table class="tableFile">
370
+ <tr><td>1</td><td>Current report</td><td><a href="/Archives/edgar/data/320193/000032019324000123/aapl-8k.htm">aapl-8k.htm</a></td><td>8-K</td><td>10000</td></tr>
371
+ <tr><td>2</td><td>Investor presentation</td><td><a href="/Archives/edgar/data/320193/000032019324000123/aapl-ex992.pdf">aapl-ex992.pdf</a></td><td>EX-99.2</td><td>90000</td></tr>
372
+ </table>
373
+ </body>
374
+ </html>
375
+ `, {
376
+ status: 200,
377
+ headers: { "content-type": "text/html" },
378
+ })) as unknown as typeof fetch;
379
+
380
+ const client = new SecEdgarClient();
381
+ const documents = await client.getFilingDocuments({
382
+ accessionNumber: "0000320193-24-000123",
383
+ form: "8-K",
384
+ filingDate: new Date("2024-08-01T00:00:00Z"),
385
+ cik: "0000320193",
386
+ filingUrl: "https://www.sec.gov/Archives/edgar/data/320193/0000320193-24-000123-index.htm",
387
+ primaryDocument: "aapl-8k.htm",
388
+ });
389
+
390
+ expect(documents.map((document) => document.type)).toEqual(["8-K", "EX-99.2"]);
391
+ expect(documents[1]?.description).toBe("Investor presentation");
392
+ });
286
393
  });
@@ -1,4 +1,4 @@
1
- import type { SecFilingItem } from "../types/data-provider";
1
+ import type { SecFilingDocument, SecFilingItem } from "../types/data-provider";
2
2
  import { truncateWithEllipsis } from "../utils/text-wrap";
3
3
  import {
4
4
  PDF_FALLBACK_MESSAGE,
@@ -84,6 +84,56 @@ function stripAccessionDashes(accessionNumber: string): string {
84
84
  return accessionNumber.replace(/-/g, "");
85
85
  }
86
86
 
87
+ function documentNameFromUrl(url: string): string {
88
+ try {
89
+ return decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
90
+ } catch {
91
+ return url.split("/").pop() ?? url;
92
+ }
93
+ }
94
+
95
+ function normalizeDocumentName(value: string | undefined): string {
96
+ return (value ?? "").trim().toLowerCase();
97
+ }
98
+
99
+ function decodeSecHtmlEntities(text: string): string {
100
+ return text
101
+ .replace(/&nbsp;/gi, " ")
102
+ .replace(/&amp;/gi, "&")
103
+ .replace(/&lt;/gi, "<")
104
+ .replace(/&gt;/gi, ">")
105
+ .replace(/&quot;/gi, "\"")
106
+ .replace(/&#39;|&#x27;/gi, "'")
107
+ .replace(/&#(\d+);/g, (_match, digits: string) => String.fromCodePoint(Number(digits)))
108
+ .replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(parseInt(hex, 16)));
109
+ }
110
+
111
+ function cleanSecTableText(value: string): string {
112
+ return decodeSecHtmlEntities(
113
+ value
114
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
115
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
116
+ .replace(/<[^>]+>/g, " ")
117
+ .replace(/\s+/g, " ")
118
+ .trim(),
119
+ );
120
+ }
121
+
122
+ function resolveSecArchiveUrl(href: string, filingUrl: string): string | null {
123
+ const trimmed = href.trim();
124
+ if (!trimmed) return null;
125
+ try {
126
+ const url = new URL(trimmed, filingUrl);
127
+ const ixviewerDocument = url.searchParams.get("doc");
128
+ if (ixviewerDocument?.startsWith("/Archives/")) {
129
+ return new URL(ixviewerDocument, "https://www.sec.gov").toString();
130
+ }
131
+ return url.toString();
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
87
137
  function isHtmlResponse(body: string): boolean {
88
138
  return /^\s*<!DOCTYPE html/i.test(body) || /^\s*<html/i.test(body);
89
139
  }
@@ -201,6 +251,52 @@ export function parseRecentFilings(payload: unknown, count = 15): SecFilingItem[
201
251
  return results;
202
252
  }
203
253
 
254
+ export function parseFilingDocuments(indexHtml: string, filing: SecFilingItem): SecFilingDocument[] {
255
+ const documents: SecFilingDocument[] = [];
256
+ const primaryUrl = normalizeDocumentName(filing.primaryDocumentUrl);
257
+ const primaryDocument = normalizeDocumentName(filing.primaryDocument);
258
+ const rows = [...indexHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)];
259
+
260
+ for (const rowMatch of rows) {
261
+ const rowHtml = rowMatch[1] ?? "";
262
+ const cells = [...rowHtml.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map((match) => match[1] ?? "");
263
+ if (cells.length < 3) continue;
264
+
265
+ const hrefMatch = rowHtml.match(/<a\b[^>]*href="([^"]+)"[^>]*>/i);
266
+ if (!hrefMatch?.[1]) continue;
267
+ const url = resolveSecArchiveUrl(hrefMatch[1], filing.filingUrl);
268
+ if (!url || !url.includes("/Archives/edgar/data/")) continue;
269
+
270
+ const sequence = cleanSecTableText(cells[0] ?? "") || undefined;
271
+ const description = cleanSecTableText(cells[1] ?? "") || undefined;
272
+ const linkedDocument = cleanSecTableText(cells[2] ?? "") || documentNameFromUrl(url);
273
+ const type = cleanSecTableText(cells[3] ?? "") || linkedDocument;
274
+ const size = cleanSecTableText(cells[4] ?? "") || undefined;
275
+ const document = linkedDocument || documentNameFromUrl(url);
276
+ const normalizedDocument = normalizeDocumentName(document);
277
+ const normalizedUrl = normalizeDocumentName(url);
278
+
279
+ documents.push({
280
+ sequence,
281
+ type,
282
+ description,
283
+ document,
284
+ url,
285
+ size,
286
+ isPrimary: (
287
+ (primaryDocument.length > 0 && normalizedDocument === primaryDocument)
288
+ || (primaryUrl.length > 0 && normalizedUrl === primaryUrl)
289
+ || (
290
+ documents.length === 0
291
+ && normalizeDocumentName(type) === normalizeDocumentName(filing.form)
292
+ )
293
+ ),
294
+ });
295
+ }
296
+
297
+ return documents;
298
+ }
299
+
204
300
  export class SecEdgarClient {
205
301
  private lookupPromise: Promise<Map<string, LookupEntry>> | null = null;
206
302
 
@@ -311,6 +407,11 @@ export class SecEdgarClient {
311
407
  return parseRecentFilings(payload, count);
312
408
  }
313
409
 
410
+ async getFilingDocuments(filing: SecFilingItem): Promise<SecFilingDocument[]> {
411
+ const { body } = await this.fetchText(filing.filingUrl);
412
+ return parseFilingDocuments(body, filing);
413
+ }
414
+
314
415
  async getFilingContent(filing: Pick<SecFilingItem, "primaryDocumentUrl" | "filingUrl" | "form">): Promise<string | null> {
315
416
  let targetUrl = filing.primaryDocumentUrl || filing.filingUrl;
316
417
  if (!targetUrl) return null;
@@ -328,6 +429,9 @@ export class SecEdgarClient {
328
429
  }
329
430
 
330
431
  if (filing.primaryDocumentUrl && isPdfDocument("", "", filing.primaryDocumentUrl)) {
432
+ if (filing.filingUrl === filing.primaryDocumentUrl) {
433
+ return PDF_FALLBACK_MESSAGE;
434
+ }
331
435
  const alternativeUrl = filing.filingUrl
332
436
  ? await this.findAlternativeHtmlDocumentUrl(filing.filingUrl, filing.primaryDocumentUrl)
333
437
  : null;
@@ -226,6 +226,10 @@ export class YahooFinanceClient implements DataProvider {
226
226
  return this.secClient.getRecentFilings(ticker, count);
227
227
  }
228
228
 
229
+ async getSecFilingDocuments(filing: SecFilingItem) {
230
+ return this.secClient.getFilingDocuments(filing);
231
+ }
232
+
229
233
  async getSecFilingContent(filing: SecFilingItem): Promise<string | null> {
230
234
  return this.secClient.getFilingContent(filing);
231
235
  }
@@ -26,6 +26,7 @@ export function createTestPluginRuntime(
26
26
  showPane() {},
27
27
  createPaneFromTemplate() {},
28
28
  hidePane() {},
29
+ focusPane() {},
29
30
  openPaneSettings() {},
30
31
  openPluginCommandWorkflow() {},
31
32
  notify() {},
@@ -34,6 +34,16 @@ export interface SecFilingItem {
34
34
  primaryDocumentUrl?: string;
35
35
  }
36
36
 
37
+ export interface SecFilingDocument {
38
+ sequence?: string;
39
+ type: string;
40
+ description?: string;
41
+ document: string;
42
+ url: string;
43
+ size?: string;
44
+ isPrimary: boolean;
45
+ }
46
+
37
47
  export interface EarningsEvent {
38
48
  symbol: string;
39
49
  name: string;
@@ -127,6 +137,7 @@ export interface AssetDataProvider {
127
137
  getAnalystResearch?(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<AnalystResearchData>;
128
138
  getCorporateActions?(ticker: string, exchange?: string, context?: MarketDataRequestContext): Promise<CorporateActionsData>;
129
139
  getEarningsCalendar?(symbols: string[], context?: MarketDataRequestContext): Promise<EarningsEvent[]>;
140
+ getSecFilingDocuments?(filing: SecFilingItem): Promise<SecFilingDocument[]>;
130
141
  getSecFilingContent?(filing: SecFilingItem): Promise<string | null>;
131
142
  /** Fetch article summary/description by URL (lazy-loaded on selection) */
132
143
  getArticleSummary(url: string): Promise<string | null>;
package/src/ui/host.tsx CHANGED
@@ -122,6 +122,7 @@ export interface ScrollBoxRenderable {
122
122
  export interface InputRenderable {
123
123
  editBuffer: TextEditBuffer;
124
124
  cursorOffset?: number;
125
+ setCursorOffset?(offset: number): void;
125
126
  focus?(): void;
126
127
  }
127
128
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.8.0";
1
+ export const VERSION = "0.8.2";