gloomberb 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +359 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +1 -1
  37. package/src/components/layout/pane-header.tsx +26 -2
  38. package/src/components/layout/shell.tsx +62 -50
  39. package/src/components/layout/status-bar.test.tsx +66 -0
  40. package/src/components/layout/status-bar.tsx +26 -5
  41. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  42. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  43. package/src/components/ticker-list-table.test.tsx +83 -0
  44. package/src/components/ticker-list-table.tsx +174 -97
  45. package/src/components/ui/data-table.tsx +27 -2
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/ui/page-stack-view.tsx +60 -0
  48. package/src/components/use-double-click-activation.ts +31 -0
  49. package/src/index.tsx +10 -10
  50. package/src/market-data/coordinator.test.ts +92 -4
  51. package/src/market-data/coordinator.ts +32 -5
  52. package/src/market-data/hooks.test.tsx +120 -0
  53. package/src/market-data/hooks.tsx +53 -16
  54. package/src/market-data/request-types.ts +4 -2
  55. package/src/market-data/selectors.ts +24 -58
  56. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  57. package/src/plugins/builtin/chat-controller.ts +310 -35
  58. package/src/plugins/builtin/chat.test.tsx +118 -6
  59. package/src/plugins/builtin/chat.tsx +175 -116
  60. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  61. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  62. package/src/plugins/builtin/news.tsx +1 -11
  63. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  64. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  65. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  66. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  67. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  68. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  69. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  70. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  71. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  72. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  73. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  74. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  75. package/src/plugins/builtin/sec.tsx +1 -11
  76. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  77. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  78. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  79. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  80. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  81. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  82. package/src/plugins/catalog.ts +65 -0
  83. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  84. package/src/plugins/ibkr/gateway-service.test.ts +45 -1
  85. package/src/plugins/ibkr/gateway-service.ts +141 -7
  86. package/src/plugins/pane-manager.test.ts +1 -1
  87. package/src/plugins/plugin-runtime.tsx +27 -7
  88. package/src/plugins/prediction-markets/cli.ts +28 -0
  89. package/src/plugins/prediction-markets/controller-data.ts +11 -3
  90. package/src/plugins/prediction-markets/controller.ts +141 -121
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/plugins/prediction-markets/launch.ts +51 -16
  93. package/src/plugins/prediction-markets/metrics.ts +9 -5
  94. package/src/plugins/prediction-markets/pane.test.tsx +119 -57
  95. package/src/plugins/prediction-markets/pane.tsx +74 -153
  96. package/src/plugins/prediction-markets/table.tsx +6 -3
  97. package/src/plugins/prediction-markets/types.ts +0 -1
  98. package/src/sources/gloomberb-cloud.test.ts +29 -0
  99. package/src/sources/gloomberb-cloud.ts +89 -1
  100. package/src/sources/provider-router.test.ts +265 -3
  101. package/src/sources/provider-router.ts +239 -11
  102. package/src/sources/yahoo-finance.test.ts +26 -0
  103. package/src/sources/yahoo-finance.ts +54 -3
  104. package/src/state/app-context-selector.test.tsx +99 -0
  105. package/src/state/app-context.test.ts +18 -0
  106. package/src/state/app-context.tsx +170 -65
  107. package/src/state/selectors.ts +25 -0
  108. package/src/theme/colors.ts +8 -8
  109. package/src/theme/themes.test.ts +2 -0
  110. package/src/theme/themes.ts +22 -10
  111. package/src/types/broker.ts +21 -0
  112. package/src/types/config.ts +17 -1
  113. package/src/types/data-provider.ts +18 -0
  114. package/src/types/financials.ts +31 -1
  115. package/src/types/plugin.ts +70 -0
  116. package/src/utils/api-client.test.ts +57 -0
  117. package/src/utils/api-client.ts +35 -12
  118. package/src/utils/back-navigation.ts +27 -0
  119. package/src/utils/format.test.ts +14 -0
  120. package/src/utils/format.ts +3 -3
  121. package/src/utils/market-status.ts +46 -1
  122. package/src/utils/quote-freshness.ts +127 -0
  123. package/src/utils/quote-resolution.test.ts +277 -0
  124. package/src/utils/quote-resolution.ts +641 -0
  125. package/src/utils/slugify.ts +3 -0
  126. package/src/utils/timestamp.ts +25 -0
  127. package/src/version.ts +1 -1
  128. package/src/cli/commands/portfolio.ts +0 -210
  129. package/src/cli/commands/watchlist.ts +0 -210
  130. package/src/plugins/builtin/data-providers.ts +0 -16
  131. package/src/sources/exchange-rates.ts +0 -18
  132. package/src/utils/ascii-chart.ts +0 -172
  133. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -0,0 +1,226 @@
1
+ import type { AppConfig } from "../../../types/config";
2
+ import type { Portfolio, TickerPosition, TickerRecord } from "../../../types/ticker";
3
+ import { slugifyName } from "../../../utils/slugify";
4
+
5
+ export interface DeleteManualPortfolioResult {
6
+ config: AppConfig;
7
+ portfolio: Portfolio;
8
+ tickers: TickerRecord[];
9
+ cleanedTickerCount: number;
10
+ removedPositionCount: number;
11
+ }
12
+
13
+ export interface RemoveTickerFromPortfolioResult {
14
+ changed: boolean;
15
+ ticker: TickerRecord;
16
+ removedPositionCount: number;
17
+ }
18
+
19
+ export interface SetManualPortfolioPositionInput {
20
+ shares: number;
21
+ avgCost: number;
22
+ currency: string;
23
+ }
24
+
25
+ export interface SetManualPortfolioPositionResult {
26
+ ticker: TickerRecord;
27
+ addedMembership: boolean;
28
+ replacedPositionCount: number;
29
+ }
30
+
31
+ function clonePortfolio(portfolio: Portfolio): Portfolio {
32
+ return { ...portfolio };
33
+ }
34
+
35
+ function normalizePortfolioName(rawName: string): string {
36
+ return rawName.trim().toLowerCase();
37
+ }
38
+
39
+ function replaceTickerMetadata(ticker: TickerRecord, metadata: TickerRecord["metadata"]): TickerRecord {
40
+ return {
41
+ ...ticker,
42
+ metadata,
43
+ };
44
+ }
45
+
46
+ export function isManualPortfolio(portfolio: Portfolio): boolean {
47
+ return !portfolio.brokerId && !portfolio.brokerInstanceId;
48
+ }
49
+
50
+ export function findPortfolio(config: AppConfig, rawName: string): Portfolio | null {
51
+ const normalized = normalizePortfolioName(rawName);
52
+ if (!normalized) return null;
53
+ return config.portfolios.find((portfolio) =>
54
+ portfolio.id.toLowerCase() === normalized || portfolio.name.toLowerCase() === normalized
55
+ ) ?? null;
56
+ }
57
+
58
+ export function getManualPortfolioPosition(ticker: TickerRecord, portfolioId: string): TickerPosition | null {
59
+ return ticker.metadata.positions.find((position) =>
60
+ position.portfolio === portfolioId && position.broker === "manual"
61
+ ) ?? null;
62
+ }
63
+
64
+ export function resolveManualPositionCurrency(
65
+ rawCurrency: string | undefined,
66
+ ticker: TickerRecord,
67
+ portfolio: Portfolio,
68
+ baseCurrency: string | undefined,
69
+ ): string {
70
+ return (rawCurrency?.trim() || ticker.metadata.currency || portfolio.currency || baseCurrency || "USD").toUpperCase();
71
+ }
72
+
73
+ export function createManualPortfolio(
74
+ config: AppConfig,
75
+ name: string,
76
+ baseCurrency: string,
77
+ ): { config: AppConfig; portfolio: Portfolio } {
78
+ const trimmedName = name.trim();
79
+ if (!trimmedName) {
80
+ throw new Error("Portfolio name is required.");
81
+ }
82
+
83
+ const id = slugifyName(trimmedName, "portfolio");
84
+ const normalizedName = trimmedName.toLowerCase();
85
+ const duplicate = config.portfolios.some((portfolio) =>
86
+ portfolio.id.toLowerCase() === id || portfolio.name.toLowerCase() === normalizedName
87
+ );
88
+ if (duplicate) {
89
+ throw new Error(`Portfolio "${trimmedName}" already exists.`);
90
+ }
91
+
92
+ const portfolio: Portfolio = {
93
+ id,
94
+ name: trimmedName,
95
+ currency: (baseCurrency || config.baseCurrency || "USD").toUpperCase(),
96
+ };
97
+
98
+ return {
99
+ config: {
100
+ ...config,
101
+ portfolios: [...config.portfolios, portfolio],
102
+ },
103
+ portfolio,
104
+ };
105
+ }
106
+
107
+ export function addTickerToPortfolio(
108
+ ticker: TickerRecord,
109
+ portfolioId: string,
110
+ ): { changed: boolean; ticker: TickerRecord } {
111
+ if (ticker.metadata.portfolios.includes(portfolioId)) {
112
+ return { changed: false, ticker };
113
+ }
114
+
115
+ return {
116
+ changed: true,
117
+ ticker: replaceTickerMetadata(ticker, {
118
+ ...ticker.metadata,
119
+ portfolios: [...ticker.metadata.portfolios, portfolioId],
120
+ }),
121
+ };
122
+ }
123
+
124
+ export function removeTickerFromPortfolio(
125
+ ticker: TickerRecord,
126
+ portfolioId: string,
127
+ ): RemoveTickerFromPortfolioResult {
128
+ const nextPortfolios = ticker.metadata.portfolios.filter((entry) => entry !== portfolioId);
129
+ const removedPositionCount = ticker.metadata.positions.filter((position) => position.portfolio === portfolioId).length;
130
+ const nextPositions = ticker.metadata.positions.filter((position) => position.portfolio !== portfolioId);
131
+ const changed =
132
+ nextPortfolios.length !== ticker.metadata.portfolios.length
133
+ || removedPositionCount > 0;
134
+
135
+ if (!changed) {
136
+ return { changed: false, ticker, removedPositionCount: 0 };
137
+ }
138
+
139
+ return {
140
+ changed: true,
141
+ removedPositionCount,
142
+ ticker: replaceTickerMetadata(ticker, {
143
+ ...ticker.metadata,
144
+ portfolios: nextPortfolios,
145
+ positions: nextPositions,
146
+ }),
147
+ };
148
+ }
149
+
150
+ export function setManualPortfolioPosition(
151
+ ticker: TickerRecord,
152
+ portfolioId: string,
153
+ input: SetManualPortfolioPositionInput,
154
+ ): SetManualPortfolioPositionResult {
155
+ const nextCurrency = input.currency.trim().toUpperCase();
156
+ if (!Number.isFinite(input.shares) || input.shares <= 0) {
157
+ throw new Error("Shares must be greater than 0.");
158
+ }
159
+ if (!Number.isFinite(input.avgCost)) {
160
+ throw new Error("Average cost must be a valid number.");
161
+ }
162
+ if (!nextCurrency) {
163
+ throw new Error("Position currency is required.");
164
+ }
165
+
166
+ const replacedPositionCount = ticker.metadata.positions.filter((position) => position.portfolio === portfolioId).length;
167
+ const nextPositions = ticker.metadata.positions.filter((position) => position.portfolio !== portfolioId);
168
+ const addedMembership = !ticker.metadata.portfolios.includes(portfolioId);
169
+
170
+ nextPositions.push({
171
+ portfolio: portfolioId,
172
+ shares: input.shares,
173
+ avgCost: input.avgCost,
174
+ currency: nextCurrency,
175
+ broker: "manual",
176
+ });
177
+
178
+ return {
179
+ addedMembership,
180
+ replacedPositionCount,
181
+ ticker: replaceTickerMetadata(ticker, {
182
+ ...ticker.metadata,
183
+ portfolios: addedMembership ? [...ticker.metadata.portfolios, portfolioId] : ticker.metadata.portfolios,
184
+ positions: nextPositions,
185
+ }),
186
+ };
187
+ }
188
+
189
+ export function deleteManualPortfolio(
190
+ config: AppConfig,
191
+ tickers: TickerRecord[],
192
+ portfolioId: string,
193
+ ): DeleteManualPortfolioResult {
194
+ const portfolio = config.portfolios.find((entry) => entry.id === portfolioId);
195
+ if (!portfolio) {
196
+ throw new Error("Portfolio not found.");
197
+ }
198
+ if (!isManualPortfolio(portfolio)) {
199
+ throw new Error(`Portfolio "${portfolio.name}" is broker-managed and cannot be modified manually.`);
200
+ }
201
+
202
+ const nextConfig: AppConfig = {
203
+ ...config,
204
+ portfolios: config.portfolios
205
+ .filter((entry) => entry.id !== portfolioId)
206
+ .map(clonePortfolio),
207
+ };
208
+
209
+ const changedTickers: TickerRecord[] = [];
210
+ let removedPositionCount = 0;
211
+
212
+ for (const ticker of tickers) {
213
+ const result = removeTickerFromPortfolio(ticker, portfolioId);
214
+ if (!result.changed) continue;
215
+ changedTickers.push(result.ticker);
216
+ removedPositionCount += result.removedPositionCount;
217
+ }
218
+
219
+ return {
220
+ config: nextConfig,
221
+ portfolio: clonePortfolio(portfolio),
222
+ tickers: changedTickers,
223
+ cleanedTickerCount: changedTickers.length,
224
+ removedPositionCount,
225
+ };
226
+ }
@@ -39,6 +39,7 @@ import {
39
39
  resolveVisibleColumns,
40
40
  } from "./settings";
41
41
  import { PortfolioTickerTable, type QuoteFlashDirection } from "./table";
42
+ import { useThrottledCursorSymbol } from "./use-throttled-cursor-symbol";
42
43
 
43
44
  const VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS = 5 * 60_000;
44
45
 
@@ -52,7 +53,6 @@ function needsVisibleFinancialWarmup(ticker: TickerRecord, financials: TickerFin
52
53
  function selectStreamTickers(
53
54
  tickers: TickerRecord[],
54
55
  _visibleRange: { start: number; end: number },
55
- _cursorSymbol: string | null,
56
56
  ) {
57
57
  return tickers;
58
58
  }
@@ -129,7 +129,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
129
129
  const paneCollection = usePaneCollection();
130
130
 
131
131
  const [currentCollectionId, setCurrentCollectionId] = usePaneStateValue<string>("collectionId", paneCollection.collectionId ?? "");
132
- const [cursorSymbol, setCursorSymbol] = usePaneStateValue<string | null>("cursorSymbol", null);
132
+ const [committedCursorSymbol, setCommittedCursorSymbol] = usePaneStateValue<string | null>("cursorSymbol", null);
133
133
  const [collectionSorts, setCollectionSorts] = usePaneStateValue<Record<string, CollectionSortPreference>>("collectionSorts", {});
134
134
  const [cashDrawerExpanded, setCashDrawerExpanded] = usePaneStateValue<boolean>("cashDrawerExpanded", false);
135
135
 
@@ -144,6 +144,12 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
144
144
  const warmupAttemptRef = useRef(new Map<string, number>());
145
145
  const scrollRef = useRef<ScrollBoxRenderable>(null);
146
146
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);
147
+ const {
148
+ cursorSymbol,
149
+ setCursorSymbol,
150
+ flushCursorSymbol,
151
+ cancelPendingCursorSymbol,
152
+ } = useThrottledCursorSymbol(committedCursorSymbol, setCommittedCursorSymbol);
147
153
 
148
154
  const paneSettings = useMemo(
149
155
  () => getPortfolioPaneSettings(paneInstance?.settings),
@@ -159,11 +165,16 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
159
165
  );
160
166
  const activeCollectionId = resolveActiveCollectionId(currentCollectionId, visibleCollections, paneSettings);
161
167
  const isPortfolioTab = getCollectionType(state, activeCollectionId) === "portfolio";
162
- const currentPortfolio = isPortfolioTab
163
- ? state.config.portfolios.find((portfolio) => portfolio.id === activeCollectionId) ?? null
164
- : null;
165
-
166
- const tickers = getCollectionTickers(state, activeCollectionId);
168
+ const currentPortfolio = useMemo(() => (
169
+ isPortfolioTab
170
+ ? state.config.portfolios.find((portfolio) => portfolio.id === activeCollectionId) ?? null
171
+ : null
172
+ ), [activeCollectionId, isPortfolioTab, state.config.portfolios]);
173
+
174
+ const tickers = useMemo(
175
+ () => getCollectionTickers(state, activeCollectionId),
176
+ [activeCollectionId, state.config.portfolios, state.config.watchlists, state.tickers],
177
+ );
167
178
  const marketFinancialsMap = useTickerFinancialsMap(tickers);
168
179
  const sharedCoordinator = getSharedMarketDataCoordinator();
169
180
  const financialsMap = useMemo(() => {
@@ -240,6 +251,11 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
240
251
  updateStreamWindow();
241
252
  }, [syncHeaderScroll, updateStreamWindow]);
242
253
 
254
+ const handleCollectionSelect = useCallback((collectionId: string) => {
255
+ cancelPendingCursorSymbol();
256
+ setCurrentCollectionId(collectionId);
257
+ }, [cancelPendingCursorSymbol, setCurrentCollectionId]);
258
+
243
259
  const setSortPreference = useCallback((preference: CollectionSortPreference) => {
244
260
  if (!activeCollectionId) return;
245
261
  setCollectionSorts({
@@ -293,18 +309,20 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
293
309
 
294
310
  if (!paneSettings.hideTabs && (key === "h" || key === "left")) {
295
311
  const previousCollection = visibleCollections[Math.max(currentTabIdx - 1, 0)];
296
- if (previousCollection) setCurrentCollectionId(previousCollection.id);
312
+ if (previousCollection) handleCollectionSelect(previousCollection.id);
297
313
  return;
298
314
  }
299
315
 
300
316
  if (!paneSettings.hideTabs && (key === "l" || key === "right")) {
301
317
  const nextCollection = visibleCollections[Math.min(currentTabIdx + 1, visibleCollections.length - 1)];
302
- if (nextCollection) setCurrentCollectionId(nextCollection.id);
318
+ if (nextCollection) handleCollectionSelect(nextCollection.id);
303
319
  return;
304
320
  }
305
321
 
306
322
  if (!isEnter) return;
307
323
 
324
+ flushCursorSymbol(cursorSymbol);
325
+
308
326
  const follower = state.config.layout.instances.find((instance) =>
309
327
  instance.paneId === "ticker-detail"
310
328
  && instance.binding?.kind === "follow"
@@ -325,7 +343,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
325
343
  safeSelectedIdx,
326
344
  setCashDrawerExpanded,
327
345
  setCursorSymbol,
328
- setCurrentCollectionId,
346
+ flushCursorSymbol,
347
+ handleCollectionSelect,
329
348
  showCashDrawer,
330
349
  sortedTickers,
331
350
  state.config.layout.instances,
@@ -336,9 +355,10 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
336
355
 
337
356
  useEffect(() => {
338
357
  if (activeCollectionId !== currentCollectionId) {
358
+ cancelPendingCursorSymbol();
339
359
  setCurrentCollectionId(activeCollectionId);
340
360
  }
341
- }, [activeCollectionId, currentCollectionId, setCurrentCollectionId]);
361
+ }, [activeCollectionId, cancelPendingCursorSymbol, currentCollectionId, setCurrentCollectionId]);
342
362
 
343
363
  useEffect(() => {
344
364
  return () => {
@@ -402,19 +422,19 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
402
422
 
403
423
  useEffect(() => {
404
424
  if (sortedTickers.length === 0) {
405
- if (cursorSymbol !== null) setCursorSymbol(null);
425
+ if (cursorSymbol !== null) setCursorSymbol(null, { immediate: true });
406
426
  return;
407
427
  }
408
428
 
409
429
  const hasSelection = cursorSymbol && sortedTickers.some((ticker) => ticker.metadata.ticker === cursorSymbol);
410
430
  if (!hasSelection) {
411
- setCursorSymbol(sortedTickers[0]!.metadata.ticker);
431
+ setCursorSymbol(sortedTickers[0]!.metadata.ticker, { immediate: true });
412
432
  }
413
433
  }, [cursorSymbol, setCursorSymbol, sortedTickers]);
414
434
 
415
435
  const streamTickers = useMemo(
416
- () => selectStreamTickers(sortedTickers, streamWindow, cursorSymbol),
417
- [cursorSymbol, sortedTickers, streamWindow],
436
+ () => selectStreamTickers(sortedTickers, streamWindow),
437
+ [sortedTickers, streamWindow],
418
438
  );
419
439
  const streamTargets = useMemo(() => (
420
440
  streamTickers
@@ -477,7 +497,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
477
497
  <TabBar
478
498
  tabs={visibleCollections.map((collection) => ({ label: collection.name, value: collection.id }))}
479
499
  activeValue={activeCollectionId}
480
- onSelect={setCurrentCollectionId}
500
+ onSelect={handleCollectionSelect}
481
501
  compact
482
502
  />
483
503
  </box>
@@ -488,7 +508,9 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
488
508
  <PortfolioSummaryBar
489
509
  tickers={sortedTickers}
490
510
  financialsMap={financialsMap}
491
- state={state}
511
+ baseCurrency={state.config.baseCurrency}
512
+ exchangeRates={effectiveExchangeRates}
513
+ refreshingCount={state.refreshing.size}
492
514
  isPortfolio={isPortfolioTab}
493
515
  collectionId={activeCollectionId}
494
516
  width={Math.max(0, width)}
@@ -1,4 +1,4 @@
1
- import type { RefObject } from "react";
1
+ import { useCallback, type RefObject } from "react";
2
2
  import type { ScrollBoxRenderable } from "@opentui/core";
3
3
  import { TickerListTable, type QuoteFlashDirection } from "../../../components/ticker-list-table";
4
4
  import type { ColumnConfig } from "../../../types/config";
@@ -6,6 +6,8 @@ import type { TickerFinancials } from "../../../types/financials";
6
6
  import type { TickerRecord } from "../../../types/ticker";
7
7
  import { getColumnValue, type ColumnContext } from "./metrics";
8
8
 
9
+ export type { QuoteFlashDirection };
10
+
9
11
  export function PortfolioTickerTable({
10
12
  columns,
11
13
  sortColumnId,
@@ -41,6 +43,13 @@ export function PortfolioTickerTable({
41
43
  columnContext: ColumnContext;
42
44
  flashSymbols: Map<string, QuoteFlashDirection>;
43
45
  }) {
46
+ const resolveCell = useCallback(
47
+ (column: ColumnConfig, ticker: TickerRecord, financials: TickerFinancials | undefined) => (
48
+ getColumnValue(column, ticker, financials, columnContext)
49
+ ),
50
+ [columnContext],
51
+ );
52
+
44
53
  return (
45
54
  <TickerListTable
46
55
  columns={columns}
@@ -49,7 +58,7 @@ export function PortfolioTickerTable({
49
58
  hoveredIdx={hoveredIdx}
50
59
  setHoveredIdx={setHoveredIdx}
51
60
  setCursorSymbol={setCursorSymbol}
52
- resolveCell={(column, ticker, financials) => getColumnValue(column, ticker, financials, columnContext)}
61
+ resolveCell={resolveCell}
53
62
  financialsMap={financialsMap}
54
63
  headerScrollRef={headerScrollRef}
55
64
  scrollRef={scrollRef}
@@ -0,0 +1,163 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act, useState } from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { useThrottledCursorSymbol } from "./use-throttled-cursor-symbol";
5
+
6
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
7
+ let setHarnessCursorSymbol: ((symbol: string | null, options?: { immediate?: boolean }) => void) | null = null;
8
+ let flushHarnessCursorSymbol: ((symbol?: string | null) => void) | null = null;
9
+ let latestCursorSymbol: string | null = null;
10
+ let latestCommittedCursorSymbol: string | null = null;
11
+
12
+ const TEST_THROTTLE_MS = 80;
13
+
14
+ function ThrottledCursorHarness() {
15
+ const [committedCursorSymbol, setCommittedCursorSymbol] = useState<string | null>("AAPL");
16
+ const {
17
+ cursorSymbol,
18
+ setCursorSymbol,
19
+ flushCursorSymbol,
20
+ } = useThrottledCursorSymbol(committedCursorSymbol, setCommittedCursorSymbol, TEST_THROTTLE_MS);
21
+
22
+ latestCursorSymbol = cursorSymbol;
23
+ latestCommittedCursorSymbol = committedCursorSymbol;
24
+ setHarnessCursorSymbol = setCursorSymbol;
25
+ flushHarnessCursorSymbol = flushCursorSymbol;
26
+
27
+ return <text>{cursorSymbol ?? "none"}|{committedCursorSymbol ?? "none"}</text>;
28
+ }
29
+
30
+ afterEach(async () => {
31
+ if (testSetup) {
32
+ await act(async () => {
33
+ testSetup!.renderer.destroy();
34
+ });
35
+ testSetup = undefined;
36
+ }
37
+ setHarnessCursorSymbol = null;
38
+ flushHarnessCursorSymbol = null;
39
+ latestCursorSymbol = null;
40
+ latestCommittedCursorSymbol = null;
41
+ });
42
+
43
+ describe("useThrottledCursorSymbol", () => {
44
+ test("keeps the highlighted row immediate while settling the committed cursor", async () => {
45
+ testSetup = await testRender(<ThrottledCursorHarness />, {
46
+ width: 24,
47
+ height: 1,
48
+ });
49
+
50
+ await act(async () => {
51
+ await testSetup!.renderOnce();
52
+ });
53
+
54
+ await act(async () => {
55
+ setHarnessCursorSymbol?.("MSFT");
56
+ await Promise.resolve();
57
+ });
58
+ await act(async () => {
59
+ await testSetup!.renderOnce();
60
+ });
61
+
62
+ expect(latestCursorSymbol).toBe("MSFT");
63
+ expect(latestCommittedCursorSymbol).toBe("AAPL");
64
+
65
+ await act(async () => {
66
+ setHarnessCursorSymbol?.("NVDA");
67
+ await Promise.resolve();
68
+ });
69
+ await act(async () => {
70
+ await testSetup!.renderOnce();
71
+ });
72
+
73
+ expect(latestCursorSymbol).toBe("NVDA");
74
+ expect(latestCommittedCursorSymbol).toBe("AAPL");
75
+
76
+ await act(async () => {
77
+ await new Promise((resolve) => setTimeout(resolve, TEST_THROTTLE_MS + 20));
78
+ });
79
+ await act(async () => {
80
+ await testSetup!.renderOnce();
81
+ });
82
+
83
+ expect(latestCommittedCursorSymbol).toBe("NVDA");
84
+ });
85
+
86
+ test("coalesces repeated cursor moves into the final committed symbol", async () => {
87
+ testSetup = await testRender(<ThrottledCursorHarness />, {
88
+ width: 24,
89
+ height: 1,
90
+ });
91
+
92
+ await act(async () => {
93
+ await testSetup!.renderOnce();
94
+ });
95
+
96
+ await act(async () => {
97
+ setHarnessCursorSymbol?.("MSFT");
98
+ await Promise.resolve();
99
+ });
100
+ await act(async () => {
101
+ setHarnessCursorSymbol?.("NVDA");
102
+ await Promise.resolve();
103
+ });
104
+ await act(async () => {
105
+ setHarnessCursorSymbol?.("AMD");
106
+ await Promise.resolve();
107
+ });
108
+ await act(async () => {
109
+ await testSetup!.renderOnce();
110
+ });
111
+
112
+ expect(latestCursorSymbol).toBe("AMD");
113
+ expect(latestCommittedCursorSymbol).toBe("AAPL");
114
+
115
+ await act(async () => {
116
+ await new Promise((resolve) => setTimeout(resolve, TEST_THROTTLE_MS + 20));
117
+ });
118
+ await act(async () => {
119
+ await testSetup!.renderOnce();
120
+ });
121
+
122
+ expect(latestCommittedCursorSymbol).toBe("AMD");
123
+ });
124
+
125
+ test("can flush a pending cursor immediately", async () => {
126
+ testSetup = await testRender(<ThrottledCursorHarness />, {
127
+ width: 24,
128
+ height: 1,
129
+ });
130
+
131
+ await act(async () => {
132
+ await testSetup!.renderOnce();
133
+ });
134
+
135
+ await act(async () => {
136
+ setHarnessCursorSymbol?.("MSFT");
137
+ await Promise.resolve();
138
+ });
139
+ await act(async () => {
140
+ await testSetup!.renderOnce();
141
+ });
142
+
143
+ await act(async () => {
144
+ setHarnessCursorSymbol?.("NVDA");
145
+ await Promise.resolve();
146
+ });
147
+ await act(async () => {
148
+ await testSetup!.renderOnce();
149
+ });
150
+
151
+ expect(latestCommittedCursorSymbol).toBe("AAPL");
152
+
153
+ await act(async () => {
154
+ flushHarnessCursorSymbol?.("NVDA");
155
+ await Promise.resolve();
156
+ });
157
+ await act(async () => {
158
+ await testSetup!.renderOnce();
159
+ });
160
+
161
+ expect(latestCommittedCursorSymbol).toBe("NVDA");
162
+ });
163
+ });
@@ -0,0 +1,81 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ export const FOLLOW_CURSOR_THROTTLE_MS = 80;
4
+
5
+ export function useThrottledCursorSymbol(
6
+ committedCursorSymbol: string | null,
7
+ setCommittedCursorSymbol: (symbol: string | null) => void,
8
+ throttleMs = FOLLOW_CURSOR_THROTTLE_MS,
9
+ ) {
10
+ const [cursorSymbol, setCursorSymbolState] = useState<string | null>(committedCursorSymbol);
11
+ const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
12
+ const hasPendingCommitRef = useRef(false);
13
+ const pendingCursorSymbolRef = useRef<string | null>(committedCursorSymbol);
14
+ const appliedCursorSymbolRef = useRef<string | null>(committedCursorSymbol);
15
+
16
+ const clearPendingCursorCommit = useCallback(() => {
17
+ if (commitTimerRef.current) {
18
+ clearTimeout(commitTimerRef.current);
19
+ commitTimerRef.current = null;
20
+ }
21
+ hasPendingCommitRef.current = false;
22
+ }, []);
23
+
24
+ const commitCursorSymbol = useCallback((nextCursorSymbol?: string | null) => {
25
+ const targetCursorSymbol = nextCursorSymbol ?? pendingCursorSymbolRef.current;
26
+ clearPendingCursorCommit();
27
+ pendingCursorSymbolRef.current = targetCursorSymbol;
28
+
29
+ if (Object.is(appliedCursorSymbolRef.current, targetCursorSymbol)) {
30
+ return;
31
+ }
32
+
33
+ appliedCursorSymbolRef.current = targetCursorSymbol;
34
+ setCommittedCursorSymbol(targetCursorSymbol);
35
+ }, [clearPendingCursorCommit, setCommittedCursorSymbol]);
36
+
37
+ const scheduleCursorSymbol = useCallback((nextCursorSymbol: string | null, options?: { immediate?: boolean }) => {
38
+ setCursorSymbolState((current) => (Object.is(current, nextCursorSymbol) ? current : nextCursorSymbol));
39
+ pendingCursorSymbolRef.current = nextCursorSymbol;
40
+
41
+ if (options?.immediate) {
42
+ commitCursorSymbol(nextCursorSymbol);
43
+ return;
44
+ }
45
+
46
+ clearPendingCursorCommit();
47
+
48
+ if (Object.is(appliedCursorSymbolRef.current, nextCursorSymbol)) {
49
+ return;
50
+ }
51
+
52
+ hasPendingCommitRef.current = true;
53
+ commitTimerRef.current = setTimeout(() => {
54
+ commitCursorSymbol();
55
+ }, throttleMs);
56
+ }, [clearPendingCursorCommit, commitCursorSymbol, throttleMs]);
57
+
58
+ useEffect(() => {
59
+ if (hasPendingCommitRef.current && Object.is(committedCursorSymbol, pendingCursorSymbolRef.current)) {
60
+ clearPendingCursorCommit();
61
+ }
62
+
63
+ if (!hasPendingCommitRef.current) {
64
+ appliedCursorSymbolRef.current = committedCursorSymbol;
65
+ setCursorSymbolState((current) => (Object.is(current, committedCursorSymbol) ? current : committedCursorSymbol));
66
+ }
67
+ }, [clearPendingCursorCommit, committedCursorSymbol]);
68
+
69
+ useEffect(() => () => {
70
+ if (commitTimerRef.current) {
71
+ clearTimeout(commitTimerRef.current);
72
+ }
73
+ }, []);
74
+
75
+ return {
76
+ cursorSymbol,
77
+ setCursorSymbol: scheduleCursorSymbol,
78
+ flushCursorSymbol: commitCursorSymbol,
79
+ cancelPendingCursorSymbol: clearPendingCursorCommit,
80
+ };
81
+ }