gloomberb 0.9.1 → 0.9.3

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 (136) hide show
  1. package/README.md +23 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app.tsx +10 -7
  5. package/src/capabilities/registry.ts +1 -0
  6. package/src/capabilities/types.ts +23 -0
  7. package/src/cli/commands/api.ts +119 -0
  8. package/src/cli/commands/automation.ts +173 -0
  9. package/src/cli/commands/command-utils.ts +53 -0
  10. package/src/cli/commands/market.ts +527 -0
  11. package/src/cli/commands/overview.ts +222 -0
  12. package/src/cli/commands/search.ts +39 -0
  13. package/src/cli/commands/system.ts +553 -0
  14. package/src/cli/commands/ticker.ts +104 -12
  15. package/src/cli/context.ts +35 -2
  16. package/src/cli/entry.ts +51 -0
  17. package/src/cli/errors.ts +56 -5
  18. package/src/cli/index.ts +73 -11
  19. package/src/cli/options.test.ts +38 -0
  20. package/src/cli/options.ts +95 -0
  21. package/src/cli/pane-functions/index.ts +10 -1
  22. package/src/cli/registry.test.ts +40 -0
  23. package/src/cli/registry.ts +46 -5
  24. package/src/cli/result.test.ts +82 -0
  25. package/src/cli/result.ts +149 -0
  26. package/src/cli/types.ts +11 -0
  27. package/src/cli.test.ts +11 -11
  28. package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
  29. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  30. package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
  31. package/src/components/chart/comparison-stock-chart/legend.tsx +45 -44
  32. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  33. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  34. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  35. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  36. package/src/components/chart/comparison-stock-chart/view.tsx +56 -6
  37. package/src/components/chart/core/pane-settings.test.ts +27 -0
  38. package/src/components/chart/core/pane-settings.ts +60 -1
  39. package/src/components/chart/core/pointer.ts +2 -0
  40. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  41. package/src/components/chart/stock-chart/settings.ts +7 -3
  42. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  43. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  44. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  45. package/src/components/data-table/view.tsx +13 -2
  46. package/src/components/feed-data-table/stack-view.tsx +9 -1
  47. package/src/components/index.ts +1 -1
  48. package/src/components/input-search-bar.test.tsx +103 -0
  49. package/src/components/input-search-bar.tsx +16 -0
  50. package/src/components/layout/detached-pane-shell.tsx +5 -2
  51. package/src/components/layout/header.tsx +13 -5
  52. package/src/components/layout/shell/active-drag.ts +5 -1
  53. package/src/components/layout/shell/drag/runtime.ts +21 -3
  54. package/src/components/layout/shell/fullscreen.ts +10 -0
  55. package/src/components/layout/shell/index.test.tsx +395 -2
  56. package/src/components/layout/shell/index.tsx +128 -2
  57. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  58. package/src/components/layout/shell/pane/layers.tsx +26 -14
  59. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  60. package/src/components/layout/shell/shortcuts.ts +4 -0
  61. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  62. package/src/components/layout/shell/window-mode/index.ts +11 -3
  63. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  64. package/src/components/layout/status-bar.test.tsx +105 -0
  65. package/src/components/layout/status-bar.tsx +32 -6
  66. package/src/components/layout/transient-layout.tsx +57 -0
  67. package/src/components/layout/window-controls.tsx +19 -10
  68. package/src/components/layout/window-edit/mode.test.ts +76 -5
  69. package/src/components/layout/window-edit/mode.ts +47 -2
  70. package/src/components/layout/window-edit/presentation.ts +2 -1
  71. package/src/components/price-performance.tsx +183 -0
  72. package/src/core/app-services.ts +7 -4
  73. package/src/data/config/store/index.ts +1 -2
  74. package/src/market-data/performance.test.ts +54 -0
  75. package/src/market-data/performance.ts +109 -0
  76. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  77. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  78. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  79. package/src/plugins/builtin/comparison-chart/index.test.tsx +218 -15
  80. package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
  81. package/src/plugins/builtin/congress-trades/footer.ts +14 -8
  82. package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
  83. package/src/plugins/builtin/congress-trades/model.ts +29 -0
  84. package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
  85. package/src/plugins/builtin/congress-trades/table.tsx +10 -1
  86. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  87. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  88. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  89. package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
  90. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  91. package/src/plugins/builtin/shared/read-state.ts +43 -0
  92. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  93. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  94. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  95. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  96. package/src/plugins/builtin/substack/pane.tsx +8 -3
  97. package/src/plugins/builtin/substack/read-state.ts +55 -0
  98. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  99. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  100. package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
  101. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
  102. package/src/plugins/pane-manager/docking.ts +9 -2
  103. package/src/plugins/pane-manager/queries.ts +8 -2
  104. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  105. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  106. package/src/plugins/prediction-markets/pane.test.tsx +32 -0
  107. package/src/plugins/prediction-markets/pane.tsx +21 -1
  108. package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
  109. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  110. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  111. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  112. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  113. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +9 -2
  114. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  115. package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
  116. package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
  117. package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
  118. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
  119. package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
  120. package/src/renderers/electrobun/bun/index.ts +22 -2
  121. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  122. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  123. package/src/renderers/electrobun/shared/protocol.ts +1 -0
  124. package/src/renderers/electrobun/view/app-services.ts +6 -4
  125. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  126. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  127. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  128. package/src/renderers/electrobun/view/main.tsx +2 -1
  129. package/src/renderers/electrobun/view/styles.css +9 -8
  130. package/src/renderers/electrobun/view/ui-host.tsx +102 -77
  131. package/src/renderers/opentui/start.tsx +13 -5
  132. package/src/state/app/input-capture.ts +12 -0
  133. package/src/types/plugin.ts +28 -0
  134. package/src/utils/cli-output.ts +6 -0
  135. package/src/utils/search-focus-navigation.ts +24 -0
  136. package/src/version.ts +1 -1
@@ -129,6 +129,23 @@ async function removeTickerFromWatchlist(watchlistName: string, symbol: string,
129
129
  async function listWatchlists(ctx: CliCommandContext) {
130
130
  const { config, store, persistence } = await ctx.initConfigData();
131
131
  const tickers = await store.loadAllTickers();
132
+ const rows = config.watchlists.map((watchlist) => ({
133
+ id: watchlist.id,
134
+ name: watchlist.name,
135
+ tickerCount: countCollectionTickers(tickers, "watchlists", watchlist.id),
136
+ }));
137
+
138
+ if (ctx.cliOptions.format !== "text") {
139
+ ctx.printResult({ data: rows }, {
140
+ columns: [
141
+ { key: "name", header: "Watchlist" },
142
+ { key: "id", header: "ID" },
143
+ { key: "tickerCount", header: "Tickers", align: "right" },
144
+ ],
145
+ });
146
+ persistence.close();
147
+ return;
148
+ }
132
149
 
133
150
  console.log(renderSection("Watchlists"));
134
151
  if (config.watchlists.length === 0) {
@@ -143,10 +160,10 @@ async function listWatchlists(ctx: CliCommandContext) {
143
160
  { header: "ID" },
144
161
  { header: "Tickers", align: "right" },
145
162
  ],
146
- config.watchlists.map((watchlist) => [
163
+ rows.map((watchlist) => [
147
164
  watchlist.name,
148
165
  watchlist.id,
149
- String(countCollectionTickers(tickers, "watchlists", watchlist.id)),
166
+ String(watchlist.tickerCount),
150
167
  ]),
151
168
  ));
152
169
  persistence.close();
@@ -363,7 +363,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
363
363
  activeValue={activeCollectionId}
364
364
  onSelect={handleCollectionSelect}
365
365
  compact
366
- focused={focused}
366
+ focused={focused && !quickAddFocused}
367
367
  />
368
368
  </Box>
369
369
  </Box>
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { Box, Input, Text, type InputRenderable } from "../../../../ui";
3
3
  import { useShortcut } from "../../../../react/input";
4
4
  import { useAppDispatch, useAppSelector } from "../../../../state/app/context";
5
+ import { useAppInputCapture } from "../../../../state/app/input-capture";
5
6
  import { getSharedRegistry } from "../../../registry";
6
7
  import { usePluginAppActions } from "../../../runtime";
7
8
  import { colors, priceColor } from "../../../../theme/colors";
@@ -102,6 +103,7 @@ export function QuickAddTickerInput({
102
103
  const [inputValue, setInputValue] = useState("");
103
104
  const [validation, setValidation] = useState<QuickAddValidation>(IDLE_VALIDATION);
104
105
  const [submitting, setSubmitting] = useState(false);
106
+ useAppInputCapture(inputFocused && focused);
105
107
 
106
108
  const focusInput = useCallback(() => {
107
109
  if (!focused) return;
@@ -122,13 +124,6 @@ export function QuickAddTickerInput({
122
124
  onFocusChange?.(inputFocused && focused);
123
125
  }, [focused, inputFocused, onFocusChange]);
124
126
 
125
- useEffect(() => {
126
- if (inputFocused && focused) {
127
- dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
128
- return () => dispatch({ type: "SET_INPUT_CAPTURED", captured: false });
129
- }
130
- }, [dispatch, focused, inputFocused]);
131
-
132
127
  useEffect(() => {
133
128
  if (!focused && inputFocused) {
134
129
  blurInput();
@@ -0,0 +1,43 @@
1
+ export const DEFAULT_MAX_READ_IDS = 2_000;
2
+
3
+ function normalizeReadId(readId: unknown): string {
4
+ return typeof readId === "string" ? readId.trim() : "";
5
+ }
6
+
7
+ export function sameStringArray(left: readonly string[], right: readonly string[]): boolean {
8
+ if (left.length !== right.length) return false;
9
+ return left.every((value, index) => value === right[index]);
10
+ }
11
+
12
+ export function normalizeReadIds(
13
+ readIds: readonly unknown[] | undefined,
14
+ maxIds = DEFAULT_MAX_READ_IDS,
15
+ ): string[] {
16
+ const seen = new Set<string>();
17
+ const normalizedIds: string[] = [];
18
+
19
+ for (const rawId of readIds ?? []) {
20
+ const readId = normalizeReadId(rawId);
21
+ if (!readId || seen.has(readId)) continue;
22
+ seen.add(readId);
23
+ normalizedIds.push(readId);
24
+ if (normalizedIds.length >= maxIds) break;
25
+ }
26
+
27
+ return normalizedIds;
28
+ }
29
+
30
+ export function markReadId(
31
+ readIds: readonly unknown[] | undefined,
32
+ readId: string,
33
+ maxIds = DEFAULT_MAX_READ_IDS,
34
+ ): string[] {
35
+ const normalizedId = normalizeReadId(readId);
36
+ const current = normalizeReadIds(readIds, maxIds);
37
+ if (!normalizedId) return current;
38
+
39
+ return [
40
+ normalizedId,
41
+ ...current.filter((currentId) => currentId !== normalizedId),
42
+ ].slice(0, maxIds);
43
+ }
@@ -0,0 +1,10 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveArticleImageSize } from "./article-detail";
3
+
4
+ describe("Substack article image sizing", () => {
5
+ test("uses extra detail pane room without shrinking compact panes", () => {
6
+ expect(resolveArticleImageSize(40)).toEqual({ width: 40, height: 12 });
7
+ expect(resolveArticleImageSize(86)).toEqual({ width: 86, height: 15 });
8
+ expect(resolveArticleImageSize(220)).toEqual({ width: 152, height: 26 });
9
+ });
10
+ });
@@ -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?.();
@@ -90,20 +90,6 @@ export function buildOverviewStats({
90
90
  valueColor: priceColor(fundamentals.revenueGrowth),
91
91
  });
92
92
  }
93
- if (fundamentals?.return1Y != null) {
94
- stats.push({
95
- label: "1Y Return",
96
- value: formatPercent(fundamentals.return1Y),
97
- valueColor: priceColor(fundamentals.return1Y),
98
- });
99
- }
100
- if (fundamentals?.return3Y != null) {
101
- stats.push({
102
- label: "3Y Return",
103
- value: formatPercent(fundamentals.return3Y),
104
- valueColor: priceColor(fundamentals.return3Y),
105
- });
106
- }
107
93
  if (fundamentals?.enterpriseValue) {
108
94
  stats.push({ label: "EV", value: formatCompact(fundamentals.enterpriseValue) });
109
95
  }
@@ -15,6 +15,11 @@ import {
15
15
  import { selectEffectiveExchangeRates } from "../../../utils/exchange-rate-map";
16
16
  import { EmptyState } from "../../../components";
17
17
  import { ResolvedStockChart } from "../../../components/chart/stock-chart";
18
+ import { PriceReturnStrip } from "../../../components/price-performance";
19
+ import {
20
+ appendQuoteToPriceReturnHistory,
21
+ buildPriceReturnFields,
22
+ } from "../../../market-data/performance";
18
23
  import type { TickerFinancials } from "../../../types/financials";
19
24
  import type { TickerRecord } from "../../../types/ticker";
20
25
  import {
@@ -96,6 +101,19 @@ export function OverviewTab({
96
101
  baseCurrency,
97
102
  toBase,
98
103
  });
104
+ const performanceFields = buildPriceReturnFields(
105
+ appendQuoteToPriceReturnHistory(financials?.priceHistory ?? [], quote),
106
+ ).map((field) => {
107
+ if (field.value != null) return field;
108
+ if (field.id === "1Y" && fundamentals?.return1Y != null) {
109
+ return { ...field, value: fundamentals.return1Y };
110
+ }
111
+ if (field.id === "3Y" && fundamentals?.return3Y != null) {
112
+ return { ...field, value: fundamentals.return3Y };
113
+ }
114
+ return field;
115
+ });
116
+ const hasPerformance = performanceFields.some((field) => field.value != null);
99
117
  const positionRows = buildPositionRows({
100
118
  ticker,
101
119
  quote,
@@ -210,6 +228,13 @@ export function OverviewTab({
210
228
  />
211
229
  )}
212
230
 
231
+ {hasPerformance && (
232
+ <Box flexDirection="column">
233
+ <SectionHeader title="Price Return" />
234
+ <PriceReturnStrip fields={performanceFields} width={contentWidth} />
235
+ </Box>
236
+ )}
237
+
213
238
  {stats.length > 0 && (
214
239
  <Box flexDirection="column">
215
240
  <SectionHeader title="Fundamentals" />
@@ -6,6 +6,7 @@ import {
6
6
  findDockLeaf,
7
7
  getNodeAtPath,
8
8
  replaceNodeAtPath,
9
+ type DockGeometryOptions,
9
10
  type DockTarget,
10
11
  type LayoutBounds,
11
12
  } from "./dock-tree";
@@ -116,11 +117,17 @@ export function applyDrop(layout: LayoutConfig, draggedId: string, dropTarget: D
116
117
  return insertRelativeToLeaf(layout, draggedId, dropTarget.targetId, direction);
117
118
  }
118
119
 
119
- export function simulateDrop(layout: LayoutConfig, draggedId: string, dropTarget: DropTarget, bounds: LayoutBounds): LayoutSimulation {
120
+ export function simulateDrop(
121
+ layout: LayoutConfig,
122
+ draggedId: string,
123
+ dropTarget: DropTarget,
124
+ bounds: LayoutBounds,
125
+ options?: DockGeometryOptions,
126
+ ): LayoutSimulation {
120
127
  const nextLayout = applyDrop(layout, draggedId, dropTarget);
121
128
  return {
122
129
  layout: nextLayout,
123
- previewRect: getLeafRect(nextLayout, draggedId, bounds),
130
+ previewRect: getLeafRect(nextLayout, draggedId, bounds, options),
124
131
  };
125
132
  }
126
133
 
@@ -10,6 +10,7 @@ import {
10
10
  countColumnsFromGeometry,
11
11
  findDockLeaf,
12
12
  getDockLeafLayouts,
13
+ type DockGeometryOptions,
13
14
  type LayoutBounds,
14
15
  } from "./dock-tree";
15
16
 
@@ -20,8 +21,13 @@ export interface ResolvedPane {
20
21
  path?: Array<0 | 1>;
21
22
  }
22
23
 
23
- export function getLeafRect(layout: LayoutConfig, instanceId: string, bounds: LayoutBounds): LayoutBounds | null {
24
- return getDockLeafLayouts(layout, bounds).find((entry) => entry.instanceId === instanceId)?.rect ?? null;
24
+ export function getLeafRect(
25
+ layout: LayoutConfig,
26
+ instanceId: string,
27
+ bounds: LayoutBounds,
28
+ options?: DockGeometryOptions,
29
+ ): LayoutBounds | null {
30
+ return getDockLeafLayouts(layout, bounds, options).find((entry) => entry.instanceId === instanceId)?.rect ?? null;
25
31
  }
26
32
 
27
33
  export function resolveDocked(
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { type InputRenderable, type ScrollBoxRenderable } from "../../../ui";
3
3
  import { useViewport } from "../../../react/input";
4
+ import { useAppInputCapture } from "../../../state/app/input-capture";
4
5
  import { usePaneInstance } from "../../../state/app/context";
5
6
  import {
6
7
  useDebouncedPluginPaneState,
@@ -101,6 +102,7 @@ export function usePredictionMarketsController({
101
102
  const [searchFocused, setSearchFocused] = useState(false);
102
103
  const [initialParamsApplied, setInitialParamsApplied] = useState(false);
103
104
  const appViewport = useViewport();
105
+ useAppInputCapture(focused && searchFocused);
104
106
 
105
107
  const scrollRef = useRef<ScrollBoxRenderable>(null);
106
108
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);