gloomberb 0.9.2 → 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 (111) 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/interactions.ts +7 -1
  29. package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
  30. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  31. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  32. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  33. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  34. package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
  35. package/src/components/chart/core/pane-settings.test.ts +27 -0
  36. package/src/components/chart/core/pane-settings.ts +60 -1
  37. package/src/components/chart/core/pointer.ts +2 -0
  38. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  39. package/src/components/chart/stock-chart/settings.ts +7 -3
  40. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  41. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  42. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  43. package/src/components/data-table/view.tsx +13 -2
  44. package/src/components/feed-data-table/stack-view.tsx +9 -1
  45. package/src/components/index.ts +1 -1
  46. package/src/components/input-search-bar.test.tsx +103 -0
  47. package/src/components/input-search-bar.tsx +16 -0
  48. package/src/components/layout/shell/active-drag.ts +5 -1
  49. package/src/components/layout/shell/drag/runtime.ts +21 -3
  50. package/src/components/layout/shell/fullscreen.ts +10 -0
  51. package/src/components/layout/shell/index.test.tsx +395 -2
  52. package/src/components/layout/shell/index.tsx +128 -2
  53. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  54. package/src/components/layout/shell/pane/layers.tsx +26 -14
  55. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  56. package/src/components/layout/shell/shortcuts.ts +4 -0
  57. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  58. package/src/components/layout/shell/window-mode/index.ts +11 -3
  59. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  60. package/src/components/layout/status-bar.test.tsx +105 -0
  61. package/src/components/layout/status-bar.tsx +32 -6
  62. package/src/components/layout/transient-layout.tsx +57 -0
  63. package/src/components/layout/window-edit/mode.test.ts +76 -5
  64. package/src/components/layout/window-edit/mode.ts +47 -2
  65. package/src/components/layout/window-edit/presentation.ts +2 -1
  66. package/src/components/price-performance.tsx +9 -1
  67. package/src/core/app-services.ts +7 -4
  68. package/src/data/config/store/index.ts +1 -2
  69. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  70. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  71. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  72. package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
  73. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  74. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  75. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  76. package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
  77. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  78. package/src/plugins/builtin/shared/read-state.ts +43 -0
  79. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  80. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  81. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  82. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  83. package/src/plugins/builtin/substack/pane.tsx +8 -3
  84. package/src/plugins/builtin/substack/read-state.ts +55 -0
  85. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  86. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  87. package/src/plugins/pane-manager/docking.ts +9 -2
  88. package/src/plugins/pane-manager/queries.ts +8 -2
  89. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  90. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  91. package/src/plugins/prediction-markets/pane.test.tsx +32 -0
  92. package/src/plugins/prediction-markets/pane.tsx +21 -1
  93. package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
  94. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  95. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  96. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  97. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  98. package/src/renderers/electrobun/bun/index.ts +13 -0
  99. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  100. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  101. package/src/renderers/electrobun/view/app-services.ts +6 -4
  102. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  103. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  104. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  105. package/src/renderers/electrobun/view/ui-host.tsx +1 -1
  106. package/src/renderers/opentui/start.tsx +13 -5
  107. package/src/state/app/input-capture.ts +12 -0
  108. package/src/types/plugin.ts +28 -0
  109. package/src/utils/cli-output.ts +6 -0
  110. package/src/utils/search-focus-navigation.ts +24 -0
  111. package/src/version.ts +1 -1
@@ -6,6 +6,7 @@ import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../../
6
6
  import { PaneFooterBar, PaneFooterProvider } from "../../../components/layout/pane/footer";
7
7
  import {
8
8
  AppContext,
9
+ type AppAction,
9
10
  createInitialState,
10
11
  PaneInstanceProvider,
11
12
  } from "../../../state/app/context";
@@ -13,7 +14,7 @@ import { Box } from "../../../ui";
13
14
  import { cloneLayout, createDefaultConfig } from "../../../types/config";
14
15
  import { createTestPluginRuntime } from "../../../test-support/plugin-runtime";
15
16
  import type { DataProvider } from "../../../types/data-provider";
16
- import type { TickerFinancials } from "../../../types/financials";
17
+ import type { PricePoint, TickerFinancials } from "../../../types/financials";
17
18
  import type { PluginRegistry } from "../../registry";
18
19
  import { PluginRenderProvider, type PluginRuntimeAccess } from "../../runtime";
19
20
  import { setSharedMarketDataForTests, setSharedRegistryForTests } from "../../registry";
@@ -30,6 +31,12 @@ let root: ReturnType<typeof createRoot> | undefined;
30
31
  let sharedCoordinator: MarketDataCoordinator | null = null;
31
32
  const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
32
33
 
34
+ interface ComparisonHarnessOptions {
35
+ dispatch?: (action: AppAction) => void;
36
+ focused?: boolean;
37
+ focusedPaneId?: string | null;
38
+ }
39
+
33
40
  function makeTicker(symbol: string, currency: string): TickerRecord {
34
41
  return {
35
42
  metadata: {
@@ -90,6 +97,13 @@ function makeDatedFinancials(symbol: string, currency: string, closes: Array<[st
90
97
  };
91
98
  }
92
99
 
100
+ function makeDatedPriceHistory(closes: Array<[string, number]>): PricePoint[] {
101
+ return closes.map(([date, close]) => ({
102
+ date: new Date(`${date}T00:00:00Z`),
103
+ close,
104
+ }));
105
+ }
106
+
93
107
  function createProvider(historyBySymbol: Record<string, number[]>, currencyBySymbol: Record<string, string>): DataProvider {
94
108
  return {
95
109
  id: "test-provider",
@@ -139,6 +153,30 @@ function createDatedProvider(financialsBySymbol: Record<string, TickerFinancials
139
153
  return provider;
140
154
  }
141
155
 
156
+ function createRangeAwareDatedProvider({
157
+ fullHistoryBySymbol,
158
+ shortHistoryBySymbol,
159
+ }: {
160
+ fullHistoryBySymbol: Record<string, Array<[string, number]>>;
161
+ shortHistoryBySymbol: Record<string, Array<[string, number]>>;
162
+ }): DataProvider {
163
+ const financialsBySymbol = Object.fromEntries(
164
+ Object.entries(shortHistoryBySymbol).map(([symbol, history]) => [
165
+ symbol,
166
+ makeDatedFinancials(symbol, "USD", history),
167
+ ]),
168
+ );
169
+ const provider = createDatedProvider(financialsBySymbol);
170
+ const resolveHistory = (symbol: string, range: string) => makeDatedPriceHistory(
171
+ range === "5Y"
172
+ ? fullHistoryBySymbol[symbol] ?? shortHistoryBySymbol[symbol] ?? []
173
+ : shortHistoryBySymbol[symbol] ?? [],
174
+ );
175
+ provider.getPriceHistory = async (symbol, _exchange, range) => resolveHistory(symbol, range);
176
+ provider.getPriceHistoryForResolution = async (symbol, _exchange, range) => resolveHistory(symbol, range);
177
+ return provider;
178
+ }
179
+
142
180
  function createRegistrySpy(spy: { selected: string[]; focused: string[] }): PluginRegistry {
143
181
  return {
144
182
  selectTicker: (symbol: string) => { spy.selected.push(symbol); },
@@ -171,7 +209,13 @@ function createComparisonHarness(
171
209
  tickers: TickerRecord[],
172
210
  financials: Array<[string, TickerFinancials]>,
173
211
  runtime: PluginRuntimeAccess,
212
+ options: ComparisonHarnessOptions = {},
174
213
  ) {
214
+ const {
215
+ dispatch = () => {},
216
+ focused = true,
217
+ focusedPaneId = focused ? TEST_PANE_ID : null,
218
+ } = options;
175
219
  const config = createDefaultConfig("/tmp/gloomberb-compare");
176
220
  const layout = {
177
221
  dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
@@ -189,7 +233,7 @@ function createComparisonHarness(
189
233
  layouts: [{ name: "Default", layout: cloneLayout(layout) }],
190
234
  };
191
235
  const state = createInitialState(nextConfig);
192
- state.focusedPaneId = TEST_PANE_ID;
236
+ state.focusedPaneId = focusedPaneId;
193
237
  state.tickers = new Map(tickers.map((ticker) => [ticker.metadata.ticker, ticker]));
194
238
  state.financials = new Map(financials);
195
239
 
@@ -202,7 +246,7 @@ function createComparisonHarness(
202
246
  }) => ReactElement;
203
247
 
204
248
  return (
205
- <AppContext value={{ state, dispatch: () => {} }}>
249
+ <AppContext value={{ state, dispatch }}>
206
250
  <PaneInstanceProvider paneId={TEST_PANE_ID}>
207
251
  <PluginRenderProvider pluginId="comparison-chart" runtime={runtime}>
208
252
  <PaneFooterProvider>
@@ -211,11 +255,11 @@ function createComparisonHarness(
211
255
  <ComparisonPane
212
256
  paneId={TEST_PANE_ID}
213
257
  paneType="comparison-chart"
214
- focused
258
+ focused={focused}
215
259
  width={120}
216
260
  height={19}
217
261
  />
218
- <PaneFooterBar footer={footer} focused width={120} />
262
+ <PaneFooterBar footer={footer} focused={focused} width={120} />
219
263
  </Box>
220
264
  )}
221
265
  </PaneFooterProvider>
@@ -241,13 +285,14 @@ async function mountComparisonHarness(
241
285
  tickers: TickerRecord[],
242
286
  financials: Array<[string, TickerFinancials]>,
243
287
  spy: { selected: string[]; focused: string[]; settings?: string[] } = { selected: [], focused: [] },
288
+ options: ComparisonHarnessOptions = {},
244
289
  ) {
245
290
  actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
246
291
  testSetup = await createTestRenderer({ width: 120, height: 20 });
247
292
  root = createRoot(testSetup.renderer);
248
293
  await act(async () => {
249
294
  root!.render(
250
- createComparisonHarness(settings, tickers, financials, createRuntimeSpy(spy)),
295
+ createComparisonHarness(settings, tickers, financials, createRuntimeSpy(spy), options),
251
296
  );
252
297
  await Promise.resolve();
253
298
  await new Promise((resolve) => setTimeout(resolve, 0));
@@ -399,6 +444,85 @@ describe("comparisonChartPlugin", () => {
399
444
  expect(frame).toMatch(/MSFT\s+0\.00%/);
400
445
  });
401
446
 
447
+ test("focuses the pane when an unfocused comparison plot is clicked", async () => {
448
+ const dispatched: AppAction[] = [];
449
+ const provider = createProvider({
450
+ AAPL: [100, 102, 104],
451
+ MSFT: [200, 202, 204],
452
+ }, {
453
+ AAPL: "USD",
454
+ MSFT: "USD",
455
+ });
456
+ setSharedMarketDataForTests(provider);
457
+ sharedCoordinator = new MarketDataCoordinator(provider);
458
+ setSharedMarketDataCoordinator(sharedCoordinator);
459
+ setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
460
+
461
+ await mountComparisonHarness({
462
+ axisMode: "percent",
463
+ symbols: ["AAPL", "MSFT"],
464
+ symbolsText: "AAPL, MSFT",
465
+ }, [
466
+ makeTicker("AAPL", "USD"),
467
+ makeTicker("MSFT", "USD"),
468
+ ], [
469
+ ["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
470
+ ["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
471
+ ], { selected: [], focused: [] }, {
472
+ dispatch: (action) => { dispatched.push(action); },
473
+ focused: false,
474
+ focusedPaneId: "ticker-detail:main",
475
+ });
476
+
477
+ await flushFrames();
478
+ await act(async () => {
479
+ await testSetup!.mockMouse.click(20, 8);
480
+ await testSetup!.renderOnce();
481
+ });
482
+
483
+ expect(dispatched).toContainEqual({ type: "FOCUS_PANE", paneId: TEST_PANE_ID });
484
+ });
485
+
486
+ test("keeps fixed return horizons independent from the selected chart range", async () => {
487
+ const shortHistory = {
488
+ AAPL: [["2025-01-02", 180], ["2026-01-02", 200]],
489
+ MSFT: [["2025-01-02", 300], ["2026-01-02", 330]],
490
+ } satisfies Record<string, Array<[string, number]>>;
491
+ const fullHistory = {
492
+ AAPL: [["2021-01-02", 100], ["2023-01-02", 150], ["2025-01-02", 180], ["2026-01-02", 200]],
493
+ MSFT: [["2021-01-02", 220], ["2023-01-02", 270], ["2025-01-02", 300], ["2026-01-02", 330]],
494
+ } satisfies Record<string, Array<[string, number]>>;
495
+ const provider = createRangeAwareDatedProvider({
496
+ fullHistoryBySymbol: fullHistory,
497
+ shortHistoryBySymbol: shortHistory,
498
+ });
499
+ setSharedMarketDataForTests(provider);
500
+ sharedCoordinator = new MarketDataCoordinator(provider);
501
+ setSharedMarketDataCoordinator(sharedCoordinator);
502
+ setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
503
+
504
+ await mountComparisonHarness({
505
+ axisMode: "percent",
506
+ rangePreset: "1Y",
507
+ chartResolution: "1d",
508
+ symbols: ["AAPL", "MSFT"],
509
+ symbolsText: "AAPL, MSFT",
510
+ }, [
511
+ makeTicker("AAPL", "USD"),
512
+ makeTicker("MSFT", "USD"),
513
+ ], [
514
+ ["AAPL", makeDatedFinancials("AAPL", "USD", shortHistory.AAPL)],
515
+ ["MSFT", makeDatedFinancials("MSFT", "USD", shortHistory.MSFT)],
516
+ ]);
517
+
518
+ await flushFrames(8);
519
+
520
+ const frame = testSetup!.captureCharFrame();
521
+ expect(frame).toContain("5Y");
522
+ expect(frame).toMatch(/> AAPL\s+\+11\.11%[^\n]*\+100\.00%/);
523
+ expect(frame).toMatch(/MSFT\s+\+10\.00%[^\n]*\+50\.00%/);
524
+ });
525
+
402
526
  test("sorts comparison summary rows by one-year return", async () => {
403
527
  const rows = {
404
528
  SLOW: makeDatedFinancials("SLOW", "USD", [["2025-01-02", 100], ["2026-01-02", 105]]),
@@ -1,55 +1,32 @@
1
1
  import { useCallback, useMemo } from "react";
2
2
  import { usePluginState } from "../../../runtime";
3
+ import {
4
+ DEFAULT_MAX_READ_IDS,
5
+ markReadId,
6
+ normalizeReadIds,
7
+ sameStringArray,
8
+ } from "../../shared/read-state";
3
9
 
4
10
  export interface NewsReadState {
5
11
  articleIds: string[];
6
12
  }
7
13
 
8
14
  const NEWS_READ_STATE_SCHEMA_VERSION = 1;
9
- export const MAX_READ_ARTICLE_IDS = 2_000;
15
+ export const MAX_READ_ARTICLE_IDS = DEFAULT_MAX_READ_IDS;
10
16
 
11
17
  const READ_STATE_KEY = "read-articles";
12
18
  const EMPTY_READ_STATE: NewsReadState = { articleIds: [] };
13
19
 
14
- function normalizeArticleId(articleId: string): string {
15
- return articleId.trim();
16
- }
17
-
18
- function sameStringArray(left: string[], right: string[]): boolean {
19
- if (left.length !== right.length) return false;
20
- return left.every((value, index) => value === right[index]);
21
- }
22
-
23
20
  export function normalizeNewsReadState(state: NewsReadState): NewsReadState {
24
- const seen = new Set<string>();
25
- const articleIds: string[] = [];
26
-
27
- for (const rawId of state.articleIds) {
28
- if (typeof rawId !== "string") continue;
29
- const articleId = normalizeArticleId(rawId);
30
- if (!articleId || seen.has(articleId)) continue;
31
- seen.add(articleId);
32
- articleIds.push(articleId);
33
- if (articleIds.length >= MAX_READ_ARTICLE_IDS) break;
34
- }
35
-
36
- return { articleIds };
21
+ return { articleIds: normalizeReadIds(state.articleIds, MAX_READ_ARTICLE_IDS) };
37
22
  }
38
23
 
39
24
  export function markNewsArticleRead(
40
25
  state: NewsReadState,
41
26
  articleId: string,
42
27
  ): NewsReadState {
43
- const normalizedId = normalizeArticleId(articleId);
44
28
  const current = normalizeNewsReadState(state).articleIds;
45
- if (!normalizedId) {
46
- return sameStringArray(current, state.articleIds) ? state : { articleIds: current };
47
- }
48
-
49
- const articleIds = [
50
- normalizedId,
51
- ...current.filter((currentId) => currentId !== normalizedId),
52
- ].slice(0, MAX_READ_ARTICLE_IDS);
29
+ const articleIds = markReadId(current, articleId, MAX_READ_ARTICLE_IDS);
53
30
 
54
31
  return sameStringArray(articleIds, state.articleIds) ? state : { articleIds };
55
32
  }
@@ -1,4 +1,5 @@
1
1
  import { saveConfig } from "../../../../data/config/store";
2
+ import { countCollectionTickers } from "../../../../cli/helpers";
2
3
  import { resolveTickerForCli } from "../../../../cli/ticker-resolution";
3
4
  import { cliStyles, renderStat } from "../../../../utils/cli-output";
4
5
  import { formatMarketCostWithCurrency, formatMarketQuantity } from "../../../../market-data/market/format";
@@ -17,6 +18,28 @@ import { parseFiniteNumber, requireManualPortfolio } from "./shared";
17
18
  async function listCollections(ctx: CliCommandContext) {
18
19
  const { config, store, persistence } = await ctx.initConfigData();
19
20
  const tickers = await store.loadAllTickers();
21
+ if (ctx.cliOptions.format !== "text") {
22
+ ctx.printResult({
23
+ data: {
24
+ portfolios: config.portfolios.map((portfolio) => ({
25
+ id: portfolio.id,
26
+ name: portfolio.name,
27
+ currency: portfolio.currency,
28
+ brokerId: portfolio.brokerId ?? "",
29
+ brokerInstanceId: portfolio.brokerInstanceId ?? "",
30
+ brokerAccountId: portfolio.brokerAccountId ?? "",
31
+ tickerCount: countCollectionTickers(tickers, "portfolios", portfolio.id),
32
+ })),
33
+ watchlists: config.watchlists.map((watchlist) => ({
34
+ id: watchlist.id,
35
+ name: watchlist.name,
36
+ tickerCount: countCollectionTickers(tickers, "watchlists", watchlist.id),
37
+ })),
38
+ },
39
+ });
40
+ persistence.close();
41
+ return;
42
+ }
20
43
  console.log(renderCollectionOverview(config, tickers));
21
44
  persistence.close();
22
45
  }
@@ -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
  }}