gloomberb 0.7.2 → 0.7.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 (129) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +21 -3
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/utils/api-client.test.ts +128 -0
  124. package/src/utils/api-client.ts +112 -7
  125. package/src/utils/fuzzy-search.ts +12 -1
  126. package/src/utils/keyboard.ts +31 -0
  127. package/src/utils/ticker-search.test.ts +2 -2
  128. package/src/utils/ticker-search.ts +48 -1
  129. package/src/version.ts +1 -1
@@ -0,0 +1,422 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, Input, Text, type InputRenderable } from "../../../ui";
3
+ import { useShortcut } from "../../../react/input";
4
+ import { useAppDispatch, useAppSelector } from "../../../state/app-context";
5
+ import { getSharedRegistry } from "../../registry";
6
+ import { usePluginAppActions } from "../../plugin-runtime";
7
+ import { colors, priceColor } from "../../../theme/colors";
8
+ import { formatPercentRaw } from "../../../utils/format";
9
+ import { formatMarketPrice } from "../../../utils/market-format";
10
+ import { resolveTickerSearch, upsertTickerFromSearchResult, type ResolvedTickerSearch } from "../../../utils/ticker-search";
11
+ import type { Quote } from "../../../types/financials";
12
+ import type { TickerRecord } from "../../../types/ticker";
13
+ import { addTickerToPortfolio, addTickerToWatchlist } from "./mutations";
14
+
15
+ const QUICK_ADD_DEBOUNCE_MS = 300;
16
+ const QUICK_ADD_MAX_QUERY_LENGTH = 32;
17
+ const QUICK_ADD_SYMBOL_RE = /^[A-Z0-9][A-Z0-9.\-\s]*$/;
18
+
19
+ export type QuickAddCollectionKind = "portfolio" | "watchlist";
20
+
21
+ interface ResolvedQuickAdd {
22
+ query: string;
23
+ symbol: string;
24
+ resolved: ResolvedTickerSearch;
25
+ ticker: TickerRecord | null;
26
+ quote: Quote | null;
27
+ }
28
+
29
+ type QuickAddValidation =
30
+ | { status: "idle"; query: "" }
31
+ | { status: "checking"; query: string }
32
+ | (ResolvedQuickAdd & { status: "ready" | "duplicate" })
33
+ | { status: "missing" | "error"; query: string; message: string };
34
+
35
+ const IDLE_VALIDATION: QuickAddValidation = { status: "idle", query: "" };
36
+
37
+ function normalizeQuickAddQuery(value: string): string {
38
+ return value.replace(/^\s*\$/, "").trim().toUpperCase().replace(/\s+/g, " ");
39
+ }
40
+
41
+ function isPlausibleTickerQuery(query: string): boolean {
42
+ return query.length > 0
43
+ && query.length <= QUICK_ADD_MAX_QUERY_LENGTH
44
+ && QUICK_ADD_SYMBOL_RE.test(query);
45
+ }
46
+
47
+ function tickerBelongsToCollection(
48
+ ticker: TickerRecord | null,
49
+ collectionKind: QuickAddCollectionKind,
50
+ collectionId: string,
51
+ ): boolean {
52
+ if (!ticker) return false;
53
+ return collectionKind === "portfolio"
54
+ ? ticker.metadata.portfolios.includes(collectionId)
55
+ : ticker.metadata.watchlists.includes(collectionId);
56
+ }
57
+
58
+ function quoteContextFromResolved(resolved: ResolvedTickerSearch) {
59
+ const instrument = resolved.kind === "provider"
60
+ ? resolved.result.brokerContract
61
+ : resolved.ticker.metadata.broker_contracts?.[0];
62
+ return instrument
63
+ ? {
64
+ brokerId: instrument.brokerId,
65
+ brokerInstanceId: instrument.brokerInstanceId,
66
+ instrument,
67
+ }
68
+ : undefined;
69
+ }
70
+
71
+ function exchangeFromResolved(resolved: ResolvedTickerSearch): string | undefined {
72
+ return resolved.kind === "provider"
73
+ ? resolved.result.exchange
74
+ : resolved.ticker.metadata.exchange;
75
+ }
76
+
77
+ function tickerNameFromValidation(validation: Extract<QuickAddValidation, { status: "ready" | "duplicate" }>): string {
78
+ if (validation.ticker?.metadata.name) return validation.ticker.metadata.name;
79
+ return validation.resolved.kind === "provider" ? validation.resolved.result.name : "";
80
+ }
81
+
82
+ function QuickAddPreview({
83
+ validation,
84
+ collectionKind,
85
+ submitting,
86
+ }: {
87
+ validation: QuickAddValidation;
88
+ collectionKind: QuickAddCollectionKind;
89
+ submitting: boolean;
90
+ }) {
91
+ if (submitting) {
92
+ return <Text fg={colors.textDim}>adding...</Text>;
93
+ }
94
+
95
+ if (validation.status === "idle") {
96
+ return <Text fg={colors.textMuted}> </Text>;
97
+ }
98
+
99
+ if (validation.status === "checking") {
100
+ return <Text fg={colors.textDim}>{`${validation.query} checking...`}</Text>;
101
+ }
102
+
103
+ if (validation.status === "missing" || validation.status === "error") {
104
+ return <Text fg={colors.textMuted}>{validation.message}</Text>;
105
+ }
106
+
107
+ if (validation.status === "duplicate") {
108
+ return <Text fg={colors.textMuted}>{`${validation.symbol} already in ${collectionKind}`}</Text>;
109
+ }
110
+
111
+ const quote = validation.quote;
112
+ const assetCategory = validation.ticker?.metadata.assetCategory
113
+ ?? (validation.resolved.kind === "provider" ? validation.resolved.result.type : undefined);
114
+ const priceText = quote?.price != null
115
+ ? formatMarketPrice(quote.price, { assetCategory, maxWidth: 12 })
116
+ : "-";
117
+ const changeValue = quote?.changePercent;
118
+ const name = tickerNameFromValidation(validation);
119
+ const showSymbol = normalizeQuickAddQuery(validation.symbol) !== validation.query;
120
+ const symbolPrefix = showSymbol ? `${validation.symbol} ` : "";
121
+
122
+ return (
123
+ <Box flexDirection="row" overflow="hidden">
124
+ <Text fg={colors.textDim}>{`${symbolPrefix}${priceText} `}</Text>
125
+ <Text fg={changeValue == null ? colors.textDim : priceColor(changeValue)}>
126
+ {formatPercentRaw(changeValue)}
127
+ </Text>
128
+ {name ? <Text fg={colors.textMuted}>{` ${name}`}</Text> : null}
129
+ </Box>
130
+ );
131
+ }
132
+
133
+ export function QuickAddTickerInput({
134
+ collectionId,
135
+ collectionKind,
136
+ collectionName,
137
+ focused,
138
+ width,
139
+ onAdded,
140
+ onFocusChange,
141
+ }: {
142
+ collectionId: string;
143
+ collectionKind: QuickAddCollectionKind;
144
+ collectionName: string;
145
+ focused: boolean;
146
+ width: number;
147
+ onAdded: (symbol: string) => void;
148
+ onFocusChange?: (focused: boolean) => void;
149
+ }) {
150
+ const dispatch = useAppDispatch();
151
+ const { notify } = usePluginAppActions();
152
+ const tickers = useAppSelector((state) => state.tickers);
153
+ const financials = useAppSelector((state) => state.financials);
154
+ const inputRef = useRef<InputRenderable | null>(null);
155
+ const validationSeqRef = useRef(0);
156
+ const [inputFocused, setInputFocused] = useState(false);
157
+ const [inputValue, setInputValue] = useState("");
158
+ const [validation, setValidation] = useState<QuickAddValidation>(IDLE_VALIDATION);
159
+ const [submitting, setSubmitting] = useState(false);
160
+
161
+ const focusInput = useCallback(() => {
162
+ if (!focused) return;
163
+ setInputFocused(true);
164
+ queueMicrotask(() => inputRef.current?.focus?.());
165
+ }, [focused]);
166
+
167
+ const blurInput = useCallback(() => {
168
+ setInputFocused(false);
169
+ }, []);
170
+
171
+ const resetInput = useCallback(() => {
172
+ setInputValue("");
173
+ setValidation(IDLE_VALIDATION);
174
+ }, []);
175
+
176
+ useEffect(() => {
177
+ onFocusChange?.(inputFocused && focused);
178
+ }, [focused, inputFocused, onFocusChange]);
179
+
180
+ useEffect(() => {
181
+ if (inputFocused && focused) {
182
+ dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
183
+ return () => dispatch({ type: "SET_INPUT_CAPTURED", captured: false });
184
+ }
185
+ }, [dispatch, focused, inputFocused]);
186
+
187
+ useEffect(() => {
188
+ if (!focused && inputFocused) {
189
+ blurInput();
190
+ }
191
+ }, [blurInput, focused, inputFocused]);
192
+
193
+ const validateQuery = useCallback(async (query: string): Promise<QuickAddValidation> => {
194
+ if (!query) return IDLE_VALIDATION;
195
+ if (!isPlausibleTickerQuery(query)) {
196
+ return { status: "missing", query, message: "Use a ticker symbol" };
197
+ }
198
+
199
+ const registry = getSharedRegistry();
200
+ if (!registry) {
201
+ return { status: "error", query, message: "Ticker lookup unavailable" };
202
+ }
203
+
204
+ try {
205
+ const resolved = await resolveTickerSearch({
206
+ query,
207
+ activeTicker: null,
208
+ tickers,
209
+ dataProvider: registry.marketData,
210
+ });
211
+ if (!resolved) {
212
+ return { status: "missing", query, message: "No exact ticker match" };
213
+ }
214
+
215
+ const symbol = resolved.symbol;
216
+ const ticker = resolved.kind === "local" ? resolved.ticker : (tickers.get(symbol) ?? null);
217
+ const cachedQuote = financials.get(symbol)?.quote ?? null;
218
+ let quote = cachedQuote;
219
+ if (!quote) {
220
+ try {
221
+ quote = await registry.marketData.getQuote(
222
+ symbol,
223
+ exchangeFromResolved(resolved),
224
+ quoteContextFromResolved(resolved),
225
+ );
226
+ } catch {
227
+ quote = null;
228
+ }
229
+ }
230
+
231
+ return {
232
+ status: tickerBelongsToCollection(ticker, collectionKind, collectionId) ? "duplicate" : "ready",
233
+ query,
234
+ symbol,
235
+ resolved,
236
+ ticker,
237
+ quote,
238
+ };
239
+ } catch {
240
+ return { status: "error", query, message: "Ticker lookup failed" };
241
+ }
242
+ }, [collectionId, collectionKind, financials, tickers]);
243
+
244
+ useEffect(() => {
245
+ const query = normalizeQuickAddQuery(inputValue);
246
+ validationSeqRef.current += 1;
247
+ const seq = validationSeqRef.current;
248
+
249
+ if (!query) {
250
+ setValidation(IDLE_VALIDATION);
251
+ return;
252
+ }
253
+
254
+ if (!isPlausibleTickerQuery(query)) {
255
+ setValidation({ status: "missing", query, message: "Use a ticker symbol" });
256
+ return;
257
+ }
258
+
259
+ setValidation({ status: "checking", query });
260
+ const timeoutId = setTimeout(() => {
261
+ void validateQuery(query).then((result) => {
262
+ if (validationSeqRef.current === seq) {
263
+ setValidation(result);
264
+ }
265
+ });
266
+ }, QUICK_ADD_DEBOUNCE_MS);
267
+
268
+ return () => clearTimeout(timeoutId);
269
+ }, [inputValue, validateQuery]);
270
+
271
+ const submitInput = useCallback(async (submittedValue?: string) => {
272
+ const query = normalizeQuickAddQuery(submittedValue ?? inputValue);
273
+ if (!query || submitting) return;
274
+
275
+ setSubmitting(true);
276
+ try {
277
+ const currentValidation = (
278
+ validation.query === query
279
+ && (validation.status === "ready" || validation.status === "duplicate")
280
+ )
281
+ ? validation
282
+ : await validateQuery(query);
283
+ setValidation(currentValidation);
284
+
285
+ if (currentValidation.status === "duplicate") {
286
+ notify({ type: "info", body: `${currentValidation.symbol} is already in ${collectionName}.` });
287
+ return;
288
+ }
289
+ if (currentValidation.status !== "ready") {
290
+ notify({ type: "error", body: currentValidation.status === "idle" ? "Ticker symbol is required." : currentValidation.message });
291
+ return;
292
+ }
293
+
294
+ const registry = getSharedRegistry();
295
+ if (!registry) {
296
+ notify({ type: "error", body: "Ticker lookup unavailable." });
297
+ return;
298
+ }
299
+
300
+ let ticker: TickerRecord;
301
+ let created = false;
302
+ if (currentValidation.resolved.kind === "local") {
303
+ ticker = currentValidation.resolved.ticker;
304
+ } else {
305
+ const result = await upsertTickerFromSearchResult(registry.tickerRepository, currentValidation.resolved.result);
306
+ ticker = result.ticker;
307
+ created = result.created;
308
+ }
309
+
310
+ const result = collectionKind === "portfolio"
311
+ ? addTickerToPortfolio(ticker, collectionId)
312
+ : addTickerToWatchlist(ticker, collectionId);
313
+
314
+ if (!result.changed) {
315
+ notify({ type: "info", body: `${ticker.metadata.ticker} is already in ${collectionName}.` });
316
+ return;
317
+ }
318
+
319
+ await registry.tickerRepository.saveTicker(result.ticker);
320
+ dispatch({ type: "UPDATE_TICKER", ticker: result.ticker });
321
+ if (created) {
322
+ registry.events.emit("ticker:added", {
323
+ symbol: result.ticker.metadata.ticker,
324
+ ticker: result.ticker,
325
+ });
326
+ }
327
+
328
+ onAdded(result.ticker.metadata.ticker);
329
+ notify({ type: "success", body: `Added ${result.ticker.metadata.ticker} to ${collectionName}.` });
330
+ resetInput();
331
+ queueMicrotask(() => inputRef.current?.focus?.());
332
+ } catch {
333
+ setValidation({ status: "error", query, message: "Ticker add failed" });
334
+ notify({ type: "error", body: `Failed to add ${query}.` });
335
+ } finally {
336
+ setSubmitting(false);
337
+ }
338
+ }, [
339
+ collectionId,
340
+ collectionKind,
341
+ collectionName,
342
+ dispatch,
343
+ inputValue,
344
+ notify,
345
+ onAdded,
346
+ resetInput,
347
+ submitting,
348
+ validateQuery,
349
+ validation,
350
+ ]);
351
+
352
+ useShortcut((event) => {
353
+ if (!focused) return;
354
+
355
+ if (inputFocused) {
356
+ if (event.name === "escape") {
357
+ event.preventDefault?.();
358
+ event.stopPropagation?.();
359
+ resetInput();
360
+ blurInput();
361
+ }
362
+ return;
363
+ }
364
+
365
+ if ((event.name === "a" || event.name === "n") && !event.ctrl && !event.meta && !event.super) {
366
+ event.preventDefault?.();
367
+ event.stopPropagation?.();
368
+ focusInput();
369
+ }
370
+ }, { phase: "before" });
371
+
372
+ const inputWidth = useMemo(() => {
373
+ const queryWidth = normalizeQuickAddQuery(inputValue).length;
374
+ if (queryWidth > 0) {
375
+ return Math.max(4, Math.min(18, queryWidth + 1));
376
+ }
377
+ return Math.max(6, Math.min(10, Math.floor(width * 0.18)));
378
+ }, [inputValue, width]);
379
+ const previewWidth = Math.max(8, width - inputWidth - 7);
380
+
381
+ return (
382
+ <Box
383
+ height={1}
384
+ width="100%"
385
+ flexDirection="row"
386
+ flexShrink={0}
387
+ paddingX={1}
388
+ backgroundColor={colors.panel}
389
+ onMouseDown={(event: { preventDefault?: () => void }) => {
390
+ event.preventDefault?.();
391
+ focusInput();
392
+ }}
393
+ >
394
+ <Text fg={inputFocused ? colors.text : colors.textDim}>+</Text>
395
+ <Box width={1} />
396
+ <Box width={inputWidth}>
397
+ <Input
398
+ ref={inputRef}
399
+ value={inputValue}
400
+ focused={inputFocused && focused}
401
+ placeholder="ticker"
402
+ placeholderColor={colors.textMuted}
403
+ textColor={colors.text}
404
+ backgroundColor={colors.panel}
405
+ onInput={(value: string) => setInputValue(value.toUpperCase())}
406
+ onChange={(value: string) => setInputValue(value.toUpperCase())}
407
+ onSubmit={(value: string) => void submitInput(value)}
408
+ onFocus={() => setInputFocused(true)}
409
+ onBlur={blurInput}
410
+ />
411
+ </Box>
412
+ <Box width={1} />
413
+ <Box width={previewWidth} overflow="hidden">
414
+ <QuickAddPreview
415
+ validation={validation}
416
+ collectionKind={collectionKind}
417
+ submitting={submitting}
418
+ />
419
+ </Box>
420
+ </Box>
421
+ );
422
+ }
@@ -1,5 +1,6 @@
1
1
  import type { PaneSettingOption, PaneSettingsDef, PaneTemplateContext } from "../../../types/plugin";
2
2
  import { DEFAULT_COLUMNS, DEFAULT_PORTFOLIO_COLUMN_IDS, type AppConfig, type ColumnConfig } from "../../../types/config";
3
+ import { PRICE_SPARKLINE_COLUMN_ID, PRICE_SPARKLINE_PERIOD_LABEL } from "../../../components/price-sparkline-view";
3
4
 
4
5
  export type CollectionScope = "all" | "portfolios" | "watchlists" | "custom";
5
6
 
@@ -9,7 +10,6 @@ export interface PortfolioPaneSettings {
9
10
  visibleCollectionIds: string[];
10
11
  hideHeader: boolean;
11
12
  hideCash: boolean;
12
- showSparklines?: boolean;
13
13
  }
14
14
 
15
15
  export interface CollectionEntry {
@@ -20,6 +20,7 @@ export interface CollectionEntry {
20
20
 
21
21
  export const PORTFOLIO_COLUMN_DEFS: ColumnConfig[] = [
22
22
  ...DEFAULT_COLUMNS,
23
+ { id: PRICE_SPARKLINE_COLUMN_ID, label: PRICE_SPARKLINE_PERIOD_LABEL, width: 6, align: "left" },
23
24
  { id: "bid", label: "BID", width: 10, align: "right", format: "currency" },
24
25
  { id: "ask", label: "ASK", width: 10, align: "right", format: "currency" },
25
26
  { id: "spread", label: "SPREAD", width: 10, align: "right", format: "currency" },
@@ -93,6 +94,13 @@ function resolveCollectionOptions(entries: CollectionEntry[]): PaneSettingOption
93
94
  }));
94
95
  }
95
96
 
97
+ function describeColumnOption(column: ColumnConfig): string {
98
+ if (column.id === PRICE_SPARKLINE_COLUMN_ID) return "One-month price sparkline.";
99
+ return PORTFOLIO_ONLY_COLUMN_IDS.has(column.id)
100
+ ? "Visible only when this pane is showing a portfolio."
101
+ : "Visible for watchlists and portfolios.";
102
+ }
103
+
96
104
  export function getPortfolioPaneSettings(settings: Record<string, unknown> | undefined): PortfolioPaneSettings {
97
105
  const columnIds = Array.isArray(settings?.columnIds)
98
106
  ? settings.columnIds.filter((value): value is string => typeof value === "string")
@@ -107,7 +115,6 @@ export function getPortfolioPaneSettings(settings: Record<string, unknown> | und
107
115
  visibleCollectionIds,
108
116
  hideHeader: settings?.hideHeader === true,
109
117
  hideCash: settings?.hideCash === true,
110
- showSparklines: settings?.showSparklines === true,
111
118
  };
112
119
  }
113
120
 
@@ -115,6 +122,7 @@ export function cleanPortfolioPaneSettings(settings: Record<string, unknown>): R
115
122
  const nextSettings = { ...settings };
116
123
  delete nextSettings.hideTabs;
117
124
  delete nextSettings.lockedCollectionId;
125
+ delete nextSettings.showSparklines;
118
126
  if (nextSettings.collectionScope !== "custom") {
119
127
  delete nextSettings.visibleCollectionIds;
120
128
  }
@@ -175,7 +183,10 @@ export function resolveVisibleColumns(columnIds: string[], isPortfolioTab: boole
175
183
  return resolved;
176
184
  }
177
185
 
178
- return DEFAULT_COLUMNS.filter((column) => isPortfolioTab || !PORTFOLIO_ONLY_COLUMN_IDS.has(column.id));
186
+ return DEFAULT_PORTFOLIO_COLUMN_IDS
187
+ .map((columnId) => PORTFOLIO_COLUMNS_BY_ID.get(columnId))
188
+ .filter((column): column is ColumnConfig => column != null)
189
+ .filter((column) => isPortfolioTab || !PORTFOLIO_ONLY_COLUMN_IDS.has(column.id));
179
190
  }
180
191
 
181
192
  export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: PortfolioPaneSettings): PaneSettingsDef {
@@ -190,9 +201,7 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
190
201
  options: PORTFOLIO_COLUMN_DEFS.map((column) => ({
191
202
  value: column.id,
192
203
  label: column.label,
193
- description: PORTFOLIO_ONLY_COLUMN_IDS.has(column.id)
194
- ? "Visible only when this pane is showing a portfolio."
195
- : "Visible for watchlists and portfolios.",
204
+ description: describeColumnOption(column),
196
205
  })),
197
206
  },
198
207
  {
@@ -222,14 +231,13 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
222
231
  label: "Hide Cash Positions",
223
232
  type: "toggle",
224
233
  });
225
- fields.push({
226
- key: "showSparklines",
227
- label: "Sparklines",
228
- type: "toggle",
229
- });
230
-
231
234
  return {
232
235
  title: "Portfolio Pane Settings",
236
+ values: {
237
+ ...settings,
238
+ columnIds: [...settings.columnIds],
239
+ visibleCollectionIds: [...settings.visibleCollectionIds],
240
+ },
233
241
  fields,
234
242
  };
235
243
  }
@@ -56,12 +56,12 @@ export function PortfolioTickerTable({
56
56
  financialsMap,
57
57
  columnContext,
58
58
  flashSymbols,
59
- showSparklines,
60
59
  onRootKeyDown,
61
60
  onVisibleRangeChange,
62
61
  visibleRangeBuffer,
63
62
  resetScrollKey,
64
63
  onRowActivate,
64
+ rootHeight,
65
65
  }: {
66
66
  columns: ColumnConfig[];
67
67
  focused?: boolean;
@@ -74,12 +74,12 @@ export function PortfolioTickerTable({
74
74
  financialsMap: Map<string, TickerFinancials>;
75
75
  columnContext: ColumnContext;
76
76
  flashSymbols: Map<string, QuoteFlashDirection>;
77
- showSparklines?: boolean;
78
77
  onRootKeyDown?: (event: DataTableKeyEvent) => boolean | void;
79
78
  onVisibleRangeChange?: (range: TickerListVisibleRange) => void;
80
79
  visibleRangeBuffer?: number;
81
80
  resetScrollKey?: unknown;
82
81
  onRowActivate?: (ticker: TickerRecord) => void;
82
+ rootHeight?: number;
83
83
  }) {
84
84
  const cellCacheRef = useRef(createRowValueCache<string, ReturnType<typeof getColumnValue>>(5000));
85
85
  const resolveCell = useCallback(
@@ -106,12 +106,12 @@ export function PortfolioTickerTable({
106
106
  sortColumnId={sortColumnId}
107
107
  sortDirection={sortDirection}
108
108
  onHeaderClick={onHeaderClick}
109
- showSparklines={showSparklines}
110
109
  onRootKeyDown={onRootKeyDown}
111
110
  onVisibleRangeChange={onVisibleRangeChange}
112
111
  visibleRangeBuffer={visibleRangeBuffer}
113
112
  resetScrollKey={resetScrollKey}
114
113
  onRowActivate={onRowActivate}
114
+ rootHeight={rootHeight}
115
115
  />
116
116
  );
117
117
  }