gloomberb 0.9.2 → 0.9.4

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 (233) hide show
  1. package/README.md +24 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app/runtime/desktop-menu.ts +5 -1
  5. package/src/app/runtime/ticker-refresh.ts +4 -1
  6. package/src/app.tsx +20 -8
  7. package/src/brokers/account-cache.ts +1 -1
  8. package/src/brokers/sync-broker-instance.test.ts +107 -0
  9. package/src/brokers/sync-broker-instance.ts +22 -10
  10. package/src/capabilities/registry.ts +1 -0
  11. package/src/capabilities/types.ts +23 -0
  12. package/src/cli/commands/api.ts +119 -0
  13. package/src/cli/commands/automation.ts +173 -0
  14. package/src/cli/commands/command-utils.ts +53 -0
  15. package/src/cli/commands/market.ts +527 -0
  16. package/src/cli/commands/overview.ts +222 -0
  17. package/src/cli/commands/search.ts +39 -0
  18. package/src/cli/commands/system.ts +553 -0
  19. package/src/cli/commands/ticker.ts +104 -12
  20. package/src/cli/context.ts +35 -2
  21. package/src/cli/desktop-pane-shot.ts +2 -1
  22. package/src/cli/entry.ts +51 -0
  23. package/src/cli/errors.ts +56 -5
  24. package/src/cli/index.ts +73 -11
  25. package/src/cli/options.test.ts +38 -0
  26. package/src/cli/options.ts +95 -0
  27. package/src/cli/pane-functions/index.ts +10 -1
  28. package/src/cli/pane-functions/screenshot.ts +13 -1
  29. package/src/cli/registry.test.ts +40 -0
  30. package/src/cli/registry.ts +46 -5
  31. package/src/cli/result.test.ts +82 -0
  32. package/src/cli/result.ts +149 -0
  33. package/src/cli/types.ts +11 -0
  34. package/src/cli.test.ts +11 -11
  35. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  36. package/src/components/chart/bar-chart-renderer.ts +226 -27
  37. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  38. package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
  39. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  40. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  41. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  42. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  43. package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
  44. package/src/components/chart/core/pane-settings.test.ts +27 -0
  45. package/src/components/chart/core/pane-settings.ts +60 -1
  46. package/src/components/chart/core/pointer.ts +34 -6
  47. package/src/components/chart/price-axis-labels.tsx +3 -1
  48. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  49. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  50. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  51. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  52. package/src/components/chart/static/chart/surface.tsx +167 -24
  53. package/src/components/chart/stock-chart/index.test.ts +25 -0
  54. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  55. package/src/components/chart/stock-chart/settings.ts +7 -3
  56. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  57. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  58. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  59. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  60. package/src/components/data-table/view.tsx +13 -2
  61. package/src/components/feed-data-table/stack-view.tsx +9 -1
  62. package/src/components/index.ts +1 -1
  63. package/src/components/input-search-bar.test.tsx +103 -0
  64. package/src/components/input-search-bar.tsx +16 -0
  65. package/src/components/layout/detached-pane-shell.tsx +6 -3
  66. package/src/components/layout/floating-pane.tsx +3 -2
  67. package/src/components/layout/pane/content.tsx +20 -8
  68. package/src/components/layout/pane/footer/registration.tsx +6 -2
  69. package/src/components/layout/pane/frame.tsx +4 -4
  70. package/src/components/layout/pane/header.tsx +40 -4
  71. package/src/components/layout/pane/index.tsx +3 -1
  72. package/src/components/layout/pane/sizing.test.ts +43 -0
  73. package/src/components/layout/pane/sizing.ts +13 -4
  74. package/src/components/layout/shell/active-drag.ts +5 -1
  75. package/src/components/layout/shell/cursor-occlusion.ts +196 -0
  76. package/src/components/layout/shell/drag/runtime.ts +28 -3
  77. package/src/components/layout/shell/fullscreen.ts +10 -0
  78. package/src/components/layout/shell/index.test.tsx +487 -4
  79. package/src/components/layout/shell/index.tsx +165 -4
  80. package/src/components/layout/shell/layout-state.test.ts +33 -0
  81. package/src/components/layout/shell/layout-state.ts +4 -9
  82. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  83. package/src/components/layout/shell/pane/layers.tsx +34 -16
  84. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  85. package/src/components/layout/shell/shortcuts.ts +4 -0
  86. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  87. package/src/components/layout/shell/visible-layout.ts +17 -0
  88. package/src/components/layout/shell/window-mode/index.ts +11 -3
  89. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  90. package/src/components/layout/status-bar.test.tsx +111 -1
  91. package/src/components/layout/status-bar.tsx +37 -7
  92. package/src/components/layout/transient-layout.tsx +57 -0
  93. package/src/components/layout/window-edit/mode.test.ts +76 -5
  94. package/src/components/layout/window-edit/mode.ts +47 -2
  95. package/src/components/layout/window-edit/presentation.ts +2 -1
  96. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  97. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  98. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  99. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  100. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  101. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  102. package/src/components/price-performance.tsx +9 -1
  103. package/src/components/table-view-shared.tsx +12 -4
  104. package/src/components/ui/data-table/opentui/index.tsx +25 -12
  105. package/src/components/ui/data-table/types.ts +4 -0
  106. package/src/components/ui/fields.tsx +10 -3
  107. package/src/components/ui/page-stack-view.tsx +6 -2
  108. package/src/components/ui/table-layout.test.ts +25 -0
  109. package/src/components/ui/table-layout.ts +22 -6
  110. package/src/components/ui/tabs.tsx +26 -10
  111. package/src/components/ui/ui.test.tsx +87 -0
  112. package/src/core/app-services.ts +7 -4
  113. package/src/core/state/app/layout-reducer.ts +2 -2
  114. package/src/data/config/store/index.ts +1 -2
  115. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  116. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  117. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  118. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  119. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  120. package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
  121. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  122. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  123. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  124. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  125. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  126. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  127. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  128. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  129. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  130. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  131. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  132. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  133. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  134. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  135. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  136. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  137. package/src/plugins/builtin/options/view.test.tsx +141 -4
  138. package/src/plugins/builtin/options/view.tsx +76 -15
  139. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  140. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  141. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  142. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  143. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  144. package/src/plugins/builtin/portfolio-list/pane/index.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  146. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  147. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  148. package/src/plugins/builtin/shared/read-state.ts +43 -0
  149. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  150. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  151. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  152. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  153. package/src/plugins/builtin/substack/pane.tsx +8 -3
  154. package/src/plugins/builtin/substack/read-state.ts +55 -0
  155. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  156. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  157. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  158. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  161. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  162. package/src/plugins/catalog-ui.ts +2 -0
  163. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  164. package/src/plugins/ibkr/config.test.ts +31 -2
  165. package/src/plugins/ibkr/config.ts +9 -2
  166. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  167. package/src/plugins/ibkr/flex/index.ts +30 -0
  168. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  169. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  170. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  171. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  172. package/src/plugins/pane-manager/docking.ts +9 -2
  173. package/src/plugins/pane-manager/gridlock.ts +13 -5
  174. package/src/plugins/pane-manager/layout-state.ts +25 -0
  175. package/src/plugins/pane-manager/queries.ts +8 -2
  176. package/src/plugins/pane-manager.test.ts +33 -0
  177. package/src/plugins/pane-manager.ts +6 -1
  178. package/src/plugins/prediction-markets/chart.tsx +10 -4
  179. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  180. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  181. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  182. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  183. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  184. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  185. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  186. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  187. package/src/plugins/prediction-markets/pane.test.tsx +75 -0
  188. package/src/plugins/prediction-markets/pane.tsx +71 -23
  189. package/src/plugins/prediction-markets/test-helpers.tsx +42 -23
  190. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  191. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  192. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  193. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  194. package/src/renderers/electrobun/bun/index.ts +13 -0
  195. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  196. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  197. package/src/renderers/electrobun/view/app-services.ts +6 -4
  198. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  199. package/src/renderers/electrobun/view/data-table/index.tsx +19 -4
  200. package/src/renderers/electrobun/view/data-table/row.tsx +30 -6
  201. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  202. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  203. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  204. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  205. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  206. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  207. package/src/renderers/electrobun/view/styles.css +11 -0
  208. package/src/renderers/electrobun/view/ui-host.tsx +1 -1
  209. package/src/renderers/opentui/host.tsx +17 -1
  210. package/src/renderers/opentui/start.tsx +13 -5
  211. package/src/renderers/opentui/ui-host.tsx +57 -25
  212. package/src/sources/provider-router/batches.ts +16 -2
  213. package/src/sources/provider-router/financial-routes.ts +5 -0
  214. package/src/sources/provider-router/financials.ts +11 -23
  215. package/src/sources/provider-router/index.test.ts +189 -0
  216. package/src/sources/provider-router/primary.ts +3 -2
  217. package/src/sources/sec-edgar.test.ts +83 -0
  218. package/src/sources/sec-edgar.ts +231 -0
  219. package/src/sources/yahoo-finance.test.ts +51 -0
  220. package/src/sources/yahoo-finance.ts +49 -1
  221. package/src/state/app/context/index.test.tsx +5 -8
  222. package/src/state/app/input-capture.ts +12 -0
  223. package/src/types/broker.ts +1 -0
  224. package/src/types/config.ts +10 -0
  225. package/src/types/plugin.ts +28 -0
  226. package/src/types/trading.ts +2 -0
  227. package/src/ui/host.tsx +16 -0
  228. package/src/ui/index.tsx +2 -0
  229. package/src/utils/cli-output.ts +6 -0
  230. package/src/utils/financial-statements.ts +23 -0
  231. package/src/utils/search-focus-navigation.ts +24 -0
  232. package/src/version.ts +1 -1
  233. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -1,4 +1,4 @@
1
- import { useCallback, type RefObject } from "react";
1
+ import { memo, useCallback, useMemo, type RefObject } from "react";
2
2
  import { Box, ScrollBox, Text, TextAttributes, useRendererHost, type ScrollBoxRenderable } from "../../../ui";
3
3
  import { Spinner } from "../../../components";
4
4
  import { RemoteImage } from "../../../components/ui";
@@ -38,6 +38,25 @@ function wrappedTextProps(width: number) {
38
38
  } as const;
39
39
  }
40
40
 
41
+ const ARTICLE_IMAGE_MAX_WIDTH = 152;
42
+ const ARTICLE_IMAGE_MAX_HEIGHT = 26;
43
+ const ARTICLE_IMAGE_MIN_HEIGHT = 6;
44
+ const ARTICLE_IMAGE_LEGACY_HEIGHT_CAP = 14;
45
+
46
+ export function resolveArticleImageSize(lineWidth: number) {
47
+ const safeLineWidth = Math.max(1, Math.floor(lineWidth));
48
+ const width = Math.min(safeLineWidth, ARTICLE_IMAGE_MAX_WIDTH);
49
+ const legacyHeight = Math.min(ARTICLE_IMAGE_LEGACY_HEIGHT_CAP, Math.floor(width * 0.32));
50
+ const expandedHeight = Math.floor(width * 0.18);
51
+ return {
52
+ width,
53
+ height: Math.max(ARTICLE_IMAGE_MIN_HEIGHT, Math.min(
54
+ ARTICLE_IMAGE_MAX_HEIGHT,
55
+ Math.max(legacyHeight, expandedHeight),
56
+ )),
57
+ };
58
+ }
59
+
41
60
  function normalizedTwitterUsername(username: string | null | undefined): string | null {
42
61
  const normalized = username?.trim().replace(/^@/, "") ?? "";
43
62
  return /^[A-Za-z0-9_]{1,15}$/.test(normalized) ? normalized : null;
@@ -251,7 +270,7 @@ function ArticleBlockView({
251
270
  }
252
271
  }
253
272
 
254
- function ArticleRichContent({
273
+ const ArticleRichContent = memo(function ArticleRichContent({
255
274
  blocks,
256
275
  fallbackText,
257
276
  fallbackImageUrls,
@@ -269,14 +288,20 @@ function ArticleRichContent({
269
288
  imageHeight: number;
270
289
  }) {
271
290
  const rendererHost = useRendererHost();
272
- const resolvedBlocks = blocks.length > 0
273
- ? blocks
274
- : [
275
- ...(fallbackText ? [{ type: "paragraph" as const, text: fallbackText }] : []),
276
- ...fallbackImageUrls.map((url): SubstackContentBlock => ({ type: "image", url, alt: articleTitle })),
277
- ];
278
- const tickerText = resolvedBlocks.map(articleBlockText).filter(Boolean).join("\n");
279
- const { catalog, openTicker } = useInlineTickers([tickerText], { liveQuotes: false });
291
+ const resolvedBlocks = useMemo(() => (
292
+ blocks.length > 0
293
+ ? blocks
294
+ : [
295
+ ...(fallbackText ? [{ type: "paragraph" as const, text: fallbackText }] : []),
296
+ ...fallbackImageUrls.map((url): SubstackContentBlock => ({ type: "image", url, alt: articleTitle })),
297
+ ]
298
+ ), [articleTitle, blocks, fallbackImageUrls, fallbackText]);
299
+ const tickerText = useMemo(
300
+ () => resolvedBlocks.map(articleBlockText).filter(Boolean).join("\n"),
301
+ [resolvedBlocks],
302
+ );
303
+ const tickerTexts = useMemo(() => [tickerText], [tickerText]);
304
+ const { catalog, openTicker } = useInlineTickers(tickerTexts, { liveQuotes: false });
280
305
  const openLink = useCallback((url: string) => {
281
306
  void rendererHost.openExternal(url);
282
307
  }, [rendererHost]);
@@ -307,9 +332,9 @@ function ArticleRichContent({
307
332
  ))}
308
333
  </Box>
309
334
  );
310
- }
335
+ });
311
336
 
312
- export function ArticleDetail({
337
+ export const ArticleDetail = memo(function ArticleDetail({
313
338
  article,
314
339
  detail,
315
340
  width,
@@ -331,8 +356,7 @@ export function ArticleDetail({
331
356
  const blocks = resolved?.contentBlocks ?? [];
332
357
  const fallbackImageUrls = resolved?.imageUrls.length ? resolved.imageUrls : article.imageUrls;
333
358
  const lineWidth = Math.max(1, width - 2);
334
- const imageWidth = Math.min(lineWidth, 86);
335
- const imageHeight = Math.max(6, Math.min(14, Math.floor(imageWidth * 0.32)));
359
+ const { width: imageWidth, height: imageHeight } = resolveArticleImageSize(lineWidth);
336
360
 
337
361
  return (
338
362
  <ScrollBox ref={scrollRef} scrollY focusable={false} flexGrow={1} paddingX={1}>
@@ -356,4 +380,4 @@ export function ArticleDetail({
356
380
  </Box>
357
381
  </ScrollBox>
358
382
  );
359
- }
383
+ });
@@ -24,6 +24,7 @@ export function SubstackArticleStack({
24
24
  detailOpen,
25
25
  selectedArticle,
26
26
  selectedArticleId,
27
+ readArticleIds,
27
28
  detailContent,
28
29
  activePublication,
29
30
  activeFeedState,
@@ -45,6 +46,7 @@ export function SubstackArticleStack({
45
46
  detailOpen: boolean;
46
47
  selectedArticle: SubstackArticleSummary | null;
47
48
  selectedArticleId: string | null;
49
+ readArticleIds: ReadonlySet<string>;
48
50
  detailContent: ReactNode;
49
51
  activePublication: SubstackPublication | null;
50
52
  activeFeedState: ActiveFeedState;
@@ -78,7 +80,9 @@ export function SubstackArticleStack({
78
80
  return {
79
81
  text: article.title,
80
82
  color: selectedColor ?? colors.text,
81
- attributes: TextAttributes.BOLD,
83
+ attributes: readArticleIds.has(article.id)
84
+ ? TextAttributes.NONE
85
+ : TextAttributes.BOLD,
82
86
  };
83
87
  case "read":
84
88
  return {
@@ -86,7 +90,7 @@ export function SubstackArticleStack({
86
90
  color: selectedColor ?? colors.textDim,
87
91
  };
88
92
  }
89
- }, []);
93
+ }, [readArticleIds]);
90
94
 
91
95
  const bodyAfter = activePublication && sortedRows.length > 0 && (activeFeedState.loading || activeFeedState.loadingMore) ? (
92
96
  <Box height={1} paddingX={1}>
@@ -1,3 +1,4 @@
1
+ import { memo, useMemo } from "react";
1
2
  import { Box } from "../../../ui";
2
3
  import { Tabs } from "../../../components";
3
4
  import { tabIdForPublication } from "./table";
@@ -7,7 +8,7 @@ import {
7
8
  } from "./types";
8
9
  import { tabLabel } from "./pane-state";
9
10
 
10
- export function SubstackFeedTabs({
11
+ export const SubstackFeedTabs = memo(function SubstackFeedTabs({
11
12
  subscriptions,
12
13
  activeTab,
13
14
  focused,
@@ -20,16 +21,18 @@ export function SubstackFeedTabs({
20
21
  detailOpen: boolean;
21
22
  onSelect: (tabId: string) => void;
22
23
  }) {
24
+ const tabs = useMemo(() => [
25
+ { label: "Feed", value: SUBSTACK_FEED_TAB_ID },
26
+ ...subscriptions.map((publication) => ({
27
+ label: tabLabel(publication.name),
28
+ value: tabIdForPublication(publication),
29
+ })),
30
+ ], [subscriptions]);
31
+
23
32
  return (
24
33
  <Box height={1}>
25
34
  <Tabs
26
- tabs={[
27
- { label: "Feed", value: SUBSTACK_FEED_TAB_ID },
28
- ...subscriptions.map((publication) => ({
29
- label: tabLabel(publication.name),
30
- value: tabIdForPublication(publication),
31
- })),
32
- ]}
35
+ tabs={tabs}
33
36
  activeValue={activeTab}
34
37
  onSelect={onSelect}
35
38
  compact
@@ -38,4 +41,4 @@ export function SubstackFeedTabs({
38
41
  />
39
42
  </Box>
40
43
  );
41
- }
44
+ });
@@ -58,6 +58,7 @@ import {
58
58
  type LoadState,
59
59
  type PublicationFeedState,
60
60
  } from "./pane-state";
61
+ import { useSubstackReadState } from "./read-state";
61
62
 
62
63
  const PUBLICATION_LOAD_MORE_THRESHOLD_ROWS = 8;
63
64
 
@@ -74,6 +75,7 @@ export function SubstackPane({ focused, width, height }: PaneProps) {
74
75
  columnId: "published",
75
76
  direction: "desc",
76
77
  });
78
+ const { readArticleIds, markArticleRead } = useSubstackReadState();
77
79
  const homeFetchGenRef = useRef(0);
78
80
  const publicationFetchGenRef = useRef<Record<string, number>>({});
79
81
  const detailFetchGenRef = useRef(0);
@@ -206,12 +208,12 @@ export function SubstackPane({ focused, width, height }: PaneProps) {
206
208
  loadPublication(activePublication, false);
207
209
  }, [activePublication, activePublicationTabId, loadPublication, publicationFeeds]);
208
210
 
209
- const activeFeedState = activeFeedStateFromSources({
211
+ const activeFeedState = useMemo(() => activeFeedStateFromSources({
210
212
  activePublication,
211
213
  activePublicationTabId,
212
214
  publicationFeeds,
213
215
  home,
214
- });
216
+ }), [activePublication, activePublicationTabId, home, publicationFeeds]);
215
217
 
216
218
  const loadMoreActivePublicationRows = useCallback(() => {
217
219
  if (!activePublication || !activePublicationTabId || detailOpen) return;
@@ -335,8 +337,9 @@ export function SubstackPane({ focused, width, height }: PaneProps) {
335
337
 
336
338
  const openSelectedArticle = useCallback(() => {
337
339
  if (!selectedArticle?.url) return;
340
+ markArticleRead(selectedArticle.id);
338
341
  void rendererHost.openExternal(selectedArticle.url);
339
- }, [rendererHost, selectedArticle]);
342
+ }, [markArticleRead, rendererHost, selectedArticle]);
340
343
 
341
344
  const handleLogin = useCallback((nextAuth: SubstackAuthState) => {
342
345
  setAuth(nextAuth);
@@ -477,7 +480,9 @@ export function SubstackPane({ focused, width, height }: PaneProps) {
477
480
  selectedArticle={selectedArticle}
478
481
  detailContent={detailContent}
479
482
  selectedArticleId={selectedArticleId}
483
+ readArticleIds={readArticleIds}
480
484
  onActivate={(article) => {
485
+ markArticleRead(article.id);
481
486
  setSelectedArticleId(article.id, { immediate: true });
482
487
  setDetailOpen(true);
483
488
  }}
@@ -0,0 +1,55 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { usePluginState } from "../../runtime";
3
+ import {
4
+ DEFAULT_MAX_READ_IDS,
5
+ markReadId,
6
+ normalizeReadIds,
7
+ sameStringArray,
8
+ } from "../shared/read-state";
9
+
10
+ export interface SubstackReadState {
11
+ articleIds: string[];
12
+ }
13
+
14
+ const SUBSTACK_READ_STATE_SCHEMA_VERSION = 1;
15
+ const READ_STATE_KEY = "read-articles";
16
+ const EMPTY_READ_STATE: SubstackReadState = { articleIds: [] };
17
+
18
+ export const MAX_READ_SUBSTACK_ARTICLE_IDS = DEFAULT_MAX_READ_IDS;
19
+
20
+ export function normalizeSubstackReadState(state: SubstackReadState): SubstackReadState {
21
+ return {
22
+ articleIds: normalizeReadIds(state.articleIds, MAX_READ_SUBSTACK_ARTICLE_IDS),
23
+ };
24
+ }
25
+
26
+ export function markSubstackArticleRead(
27
+ state: SubstackReadState,
28
+ articleId: string,
29
+ ): SubstackReadState {
30
+ const current = normalizeSubstackReadState(state).articleIds;
31
+ const articleIds = markReadId(current, articleId, MAX_READ_SUBSTACK_ARTICLE_IDS);
32
+
33
+ return sameStringArray(articleIds, state.articleIds) ? state : { articleIds };
34
+ }
35
+
36
+ export function useSubstackReadState() {
37
+ const [readState, setReadState] = usePluginState<SubstackReadState>(
38
+ READ_STATE_KEY,
39
+ EMPTY_READ_STATE,
40
+ { schemaVersion: SUBSTACK_READ_STATE_SCHEMA_VERSION },
41
+ );
42
+ const normalizedReadState = useMemo(
43
+ () => normalizeSubstackReadState(readState),
44
+ [readState],
45
+ );
46
+ const readArticleIds = useMemo(
47
+ () => new Set(normalizedReadState.articleIds),
48
+ [normalizedReadState],
49
+ );
50
+ const markArticleRead = useCallback((articleId: string) => {
51
+ setReadState((current) => markSubstackArticleRead(current, articleId));
52
+ }, [setReadState]);
53
+
54
+ return { readArticleIds, markArticleRead };
55
+ }
@@ -224,6 +224,32 @@ describe("ThirteenFPane", () => {
224
224
  ).toBeUndefined();
225
225
  });
226
226
 
227
+ test("moves from the first fund row to search with Up", async () => {
228
+ setHttpFetchTransport(async (url) => {
229
+ const path = new URL(String(url)).pathname;
230
+ if (path.endsWith("/topfunds")) {
231
+ return json([
232
+ { cik: "1", name: "Alpha Capital", period_of_report: "2026-03-31", pnl: null },
233
+ ]);
234
+ }
235
+ return json([]);
236
+ });
237
+
238
+ await act(async () => {
239
+ testSetup = await testRender(<Harness />, { width: 100, height: 20 });
240
+ });
241
+ await renderFrames();
242
+
243
+ await emitKeypress({ name: "up", sequence: "\u001B[A" });
244
+ await act(async () => {
245
+ await testSetup!.mockInput.typeText("BRK");
246
+ await testSetup!.renderOnce();
247
+ });
248
+ await renderFrames(2);
249
+
250
+ expect(testSetup!.captureCharFrame()).toContain("BRK");
251
+ });
252
+
227
253
  test("rapid keyboard navigation activates the current rendered row", async () => {
228
254
  setHttpFetchTransport(async (url) => {
229
255
  const path = new URL(String(url)).pathname;
@@ -15,6 +15,7 @@ import {
15
15
  Tabs,
16
16
  usePaneFooter,
17
17
  type DataTableKeyEvent,
18
+ type DataTableRootKeyContext,
18
19
  } from "../../../components";
19
20
  import { useShortcut } from "../../../react/input";
20
21
  import { usePaneSettingValue } from "../../../state/app/context";
@@ -23,6 +24,7 @@ import { TICKER_RESEARCH_PANE_ID } from "../../../types/config";
23
24
  import type { PaneProps } from "../../../types/plugin";
24
25
  import { isDetailBackNavigationKey } from "../../../utils/back-navigation";
25
26
  import { isPlainKey } from "../../../utils/keyboard";
27
+ import { isPlainArrowUp, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation";
26
28
  import { truncateWithEllipsis } from "../../../utils/text-wrap";
27
29
  import { usePluginPaneState, usePluginTickerActions } from "../../runtime";
28
30
  import { loadBrowserRows, loadFilingPositions, loadFundDetail } from "./data";
@@ -303,6 +305,7 @@ export function ThirteenFPane({ focused, width, height }: PaneProps) {
303
305
  normalizeValue={trimSearchValue}
304
306
  onFocus={focusSearch}
305
307
  onBlur={blurSearch}
308
+ onNavigateDown={blurSearch}
306
309
  onQueryChange={updateQuery}
307
310
  />
308
311
  </Box>
@@ -312,7 +315,15 @@ export function ThirteenFPane({ focused, width, height }: PaneProps) {
312
315
  ? "Loading 13F funds..."
313
316
  : error ?? warning ?? "No 13F funds found.";
314
317
 
315
- const handleRootKeyDown = useCallback((event: DataTableKeyEvent) => {
318
+ const handleRootKeyDown = useCallback((
319
+ event: DataTableKeyEvent,
320
+ context: DataTableRootKeyContext,
321
+ ) => {
322
+ if (context.selectedIndex <= 0 && isPlainArrowUp(event)) {
323
+ stopSearchFocusNavigation(event);
324
+ focusSearch();
325
+ return true;
326
+ }
316
327
  if (event.name === "r") {
317
328
  event.preventDefault?.();
318
329
  event.stopPropagation?.();
@@ -1,23 +1,25 @@
1
- import { useCallback, useMemo } from "react";
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { Box, TextAttributes } from "../../../../../ui";
3
3
  import {
4
4
  DataTableView,
5
+ Tabs,
5
6
  usePaneFooter,
6
7
  type DataTableCell,
7
8
  type DataTableKeyEvent,
8
9
  } from "../../../../../components";
10
+ import { useShortcut } from "../../../../../react/input";
11
+ import type { BarChartHover } from "../../../../../components/chart/bar-chart-renderer";
9
12
  import { StaticBarChartSurface } from "../../../../../components/chart/static/bar-chart-surface";
10
13
  import { colors, priceColor } from "../../../../../theme/colors";
11
14
  import { usePluginPaneState } from "../../../../runtime";
12
15
  import { loadingErrorFooterInfo, refreshFooterHint, useClampSelectedIndex } from "../../../shared/table-pane";
13
16
  import {
17
+ allMetricDefs,
14
18
  buildFundamentalColumns,
15
19
  buildGraphBarSeries,
16
- defaultMetric,
17
20
  formatMaybePercent,
18
- isMetricForKind,
19
21
  metricDef,
20
- nextMetric,
22
+ metricKind,
21
23
  } from "./model";
22
24
  import type {
23
25
  FundamentalColumn,
@@ -41,6 +43,7 @@ export function FundamentalGraphContent({
41
43
  setMetric,
42
44
  chartKind,
43
45
  setChartKind,
46
+ onCapture,
44
47
  }: {
45
48
  focused: boolean;
46
49
  width: number;
@@ -55,39 +58,106 @@ export function FundamentalGraphContent({
55
58
  setMetric: (updater: (current: GraphMetricKey) => GraphMetricKey) => void;
56
59
  chartKind: GraphKind;
57
60
  setChartKind: (updater: (current: GraphKind) => GraphKind) => void;
61
+ onCapture?: (capturing: boolean) => void;
58
62
  }) {
59
63
  const [selectedIdx, setSelectedIdx] = usePluginPaneState<number>("selectedIdx", 0);
64
+ const [hoveredBar, setHoveredBar] = useState<BarChartHover | null>(null);
65
+ const [metricTabsFocused, setMetricTabsFocused] = useState(false);
60
66
  const definition = metricDef(chartKind, metric);
67
+ const metricOptions = useMemo(() => allMetricDefs(), []);
68
+ const metricTabs = useMemo(() => metricOptions.map(({ definition }) => ({
69
+ label: definition.label,
70
+ value: definition.key,
71
+ })), [metricOptions]);
61
72
  const multiSymbol = new Set(rows.map((row) => row.symbol)).size > 1;
62
73
  const columns = useMemo(() => buildFundamentalColumns(width, multiSymbol), [multiSymbol, width]);
63
- const boundedSelectedIdx = rows.length > 0 ? Math.min(selectedIdx, rows.length - 1) : -1;
64
- const chartHeight = height >= 14 ? Math.min(12, Math.max(7, Math.floor(height * 0.52))) : Math.max(4, Math.floor(height / 2));
65
- const tableHeight = Math.max(4, height - chartHeight);
74
+ const tableRows = useMemo(() => [...rows].sort((left, right) => (
75
+ right.date.localeCompare(left.date) || left.symbol.localeCompare(right.symbol)
76
+ )), [rows]);
77
+ const boundedSelectedIdx = tableRows.length > 0 ? Math.min(selectedIdx, tableRows.length - 1) : -1;
78
+ const minTableHeight = tableRows.length > 0 ? 4 : 3;
79
+ const desiredChartHeight = height >= 14 ? Math.min(16, Math.max(9, Math.floor(height * 0.58))) : Math.max(4, Math.floor(height / 2));
80
+ const chartHeight = Math.max(1, Math.min(desiredChartHeight, Math.max(1, height - minTableHeight)));
81
+ const tableHeight = Math.max(0, height - chartHeight);
66
82
  const chartSeries = useMemo(() => buildGraphBarSeries(rows), [rows]);
67
- const cycleMetric = useCallback(() => setMetric((current) => nextMetric(chartKind, current)), [chartKind, setMetric]);
68
- const togglePeriod = useCallback(() => setPeriod((current) => current === "annual" ? "quarterly" : "annual"), [setPeriod]);
69
- const toggleGraphKind = useCallback(() => {
70
- const nextKind = chartKind === "fundamental" ? "valuation" : "fundamental";
83
+ const selectMetric = useCallback((value: string) => {
84
+ const nextMetric = value as GraphMetricKey;
85
+ const nextKind = metricKind(nextMetric);
86
+ if (!nextKind) return;
87
+ setHoveredBar(null);
71
88
  setChartKind(() => nextKind);
72
- setMetric((current) => isMetricForKind(nextKind, current) ? current : defaultMetric(nextKind));
73
- }, [chartKind, setChartKind, setMetric]);
89
+ setMetric(() => nextMetric);
90
+ }, [setChartKind, setMetric]);
91
+ const focusMetricTabs = useCallback(() => {
92
+ if (metricTabsFocused) return;
93
+ setMetricTabsFocused(true);
94
+ onCapture?.(true);
95
+ }, [metricTabsFocused, onCapture]);
96
+ const releaseMetricTabs = useCallback(() => {
97
+ if (!metricTabsFocused) return;
98
+ setMetricTabsFocused(false);
99
+ onCapture?.(false);
100
+ }, [metricTabsFocused, onCapture]);
101
+ const selectMetricFromTabs = useCallback((value: string) => {
102
+ focusMetricTabs();
103
+ selectMetric(value);
104
+ }, [focusMetricTabs, selectMetric]);
105
+ const togglePeriod = useCallback(() => setPeriod((current) => current === "annual" ? "quarterly" : "annual"), [setPeriod]);
106
+ const hoveredValue = useMemo(() => {
107
+ if (!hoveredBar) return null;
108
+ const label = chartSeries.length > 1 ? `${hoveredBar.category} ${hoveredBar.seriesLabel}` : hoveredBar.category;
109
+ return `${label} ${definition.format(hoveredBar.value)}`;
110
+ }, [chartSeries.length, definition, hoveredBar]);
111
+
112
+ useClampSelectedIndex(tableRows.length, selectedIdx, setSelectedIdx);
113
+
114
+ useEffect(() => {
115
+ if (!focused && metricTabsFocused) {
116
+ setMetricTabsFocused(false);
117
+ onCapture?.(false);
118
+ }
119
+ }, [focused, metricTabsFocused, onCapture]);
120
+
121
+ useEffect(() => () => {
122
+ onCapture?.(false);
123
+ }, [onCapture]);
74
124
 
75
- useClampSelectedIndex(rows.length, selectedIdx, setSelectedIdx);
125
+ useShortcut((event) => {
126
+ if (event.defaultPrevented || event.propagationStopped || event.targetEditable) return;
127
+ if (event.ctrl || event.meta || event.alt || event.shift) return;
128
+
129
+ const isEnter = event.name === "enter" || event.name === "return";
130
+ const isEscape = event.name === "escape" || event.name === "esc";
131
+ if (isEnter) {
132
+ event.preventDefault();
133
+ event.stopPropagation();
134
+ focusMetricTabs();
135
+ return;
136
+ }
137
+ if (isEscape && metricTabsFocused) {
138
+ event.preventDefault();
139
+ event.stopPropagation();
140
+ releaseMetricTabs();
141
+ }
142
+ }, { enabled: focused, phase: "before" });
76
143
 
77
144
  const handleKeyDown = useCallback((event: DataTableKeyEvent) => {
78
- if (event.name === "r") {
145
+ const isEnter = event.name === "enter" || event.name === "return";
146
+ if (isEnter) {
79
147
  event.preventDefault?.();
80
- reload();
148
+ event.stopPropagation?.();
149
+ focusMetricTabs();
81
150
  return true;
82
151
  }
83
- if (event.name === "m") {
152
+ if ((event.name === "escape" || event.name === "esc") && metricTabsFocused) {
84
153
  event.preventDefault?.();
85
- cycleMetric();
154
+ event.stopPropagation?.();
155
+ releaseMetricTabs();
86
156
  return true;
87
157
  }
88
- if (event.name === "g") {
158
+ if (event.name === "r") {
89
159
  event.preventDefault?.();
90
- toggleGraphKind();
160
+ reload();
91
161
  return true;
92
162
  }
93
163
  if (event.name === "p") {
@@ -96,7 +166,7 @@ export function FundamentalGraphContent({
96
166
  return true;
97
167
  }
98
168
  return false;
99
- }, [cycleMetric, reload, toggleGraphKind, togglePeriod]);
169
+ }, [focusMetricTabs, metricTabsFocused, releaseMetricTabs, reload, togglePeriod]);
100
170
 
101
171
  const renderCell = useCallback((
102
172
  row: FundamentalGraphRow,
@@ -111,7 +181,11 @@ export function FundamentalGraphContent({
111
181
  case "date":
112
182
  return { text: row.date, color: selectedColor ?? colors.textDim };
113
183
  case "value":
114
- return { text: definition.format(row.value), color: selectedColor ?? colors.textBright, attributes: TextAttributes.BOLD };
184
+ return {
185
+ text: definition.format(row.value),
186
+ color: row.value < 0 ? colors.negative : selectedColor ?? colors.textBright,
187
+ attributes: TextAttributes.BOLD,
188
+ };
115
189
  case "growth":
116
190
  return { text: formatMaybePercent(row.growth), color: selectedColor ?? priceColor(row.growth ?? 0) };
117
191
  }
@@ -119,41 +193,68 @@ export function FundamentalGraphContent({
119
193
 
120
194
  usePaneFooter("fundamental-graph", () => ({
121
195
  info: [
122
- { id: "metric", parts: [{ text: definition.label, tone: "muted" as const }] },
123
- { id: "kind", parts: [{ text: chartKind === "valuation" ? "valuation" : "fundamental", tone: "muted" as const }] },
124
- { id: "period", parts: [{ text: period, tone: "muted" as const }] },
196
+ { id: "period", parts: [{ text: "period", tone: "label" as const }, { text: period, tone: "value" as const }] },
197
+ ...(hoveredValue ? [{ id: "hover", parts: [{ text: hoveredValue, tone: "value" as const }] }] : []),
125
198
  ...loadingErrorFooterInfo(loading, error),
126
199
  ],
127
200
  hints: [
128
- { id: "metric", key: "m", label: "etric", onPress: cycleMetric },
129
- { id: "kind", key: "g", label: "raph", onPress: toggleGraphKind },
130
201
  { id: "period", key: "p", label: "eriod", onPress: togglePeriod },
131
202
  refreshFooterHint(reload),
132
203
  ],
133
- }), [chartKind, cycleMetric, definition.label, error, loading, period, reload, toggleGraphKind, togglePeriod]);
204
+ }), [error, hoveredValue, loading, period, reload, togglePeriod]);
205
+
206
+ const metricHeader = (
207
+ <Tabs
208
+ tabs={metricTabs}
209
+ activeValue={metric}
210
+ onSelect={selectMetricFromTabs}
211
+ compact
212
+ focused={focused && metricTabsFocused}
213
+ keyboardNavigation={metricTabsFocused}
214
+ scrollable
215
+ variant="underline"
216
+ />
217
+ );
134
218
 
135
219
  return (
136
- <Box flexDirection="column" width={width} height={height} overflow="hidden">
137
- <StaticBarChartSurface width={width} height={chartHeight} series={chartSeries} />
138
- <DataTableView<FundamentalGraphRow, FundamentalColumn>
139
- focused={focused}
140
- selection={{
141
- kind: "index",
142
- selectedIndex: boundedSelectedIdx,
143
- onChange: (index) => setSelectedIdx(index),
144
- }}
145
- onRootKeyDown={handleKeyDown}
146
- rootWidth={width}
147
- rootHeight={tableHeight}
148
- columns={columns}
149
- items={rows}
150
- sortColumnId={null}
151
- sortDirection="asc"
152
- onHeaderClick={() => {}}
153
- getItemKey={(row) => row.key}
154
- renderCell={renderCell}
155
- emptyStateTitle={loading ? "Loading fundamentals..." : "No graph data"}
220
+ <Box
221
+ flexDirection="column"
222
+ width={width}
223
+ height={height}
224
+ overflow="hidden"
225
+ onMouseDown={focusMetricTabs}
226
+ >
227
+ <StaticBarChartSurface
228
+ width={width}
229
+ height={chartHeight}
230
+ series={chartSeries}
231
+ header={metricHeader}
232
+ formatValue={definition.format}
233
+ onHoverChange={setHoveredBar}
234
+ onMouseDown={focusMetricTabs}
156
235
  />
236
+ {tableHeight > 0 ? (
237
+ <DataTableView<FundamentalGraphRow, FundamentalColumn>
238
+ focused={focused}
239
+ selection={{
240
+ kind: "index",
241
+ selectedIndex: boundedSelectedIdx,
242
+ onChange: (index) => setSelectedIdx(index),
243
+ }}
244
+ onRootKeyDown={handleKeyDown}
245
+ rootWidth={width}
246
+ rootHeight={tableHeight}
247
+ columns={columns}
248
+ items={tableRows}
249
+ sortColumnId="date"
250
+ sortDirection="desc"
251
+ onHeaderClick={() => {}}
252
+ onTableMouseDown={focusMetricTabs}
253
+ getItemKey={(row) => row.key}
254
+ renderCell={renderCell}
255
+ emptyStateTitle={loading ? "Loading fundamentals..." : "No graph data"}
256
+ />
257
+ ) : null}
157
258
  </Box>
158
259
  );
159
260
  }