gloomberb 0.8.1 → 0.9.0

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 (196) hide show
  1. package/README.md +106 -54
  2. package/package.json +14 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/src/api-client/chat.ts +8 -0
  5. package/src/api-client/index.test.ts +30 -0
  6. package/src/api-client/index.ts +4 -0
  7. package/src/api-client/normalizers.ts +1 -0
  8. package/src/api-client/types.ts +1 -0
  9. package/src/app/global-shortcuts.test.tsx +54 -4
  10. package/src/app/global-shortcuts.ts +11 -5
  11. package/src/capabilities/factories.ts +4 -0
  12. package/src/components/chart/comparison/data.test.ts +54 -0
  13. package/src/components/chart/comparison/data.ts +47 -24
  14. package/src/components/chart/core/projection.ts +51 -27
  15. package/src/components/command-bar/action-runtime.ts +1 -0
  16. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  17. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  18. package/src/components/command-bar/list/view.tsx +3 -1
  19. package/src/components/command-bar/pane-templates/items.ts +4 -1
  20. package/src/components/command-bar/panel/body.tsx +1 -0
  21. package/src/components/command-bar/routes/root/results.ts +0 -30
  22. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  23. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  24. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  25. package/src/components/command-bar/routing/launch-request.ts +14 -7
  26. package/src/components/command-bar/routing/list-state.ts +1 -3
  27. package/src/components/command-bar/surface/index.test.tsx +76 -4
  28. package/src/components/command-bar/surface/index.tsx +0 -2
  29. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  30. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  31. package/src/components/command-bar/view-model.ts +3 -3
  32. package/src/components/data-table/stack-view.test.tsx +14 -11
  33. package/src/components/data-table/stack-view.tsx +1 -2
  34. package/src/components/data-table/view.test.tsx +24 -18
  35. package/src/components/data-table/view.tsx +313 -83
  36. package/src/components/feed-data-table/stack-view.tsx +6 -8
  37. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  38. package/src/components/layout/pane/footer/index.tsx +14 -2
  39. package/src/components/layout/pane/title.ts +5 -1
  40. package/src/components/layout/shell/index.test.tsx +120 -2
  41. package/src/components/layout/shell/index.tsx +2 -0
  42. package/src/components/layout/shell/menu.ts +1 -7
  43. package/src/components/layout/shell/pane/actions.ts +8 -0
  44. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  45. package/src/components/layout/shell/shortcuts.ts +14 -6
  46. package/src/components/layout/shell/window-mode/index.ts +8 -3
  47. package/src/components/layout/window-edit/presentation.ts +4 -4
  48. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  49. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  50. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  51. package/src/components/ticker/list-table-view.tsx +17 -25
  52. package/src/components/ui/choice-dialog.tsx +12 -2
  53. package/src/components/ui/multi-select/dialog.tsx +17 -5
  54. package/src/components/ui/remote-image.tsx +1 -14
  55. package/src/components/ui/shortcut-hint.tsx +14 -2
  56. package/src/components/ui/table-layout.test.ts +2 -2
  57. package/src/components/ui/table-layout.ts +8 -2
  58. package/src/components/ui/tabs.tsx +115 -86
  59. package/src/components/ui/ui.test.tsx +83 -4
  60. package/src/core/state/app/types.ts +12 -4
  61. package/src/market-data/coordinator/auxiliary.ts +29 -1
  62. package/src/market-data/coordinator/index.ts +17 -1
  63. package/src/market-data/hooks.tsx +17 -1
  64. package/src/market-data/selectors.ts +4 -0
  65. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  66. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  67. package/src/plugins/builtin/account-management/model.ts +13 -3
  68. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  69. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  70. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  71. package/src/plugins/builtin/analytics/index.tsx +0 -3
  72. package/src/plugins/builtin/analytics/view.tsx +6 -5
  73. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  74. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  75. package/src/plugins/builtin/changelog/index.tsx +40 -27
  76. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  77. package/src/plugins/builtin/changelog/model.ts +98 -0
  78. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  79. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  80. package/src/plugins/builtin/chat/channels.ts +36 -19
  81. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  82. package/src/plugins/builtin/chat/content/channel-navigation.ts +50 -11
  83. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  84. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  85. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  86. package/src/plugins/builtin/chat/content/index.tsx +66 -4
  87. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  88. package/src/plugins/builtin/chat/content/shortcuts.ts +27 -1
  89. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  90. package/src/plugins/builtin/chat/content/types.ts +1 -0
  91. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  92. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  93. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  94. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  95. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  96. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  97. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  98. package/src/plugins/builtin/chat/layout.ts +94 -31
  99. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  100. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  101. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  102. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  103. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  104. package/src/plugins/builtin/chat/message/types.ts +2 -0
  105. package/src/plugins/builtin/chat/pane.tsx +35 -7
  106. package/src/plugins/builtin/chat/sidebar.test.tsx +72 -0
  107. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  108. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  109. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  110. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  111. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  112. package/src/plugins/builtin/earnings/index.tsx +8 -7
  113. package/src/plugins/builtin/econ/index.tsx +6 -8
  114. package/src/plugins/builtin/help/components.tsx +1 -1
  115. package/src/plugins/builtin/help/index.test.tsx +105 -30
  116. package/src/plugins/builtin/help/index.tsx +326 -164
  117. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  118. package/src/plugins/builtin/holders/pane.tsx +7 -6
  119. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  120. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  121. package/src/plugins/builtin/options/view.tsx +16 -8
  122. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  123. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  124. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  125. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  126. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  127. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  128. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  129. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  130. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  131. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  132. package/src/plugins/builtin/research/index.test.ts +30 -1
  133. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  134. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  135. package/src/plugins/builtin/sec/index.tsx +132 -20
  136. package/src/plugins/builtin/sectors/index.tsx +13 -23
  137. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  138. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  139. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  140. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  141. package/src/plugins/builtin/substack/api/store.ts +186 -0
  142. package/src/plugins/builtin/substack/api/types.ts +40 -0
  143. package/src/plugins/builtin/substack/api.test.ts +298 -0
  144. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  145. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  146. package/src/plugins/builtin/substack/content.ts +478 -0
  147. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  148. package/src/plugins/builtin/substack/index.tsx +49 -0
  149. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  150. package/src/plugins/builtin/substack/model.test.ts +155 -0
  151. package/src/plugins/builtin/substack/normalize.ts +343 -0
  152. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  153. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  154. package/src/plugins/builtin/substack/pane.tsx +500 -0
  155. package/src/plugins/builtin/substack/table.ts +111 -0
  156. package/src/plugins/builtin/substack/types.ts +71 -0
  157. package/src/plugins/builtin/substack/utils.ts +131 -0
  158. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  161. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  162. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  163. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  164. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  165. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  166. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  167. package/src/plugins/catalog-ui.ts +2 -0
  168. package/src/plugins/pane-manager/layout-state.ts +11 -0
  169. package/src/plugins/pane-manager.ts +1 -1
  170. package/src/plugins/prediction-markets/pane.tsx +10 -9
  171. package/src/plugins/registry/index.ts +3 -0
  172. package/src/plugins/runtime/context.tsx +1 -0
  173. package/src/plugins/runtime/index.tsx +4 -0
  174. package/src/react/use-throttled-commit-value.ts +116 -0
  175. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  176. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  177. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  178. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  179. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  180. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  181. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  182. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  183. package/src/sources/gloomberb-cloud/index.ts +4 -0
  184. package/src/sources/provider-router/cache.ts +1 -0
  185. package/src/sources/provider-router/document-routes.ts +45 -0
  186. package/src/sources/provider-router/index.ts +4 -0
  187. package/src/sources/sec-edgar/content.ts +9 -3
  188. package/src/sources/sec-edgar.test.ts +108 -1
  189. package/src/sources/sec-edgar.ts +105 -1
  190. package/src/sources/yahoo-finance.ts +4 -0
  191. package/src/state/app/context/index.test.tsx +16 -0
  192. package/src/test-support/plugin-runtime.ts +1 -0
  193. package/src/tickers/search/index.ts +1 -0
  194. package/src/types/data-provider.ts +11 -0
  195. package/src/ui/host.tsx +1 -0
  196. package/src/version.ts +1 -1
@@ -0,0 +1,111 @@
1
+ import type {
2
+ SubstackArticleSummary,
3
+ SubstackColumn,
4
+ SubstackPublication,
5
+ SubstackSortColumnId,
6
+ SubstackSortDirection,
7
+ } from "./types";
8
+ import { publicationKey } from "./normalize";
9
+ import { timestamp } from "./utils";
10
+
11
+ export function isSubstackSortColumnId(value: string): value is SubstackSortColumnId {
12
+ return value === "published" || value === "publication" || value === "title" || value === "read";
13
+ }
14
+
15
+ export function nextSubstackSort(
16
+ current: { columnId: SubstackSortColumnId; direction: SubstackSortDirection },
17
+ columnId: SubstackSortColumnId,
18
+ ): { columnId: SubstackSortColumnId; direction: SubstackSortDirection } {
19
+ if (current.columnId === columnId) {
20
+ return { columnId, direction: current.direction === "desc" ? "asc" : "desc" };
21
+ }
22
+ return {
23
+ columnId,
24
+ direction: columnId === "title" || columnId === "publication" ? "asc" : "desc",
25
+ };
26
+ }
27
+
28
+ export function sortedSubstackArticles(
29
+ articles: SubstackArticleSummary[],
30
+ sort: { columnId: SubstackSortColumnId; direction: SubstackSortDirection },
31
+ ): SubstackArticleSummary[] {
32
+ const direction = sort.direction === "asc" ? 1 : -1;
33
+ return [...articles].sort((a, b) => {
34
+ let result = 0;
35
+ switch (sort.columnId) {
36
+ case "published":
37
+ result = timestamp(a.publishedAt) - timestamp(b.publishedAt);
38
+ break;
39
+ case "publication":
40
+ result = (a.publicationName ?? "").localeCompare(b.publicationName ?? "");
41
+ break;
42
+ case "title":
43
+ result = a.title.localeCompare(b.title);
44
+ break;
45
+ case "read":
46
+ result = a.readMinutes - b.readMinutes;
47
+ break;
48
+ }
49
+ return result === 0 ? timestamp(b.publishedAt) - timestamp(a.publishedAt) : result * direction;
50
+ });
51
+ }
52
+
53
+ export function formatPublishedAt(value: string | null): string {
54
+ if (!value) return "-";
55
+ const date = new Date(value);
56
+ if (Number.isNaN(date.getTime())) return "-";
57
+ const now = new Date();
58
+ const sameYear = date.getFullYear() === now.getFullYear();
59
+ const datePart = date.toLocaleDateString("en-US", {
60
+ month: "short",
61
+ day: "numeric",
62
+ ...(sameYear ? {} : { year: "2-digit" as const }),
63
+ });
64
+ const timePart = date.toLocaleTimeString("en-US", {
65
+ hour: "2-digit",
66
+ minute: "2-digit",
67
+ hour12: false,
68
+ });
69
+ return `${datePart} ${timePart}`;
70
+ }
71
+
72
+ export function formatReadTime(minutes: number): string {
73
+ return `${Math.max(1, Math.ceil(minutes))}m`;
74
+ }
75
+
76
+ export function formatWordCount(words: number): string {
77
+ if (words >= 1000) return `${(words / 1000).toFixed(words >= 10_000 ? 0 : 1)}k words`;
78
+ return `${Math.max(0, Math.round(words))} words`;
79
+ }
80
+
81
+ export function buildSubstackColumns(width: number, includePublication: boolean): SubstackColumn[] {
82
+ const publishedWidth = 13;
83
+ const readWidth = 5;
84
+ const publicationWidth = includePublication ? Math.max(12, Math.min(21, Math.floor(width * 0.18))) : 0;
85
+ const fixed = publishedWidth + readWidth + publicationWidth;
86
+ const titleWidth = Math.max(18, width - fixed - 4);
87
+ const columns: SubstackColumn[] = [
88
+ { id: "published", label: "Published", width: publishedWidth, align: "left" },
89
+ ];
90
+ if (includePublication) {
91
+ columns.push({ id: "publication", label: "Publication", width: publicationWidth, align: "left" });
92
+ }
93
+ columns.push(
94
+ { id: "title", label: "Title", width: titleWidth, align: "left", flexGrow: 1 },
95
+ { id: "read", label: "Read", width: readWidth, align: "right" },
96
+ );
97
+ return columns;
98
+ }
99
+
100
+ export function tabIdForPublication(publication: SubstackPublication): string {
101
+ return `pub:${publicationKey(publication)}`;
102
+ }
103
+
104
+ export function publicationFromTabId(
105
+ tabId: string,
106
+ publications: SubstackPublication[],
107
+ ): SubstackPublication | null {
108
+ if (!tabId.startsWith("pub:")) return null;
109
+ const key = tabId.slice(4);
110
+ return publications.find((publication) => publicationKey(publication) === key) ?? null;
111
+ }
@@ -0,0 +1,71 @@
1
+ import type { DataTableColumn } from "../../../components";
2
+
3
+ export const SUBSTACK_PANE_ID = "substack";
4
+ export const SUBSTACK_PLUGIN_ID = "substack";
5
+ export const SUBSTACK_FEED_TAB_ID = "feed";
6
+
7
+ export interface SubstackPublication {
8
+ id: string;
9
+ name: string;
10
+ subdomain: string | null;
11
+ baseUrl: string | null;
12
+ description: string | null;
13
+ logoUrl: string | null;
14
+ latestPublishedAt: string | null;
15
+ }
16
+
17
+ export interface ExtractedArticleContent {
18
+ text: string;
19
+ blocks: SubstackContentBlock[];
20
+ imageUrls: string[];
21
+ linkUrls: string[];
22
+ wordCount: number;
23
+ readMinutes: number;
24
+ }
25
+
26
+ export type SubstackContentBlock =
27
+ | { type: "heading"; text: string; level: number }
28
+ | { type: "paragraph"; text: string }
29
+ | { type: "quote"; text: string }
30
+ | { type: "listItem"; text: string }
31
+ | { type: "image"; url: string; alt: string | null }
32
+ | {
33
+ type: "embed";
34
+ kind: "tweet";
35
+ text: string;
36
+ url: string | null;
37
+ username: string | null;
38
+ authorName: string | null;
39
+ dateLabel: string | null;
40
+ imageUrls: string[];
41
+ }
42
+ | { type: "embed"; kind: "link" | "media"; text: string; url: string | null }
43
+ | { type: "divider" };
44
+
45
+ export interface SubstackArticleSummary {
46
+ id: string;
47
+ title: string;
48
+ publicationId: string | null;
49
+ publicationName: string | null;
50
+ publicationSubdomain: string | null;
51
+ publicationBaseUrl: string | null;
52
+ url: string | null;
53
+ slug: string | null;
54
+ publishedAt: string | null;
55
+ subtitle: string | null;
56
+ previewText: string | null;
57
+ bodyHtml: string | null;
58
+ imageUrls: string[];
59
+ wordCount: number;
60
+ readMinutes: number;
61
+ }
62
+
63
+ export interface SubstackArticleDetail extends SubstackArticleSummary {
64
+ contentText: string;
65
+ contentBlocks: SubstackContentBlock[];
66
+ linkUrls: string[];
67
+ }
68
+
69
+ export type SubstackSortColumnId = "published" | "publication" | "title" | "read";
70
+ export type SubstackSortDirection = "asc" | "desc";
71
+ export type SubstackColumn = DataTableColumn & { id: SubstackSortColumnId };
@@ -0,0 +1,131 @@
1
+ import { decodeHtmlEntities } from "../../../utils/html-entities";
2
+ import { normalizedHttpUrl } from "../../../utils/url";
3
+
4
+ export type JsonRecord = Record<string, unknown>;
5
+
6
+ function isRecord(value: unknown): value is JsonRecord {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+
10
+ export function asRecord(value: unknown): JsonRecord | null {
11
+ return isRecord(value) ? value : null;
12
+ }
13
+
14
+ export function stringValue(value: unknown): string | null {
15
+ if (typeof value !== "string") return null;
16
+ const trimmed = decodeHtmlEntities(value).replace(/\s+/g, " ").trim();
17
+ return trimmed.length > 0 ? trimmed : null;
18
+ }
19
+
20
+ export function rawStringValue(value: unknown): string | null {
21
+ if (typeof value !== "string") return null;
22
+ const trimmed = value.trim();
23
+ return trimmed.length > 0 ? trimmed : null;
24
+ }
25
+
26
+ function numberValue(value: unknown): number | null {
27
+ if (typeof value === "number" && Number.isFinite(value)) return value;
28
+ if (typeof value === "string" && value.trim()) {
29
+ const parsed = Number(value);
30
+ return Number.isFinite(parsed) ? parsed : null;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ export function firstString(record: JsonRecord | null, keys: string[]): string | null {
36
+ if (!record) return null;
37
+ for (const key of keys) {
38
+ const value = stringValue(record[key]);
39
+ if (value) return value;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ export function firstRawString(record: JsonRecord | null, keys: string[]): string | null {
45
+ if (!record) return null;
46
+ for (const key of keys) {
47
+ const value = rawStringValue(record[key]);
48
+ if (value) return value;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ export function firstNumber(record: JsonRecord | null, keys: string[]): number | null {
54
+ if (!record) return null;
55
+ for (const key of keys) {
56
+ const value = numberValue(record[key]);
57
+ if (value != null) return value;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function firstValue(record: JsonRecord | null, keys: string[]): unknown {
63
+ if (!record) return null;
64
+ for (const key of keys) {
65
+ if (record[key] != null) return record[key];
66
+ }
67
+ return null;
68
+ }
69
+
70
+ export function firstRecord(...values: unknown[]): JsonRecord | null {
71
+ for (const value of values) {
72
+ const record = asRecord(value);
73
+ if (record) return record;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ export function parseDateIso(value: unknown): string | null {
79
+ if (value == null) return null;
80
+ if (typeof value === "number" && Number.isFinite(value)) {
81
+ const ms = value > 10_000_000_000 ? value : value * 1000;
82
+ const date = new Date(ms);
83
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
84
+ }
85
+ if (typeof value !== "string" || !value.trim()) return null;
86
+ const date = new Date(value);
87
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
88
+ }
89
+
90
+ export function normalizeUrl(value: unknown, baseUrl?: string | null): string | null {
91
+ const raw = rawStringValue(value);
92
+ if (!raw) return null;
93
+ const direct = normalizedHttpUrl(raw);
94
+ if (direct) return direct;
95
+ if (!baseUrl) return null;
96
+ try {
97
+ const resolved = new URL(raw, baseUrl);
98
+ return normalizedHttpUrl(resolved.toString());
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ export function uniqueStrings(values: Array<string | null | undefined>): string[] {
105
+ const seen = new Set<string>();
106
+ const result: string[] = [];
107
+ for (const value of values) {
108
+ if (!value || seen.has(value)) continue;
109
+ seen.add(value);
110
+ result.push(value);
111
+ }
112
+ return result;
113
+ }
114
+
115
+ export function timestamp(value: string | null | undefined): number {
116
+ if (!value) return 0;
117
+ const date = new Date(value);
118
+ return Number.isNaN(date.getTime()) ? 0 : date.getTime();
119
+ }
120
+
121
+ export function maxIso(a: string | null, b: string | null): string | null {
122
+ if (!a) return b;
123
+ if (!b) return a;
124
+ return timestamp(a) >= timestamp(b) ? a : b;
125
+ }
126
+
127
+ export function extractAttribute(tag: string, name: string): string | null {
128
+ const pattern = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
129
+ const match = tag.match(pattern);
130
+ return rawStringValue(match?.[1] ?? match?.[2] ?? match?.[3] ?? null);
131
+ }
@@ -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
  }}
@@ -41,6 +41,7 @@ let detailHarnessState: ReturnType<typeof createInitialState> | null = null;
41
41
  let sharedCoordinator: MarketDataCoordinator | null = null;
42
42
 
43
43
  const runtime = createTestPluginRuntime();
44
+ const TICKER_TAB_SETTLE_MS = 160;
44
45
 
45
46
  const DetailPane = tickerDetailPlugin.panes![0]!.component as (props: {
46
47
  paneId: string;
@@ -160,7 +161,7 @@ function setOptionsProvider(provider: DataProvider | undefined): void {
160
161
  setSharedMarketDataCoordinator(sharedCoordinator);
161
162
  }
162
163
 
163
- function makeRegistry(): PluginRegistry {
164
+ function makeRegistry(options: { stubFundamentalGraphs?: boolean } = {}): PluginRegistry {
164
165
  const stubTab = (_props: { width: number; height: number; focused: boolean; onCapture: (capturing: boolean) => void }) => (
165
166
  <text>stub</text>
166
167
  );
@@ -168,6 +169,10 @@ function makeRegistry(): PluginRegistry {
168
169
  tickerDetailPlugin.setup?.({
169
170
  registerTickerResearchTab: (tab: TickerResearchTabDef) => tickerResearchTabs.set(tab.id, tab),
170
171
  } as any);
172
+ if (options.stubFundamentalGraphs) {
173
+ const tab = tickerResearchTabs.get("fundamental-graphs");
174
+ if (tab) tickerResearchTabs.set(tab.id, { ...tab, component: stubTab });
175
+ }
171
176
  for (const tab of [
172
177
  ["ibkr-trade", {
173
178
  id: "ibkr-trade",
@@ -280,6 +285,13 @@ async function flushFrame() {
280
285
  });
281
286
  }
282
287
 
288
+ async function settleTickerTabCommit() {
289
+ await act(async () => {
290
+ await new Promise((resolve) => setTimeout(resolve, TICKER_TAB_SETTLE_MS));
291
+ await testSetup!.renderOnce();
292
+ });
293
+ }
294
+
283
295
  async function clickFrameText(text: string) {
284
296
  const lines = testSetup!.captureCharFrame().split("\n");
285
297
  const row = lines.findIndex((line) => line.includes(text));
@@ -482,7 +494,7 @@ describe("TickerResearchPane", () => {
482
494
  });
483
495
 
484
496
  test("lets ticker detail tab arrows leave the embedded financials tab", async () => {
485
- setSharedRegistryForTests(makeRegistry());
497
+ setSharedRegistryForTests(makeRegistry({ stubFundamentalGraphs: true }));
486
498
  setOptionsProvider(createProvider(false));
487
499
 
488
500
  testSetup = await testRender(
@@ -506,6 +518,7 @@ describe("TickerResearchPane", () => {
506
518
  });
507
519
  await flushFrame();
508
520
 
521
+ await settleTickerTabCommit();
509
522
  expect(detailHarnessState?.paneState[TEST_PANE_ID]?.activeTabId).toBe("fundamental-graphs");
510
523
 
511
524
  await act(async () => {
@@ -514,6 +527,7 @@ describe("TickerResearchPane", () => {
514
527
  });
515
528
  await flushFrame();
516
529
 
530
+ await settleTickerTabCommit();
517
531
  expect(detailHarnessState?.paneState[TEST_PANE_ID]?.activeTabId).toBe("financials");
518
532
  });
519
533
 
@@ -146,6 +146,7 @@ export const tickerDetailPlugin: GloomPlugin = {
146
146
  label: "Ticker Research",
147
147
  description: "Open another research pane for the selected ticker or current collection",
148
148
  keywords: ["new", "ticker", "research", "detail", "pane", "inspector"],
149
+ shortcut: { prefix: "T", argPlaceholder: "ticker", argKind: "ticker" },
149
150
  canCreate: (context) => context.activeTicker !== null || context.activeCollectionId !== null,
150
151
  createInstance: (context) => (
151
152
  context.activeTicker
@@ -14,6 +14,7 @@ import { useQuoteStreaming } from "../../../state/hooks/quote-streaming";
14
14
  import { getCollectionName, getCollectionTickerCount } from "../../../state/selectors";
15
15
  import { getSharedRegistry } from "../../registry";
16
16
  import { EmptyState, PaneFooterScope, Tabs } from "../../../components";
17
+ import { useThrottledCommitValue } from "../../../react/use-throttled-commit-value";
17
18
  import { resolveOptionsTarget } from "../../../utils/options";
18
19
  import {
19
20
  buildVisibleTickerResearchTabs,
@@ -21,6 +22,8 @@ import {
21
22
  resolveLockedTabId,
22
23
  } from "./settings";
23
24
 
25
+ const TICKER_RESEARCH_TAB_COMMIT_DELAY_MS = 120;
26
+
24
27
  function sameStringSet(left: Set<string>, right: Set<string>): boolean {
25
28
  if (left.size !== right.size) return false;
26
29
  for (const value of left) {
@@ -79,7 +82,16 @@ export function TickerResearchPane({ focused, width, height }: PaneProps) {
79
82
  useQuoteStreaming(streamingTargets);
80
83
 
81
84
  const { collectionId } = usePaneCollection();
82
- const [activeTabId, setActiveTabId] = usePaneStateValue<string>("activeTabId", "overview");
85
+ const [committedActiveTabId, setCommittedActiveTabId] = usePaneStateValue<string>("activeTabId", "overview");
86
+ const {
87
+ value: activeTabId,
88
+ setValue: setActiveTabId,
89
+ } = useThrottledCommitValue(
90
+ committedActiveTabId,
91
+ setCommittedActiveTabId,
92
+ TICKER_RESEARCH_TAB_COMMIT_DELAY_MS,
93
+ { commitPendingOnUnmount: true },
94
+ );
83
95
  const [pluginCaptured, setPluginCaptured] = useState(false);
84
96
  const [mountedTabIds, setMountedTabIds] = useState<Set<string>>(() => new Set());
85
97
  const paneSettings = getTickerResearchPaneSettings(paneInstance?.settings);
@@ -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}
@@ -2,6 +2,7 @@ import type { GloomPlugin } from "../types/plugin";
2
2
  import { portfolioListPlugin } from "./builtin/portfolio-list";
3
3
  import { newsPlugin } from "./builtin/news";
4
4
  import { notesPlugin } from "./builtin/notes";
5
+ import { substackPlugin } from "./builtin/substack";
5
6
  import { aiPlugin } from "./builtin/ai";
6
7
  import { gloomberbCloudPlugin } from "./builtin/cloud";
7
8
  import { changelogPlugin } from "./builtin/changelog";
@@ -26,6 +27,7 @@ export const uiBuiltinPlugins: GloomPlugin[] = [
26
27
  ibkrPlugin,
27
28
  layoutManagerPlugin,
28
29
  newsPlugin,
30
+ substackPlugin,
29
31
  notesPlugin,
30
32
  aiPlugin,
31
33
  changelogPlugin,