gloomberb 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) 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 +158 -47
  7. package/src/components/chart/chart-types.ts +20 -2
  8. package/src/components/chart/chart.test.ts +179 -13
  9. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  10. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  11. package/src/components/chart/comparison-stock-chart.tsx +129 -129
  12. package/src/components/chart/market-session.ts +187 -0
  13. package/src/components/chart/native/chart-rasterizer.ts +108 -19
  14. package/src/components/chart/price-axis-labels.tsx +25 -9
  15. package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
  16. package/src/components/chart/stock-chart.tsx +95 -49
  17. package/src/components/chart/time-axis-label.tsx +56 -2
  18. package/src/components/command-bar/command-bar.test.tsx +446 -120
  19. package/src/components/command-bar/command-bar.tsx +535 -384
  20. package/src/components/command-bar/command-registry.ts +16 -6
  21. package/src/components/command-bar/list-model.ts +8 -4
  22. package/src/components/command-bar/root-shortcuts.ts +4 -5
  23. package/src/components/command-bar/view-model.test.ts +21 -0
  24. package/src/components/command-bar/view-model.ts +17 -4
  25. package/src/components/command-bar/workflow-ops.ts +11 -32
  26. package/src/components/data-table-view.test.tsx +24 -1
  27. package/src/components/data-table-view.tsx +3 -2
  28. package/src/components/feed-data-table-stack-view.tsx +3 -2
  29. package/src/components/layout/detached-pane-shell.tsx +2 -2
  30. package/src/components/layout/floating-pane.tsx +9 -2
  31. package/src/components/layout/pane-footer.tsx +1 -1
  32. package/src/components/layout/pane-header.tsx +4 -2
  33. package/src/components/layout/pane-sizing.ts +12 -0
  34. package/src/components/layout/pane.tsx +24 -2
  35. package/src/components/layout/shell.tsx +24 -10
  36. package/src/components/layout/status-bar.test.tsx +4 -1
  37. package/src/components/layout/status-bar.tsx +4 -24
  38. package/src/components/markdown-text.tsx +53 -8
  39. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  40. package/src/components/pane-settings-dialog.tsx +9 -6
  41. package/src/components/pane-template-wizard.tsx +3 -2
  42. package/src/components/price-selector-dialog.tsx +3 -2
  43. package/src/components/price-sparkline-view.tsx +463 -0
  44. package/src/components/price-sparkline.ts +46 -0
  45. package/src/components/table-view-shared.tsx +21 -3
  46. package/src/components/ticker-list-table-view.tsx +3 -2
  47. package/src/components/ticker-list-table.tsx +15 -66
  48. package/src/components/toggle-list.tsx +3 -3
  49. package/src/components/ui/choice-dialog.tsx +3 -2
  50. package/src/components/ui/multi-select-dialog.tsx +3 -2
  51. package/src/components/ui/native-select.tsx +3 -3
  52. package/src/core/state/app-state.ts +210 -80
  53. package/src/core/state/session-persistence.ts +25 -0
  54. package/src/data/config-store-node.ts +113 -25
  55. package/src/data/config-store.test.ts +65 -3
  56. package/src/data/plugin-state-store.ts +39 -13
  57. package/src/data/sqlite-cache.test.ts +13 -0
  58. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  59. package/src/market-data/coordinator.test.ts +122 -2
  60. package/src/market-data/coordinator.ts +200 -56
  61. package/src/market-data/hooks.test.tsx +31 -0
  62. package/src/market-data/hooks.tsx +1 -0
  63. package/src/news/aggregator.test.ts +41 -0
  64. package/src/news/aggregator.ts +61 -9
  65. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  66. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  67. package/src/plugins/builtin/ai/settings.ts +3 -0
  68. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  69. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  70. package/src/plugins/builtin/chat-controller.ts +263 -48
  71. package/src/plugins/builtin/chat.test.tsx +312 -6
  72. package/src/plugins/builtin/chat.tsx +245 -66
  73. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  74. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  75. package/src/plugins/builtin/correlation/colors.ts +87 -0
  76. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  77. package/src/plugins/builtin/correlation/index.tsx +92 -21
  78. package/src/plugins/builtin/debug.tsx +4 -3
  79. package/src/plugins/builtin/econ/index.tsx +3 -2
  80. package/src/plugins/builtin/help.test.tsx +2 -2
  81. package/src/plugins/builtin/holders/index.tsx +25 -13
  82. package/src/plugins/builtin/layout-manager.tsx +1 -7
  83. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  84. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  85. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  86. package/src/plugins/builtin/news-wire/index.tsx +1 -3
  87. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  88. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  89. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  90. package/src/plugins/builtin/options.tsx +9 -17
  91. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  92. package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
  93. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  94. package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
  95. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  96. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  97. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  98. package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
  99. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  100. package/src/plugins/builtin/sectors/index.tsx +275 -43
  101. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  102. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  103. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  104. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  105. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
  106. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
  107. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  108. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  109. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  110. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  111. package/src/plugins/plugin-runtime.tsx +2 -2
  112. package/src/plugins/prediction-markets/settings.ts +4 -0
  113. package/src/plugins/registry.test.ts +55 -0
  114. package/src/plugins/registry.ts +11 -4
  115. package/src/plugins/ticker-navigation.test.ts +35 -5
  116. package/src/plugins/ticker-navigation.ts +16 -22
  117. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  118. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  119. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  120. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  121. package/src/renderers/electrobun/bun/index.ts +52 -13
  122. package/src/renderers/electrobun/view/app-services.ts +51 -9
  123. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  124. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  125. package/src/renderers/electrobun/view/main.tsx +0 -16
  126. package/src/renderers/electrobun/view/styles.css +23 -21
  127. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  128. package/src/renderers/opentui/host.tsx +2 -1
  129. package/src/renderers/opentui/start.tsx +1 -1
  130. package/src/renderers/opentui/test-utils.tsx +6 -5
  131. package/src/sources/gloomberb-cloud.test.ts +55 -2
  132. package/src/sources/gloomberb-cloud.ts +180 -29
  133. package/src/sources/provider-router.test.ts +567 -10
  134. package/src/sources/provider-router.ts +323 -93
  135. package/src/sources/yahoo-finance.test.ts +37 -0
  136. package/src/sources/yahoo-finance.ts +48 -2
  137. package/src/state/app-bootstrap.test.ts +50 -23
  138. package/src/state/app-bootstrap.ts +29 -13
  139. package/src/state/app-context.test.ts +113 -0
  140. package/src/state/app-context.tsx +8 -4
  141. package/src/state/session-persistence.test.ts +5 -3
  142. package/src/state/use-inline-tickers.ts +3 -0
  143. package/src/state/use-quote-streaming.ts +11 -0
  144. package/src/theme/colors.ts +13 -14
  145. package/src/types/config.ts +9 -4
  146. package/src/types/data-provider.ts +5 -1
  147. package/src/types/financials.ts +1 -0
  148. package/src/types/plugin.ts +8 -1
  149. package/src/ui/context-menu.tsx +4 -4
  150. package/src/utils/api-client.test.ts +248 -0
  151. package/src/utils/api-client.ts +178 -26
  152. package/src/utils/exchanges.test.ts +16 -0
  153. package/src/utils/exchanges.ts +33 -0
  154. package/src/utils/fuzzy-search.ts +12 -1
  155. package/src/utils/keyboard.ts +31 -0
  156. package/src/utils/options.ts +1 -1
  157. package/src/utils/price-history.test.ts +28 -1
  158. package/src/utils/price-history.ts +16 -0
  159. package/src/utils/quote-freshness.test.ts +150 -0
  160. package/src/utils/quote-freshness.ts +42 -1
  161. package/src/utils/quote-resolution.ts +2 -0
  162. package/src/utils/quote-time.test.ts +34 -0
  163. package/src/utils/quote-time.ts +18 -5
  164. package/src/utils/ticker-search.test.ts +2 -2
  165. package/src/utils/ticker-search.ts +48 -1
  166. package/src/version.ts +1 -1
  167. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -1,97 +1,479 @@
1
- import { Box, Text } from "../../../ui";
1
+ import { useCallback, useMemo } from "react";
2
+ import { Box, Text, useUiCapabilities } from "../../../ui";
2
3
  import { TextAttributes } from "../../../ui";
3
4
  import type { PaneProps } from "../../../types/plugin";
4
5
  import type { Quote } from "../../../types/financials";
6
+ import type { TickerFinancials } from "../../../types/financials";
7
+ import type { TickerRecord } from "../../../types/ticker";
8
+ import type { QuoteSubscriptionTarget } from "../../../types/data-provider";
5
9
  import { quoteSubscriptionTargetFromTicker } from "../../../market-data/request-types";
6
- import { usePaneTicker } from "../../../state/app-context";
10
+ import { useTickerFinancials } from "../../../market-data/hooks";
11
+ import { useAppSelector, usePaneInstance, usePaneTicker } from "../../../state/app-context";
7
12
  import { useQuoteStreaming } from "../../../state/use-quote-streaming";
13
+ import { usePluginAppActions, usePluginTickerActions } from "../../plugin-runtime";
14
+ import { useDoubleClickActivation } from "../../../components/use-double-click-activation";
8
15
  import { colors, priceColor } from "../../../theme/colors";
9
16
  import { formatPercentRaw } from "../../../utils/format";
10
17
  import { formatMarketPriceWithCurrency, formatSignedMarketPrice } from "../../../utils/market-format";
11
18
  import { getActiveQuoteDisplay } from "../../../utils/market-status";
12
- import { EmptyState, usePaneFooter } from "../../../components";
19
+ import { EmptyState } from "../../../components";
20
+ import {
21
+ PriceAreaSparklineBackground,
22
+ PriceSparkline,
23
+ resolvePriceSparklineRange,
24
+ type PriceSparklinePeriod,
25
+ type PriceSparklineTrend,
26
+ } from "../../../components/price-sparkline-view";
27
+ import { getQuoteMonitorPaneSettings } from "./settings";
28
+ import { useShortcut } from "../../../react/input";
13
29
 
14
30
  function getQuoteMonitorDisplay(quote: Quote | null | undefined) {
15
31
  return getActiveQuoteDisplay(quote);
16
32
  }
17
33
 
18
- export function QuoteMonitorPane({ focused, width }: PaneProps) {
19
- const { ticker, financials } = usePaneTicker();
20
- const streamingTarget = quoteSubscriptionTargetFromTicker(ticker, ticker?.metadata.ticker, "provider");
21
- useQuoteStreaming(streamingTarget ? [streamingTarget] : []);
22
- const quote = financials?.quote;
34
+ function chunkSymbols(symbols: string[], columns: number): string[][] {
35
+ const rows: string[][] = [];
36
+ for (let index = 0; index < symbols.length; index += columns) {
37
+ rows.push(symbols.slice(index, index + columns));
38
+ }
39
+ return rows;
40
+ }
23
41
 
24
- usePaneFooter("quote-monitor", () => ({
25
- info: [
26
- ...(ticker ? [{ id: "ticker", parts: [{ text: ticker.metadata.ticker, tone: "value" as const, bold: true }] }] : []),
27
- ...(quote?.lastUpdated ? [{
28
- id: "updated",
29
- parts: [{ text: `quote ${new Date(quote.lastUpdated).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`, tone: "muted" as const }],
30
- }] : []),
31
- ...(quote?.providerId || quote?.dataSource ? [{
32
- id: "source",
33
- parts: [{ text: quote.providerId ?? quote.dataSource ?? "", tone: "muted" as const }],
34
- }] : []),
35
- ],
36
- }), [quote?.dataSource, quote?.lastUpdated, quote?.providerId, ticker?.metadata.ticker]);
37
-
38
- if (!ticker) {
39
- return (
40
- <Box flexDirection="column" flexGrow={1} paddingX={1}>
41
- <EmptyState title="No ticker selected." />
42
- </Box>
43
- );
42
+ function resolveGridColumnCount(symbolCount: number, width: number, height: number, nativePaneChrome: boolean): number {
43
+ if (symbolCount <= 1) return 1;
44
+
45
+ const contentWidth = Math.max(1, width - (nativePaneChrome ? 0 : 2));
46
+ const contentHeight = Math.max(1, height);
47
+ const minimumCellWidth = nativePaneChrome ? 34 : 28;
48
+ const minimumCellHeight = nativePaneChrome ? 4 : 3;
49
+ const maxColumns = Math.max(1, Math.min(symbolCount, Math.floor(contentWidth / minimumCellWidth)));
50
+ const targetAspect = nativePaneChrome ? 5.5 : 4.5;
51
+ let bestColumns = 1;
52
+ let bestScore = Number.POSITIVE_INFINITY;
53
+
54
+ for (let columns = 1; columns <= maxColumns; columns++) {
55
+ const rows = Math.ceil(symbolCount / columns);
56
+ const cellWidth = Math.floor(contentWidth / columns);
57
+ const cellHeight = Math.floor(contentHeight / rows);
58
+ const aspect = cellWidth / Math.max(1, cellHeight);
59
+ const emptySlots = columns * rows - symbolCount;
60
+ const widthPenalty = cellWidth < minimumCellWidth ? (minimumCellWidth - cellWidth) * 0.5 : 0;
61
+ const heightPenalty = cellHeight < minimumCellHeight ? (minimumCellHeight - cellHeight) * 1.4 : 0;
62
+ const score = Math.abs(Math.log(aspect / targetAspect))
63
+ + emptySlots * 0.35
64
+ + widthPenalty
65
+ + heightPenalty
66
+ - columns * 0.03;
67
+ if (score < bestScore) {
68
+ bestScore = score;
69
+ bestColumns = columns;
70
+ }
44
71
  }
45
72
 
46
- const display = getQuoteMonitorDisplay(financials?.quote);
73
+ return bestColumns;
74
+ }
75
+
76
+ function quoteTrend(value: number | null | undefined): PriceSparklineTrend {
77
+ if (value == null || value === 0) return "neutral";
78
+ return value > 0 ? "positive" : "negative";
79
+ }
80
+
81
+ function QuoteMonitorCard({
82
+ symbol,
83
+ ticker,
84
+ cachedFinancials,
85
+ width,
86
+ height,
87
+ showRightDivider,
88
+ showBottomDivider,
89
+ chartPeriod,
90
+ onOpen,
91
+ }: {
92
+ symbol: string;
93
+ ticker: TickerRecord | null;
94
+ cachedFinancials: TickerFinancials | null;
95
+ width: number;
96
+ height: number;
97
+ showRightDivider: boolean;
98
+ showBottomDivider: boolean;
99
+ chartPeriod: PriceSparklinePeriod;
100
+ onOpen: (symbol: string) => void;
101
+ }) {
102
+ const { nativePaneChrome } = useUiCapabilities();
103
+ const marketFinancials = useTickerFinancials(symbol, ticker);
104
+ const financials = marketFinancials ?? cachedFinancials;
105
+ const quote = financials?.quote;
106
+ const display = getQuoteMonitorDisplay(quote);
47
107
  const changeColor = priceColor(display?.change ?? 0);
48
- const compact = width < 56;
49
- const currency = financials?.quote?.currency ?? ticker.metadata.currency ?? "USD";
108
+ const currency = quote?.currency ?? ticker?.metadata.currency ?? "USD";
109
+ const stacked = width < 31;
110
+ const priceText = display
111
+ ? formatMarketPriceWithCurrency(display.price, currency, { assetCategory: ticker?.metadata.assetCategory })
112
+ : "";
113
+ const changePercentText = display ? formatPercentRaw(display.changePercent) : "";
114
+ const changeValueText = display ? formatSignedMarketPrice(display.change, { assetCategory: ticker?.metadata.assetCategory }) : "";
115
+ const priceColumnWidth = Math.max(priceText.length, changePercentText.length + changeValueText.length + 1);
116
+ const nameMaxWidth = Math.max(10, width - priceColumnWidth - (nativePaneChrome ? 5 : 3));
117
+ const sparklineRange = resolvePriceSparklineRange(financials?.priceHistory, chartPeriod);
118
+ const rangeLabel = sparklineRange
119
+ ? `${chartPeriod} ${formatMarketPriceWithCurrency(sparklineRange.min, currency, { assetCategory: ticker?.metadata.assetCategory })}-${formatMarketPriceWithCurrency(sparklineRange.max, currency, { assetCategory: ticker?.metadata.assetCategory })}`
120
+ : "";
121
+ const sparklineWidth = Math.max(8, width - (nativePaneChrome ? rangeLabel.length + 5 : 2));
122
+ const trend = quoteTrend(display?.change);
123
+ const terminalSparklineHeight = !nativePaneChrome && !stacked && height >= 4 ? 2 : 1;
124
+ const desktopPriceStyle = nativePaneChrome
125
+ ? {
126
+ fontSize: "22px",
127
+ lineHeight: "1.05",
128
+ fontWeight: 720,
129
+ textShadow: `0 1px 2px ${colors.bg}`,
130
+ }
131
+ : undefined;
132
+ const desktopChangeStyle = nativePaneChrome
133
+ ? {
134
+ fontSize: "12px",
135
+ lineHeight: "1.1",
136
+ fontWeight: 540,
137
+ textShadow: `0 1px 2px ${colors.bg}`,
138
+ }
139
+ : undefined;
140
+ const desktopSymbolStyle = nativePaneChrome
141
+ ? {
142
+ fontSize: "15px",
143
+ lineHeight: "18px",
144
+ }
145
+ : undefined;
146
+
147
+ const handleMouseDown = useDoubleClickActivation<string>({
148
+ onActivate: onOpen,
149
+ });
50
150
 
51
151
  return (
52
- <Box flexDirection="column" flexGrow={1} backgroundColor={colors.panel} padding={1}>
53
- <Box
54
- flexGrow={1}
55
- border
56
- borderColor={focused ? colors.borderFocused : colors.border}
57
- backgroundColor={colors.bg}
58
- paddingX={compact ? 2 : 4}
59
- paddingY={1}
60
- justifyContent="center"
61
- >
62
- {!display ? (
63
- <Box flexDirection="column" alignItems="center" justifyContent="center" gap={1} flexGrow={1}>
64
- <Text attributes={TextAttributes.BOLD} fg={colors.textBright}>
65
- {ticker.metadata.ticker}
152
+ <Box
153
+ flexDirection="column"
154
+ width={nativePaneChrome ? undefined : width}
155
+ height={nativePaneChrome ? undefined : height}
156
+ backgroundColor={colors.bg}
157
+ paddingX={nativePaneChrome ? 0 : 1}
158
+ paddingY={0}
159
+ overflow="hidden"
160
+ onMouseDown={(event: any) => {
161
+ event.preventDefault?.();
162
+ handleMouseDown(symbol, symbol, event);
163
+ }}
164
+ data-gloom-role="quote-monitor-card"
165
+ style={nativePaneChrome ? {
166
+ cursor: "pointer",
167
+ position: "relative",
168
+ width: "100%",
169
+ height: "100%",
170
+ paddingLeft: 10,
171
+ paddingRight: 10,
172
+ paddingTop: 6,
173
+ paddingBottom: 4,
174
+ borderRight: showRightDivider ? `1px solid ${colors.border}` : undefined,
175
+ borderBottom: showBottomDivider ? `1px solid ${colors.border}` : undefined,
176
+ } : undefined}
177
+ >
178
+ {nativePaneChrome && display && (
179
+ <PriceAreaSparklineBackground priceHistory={financials?.priceHistory} trend={trend} period={chartPeriod} />
180
+ )}
181
+ {!display ? (
182
+ <Box flexDirection="column" flexGrow={1} justifyContent="center">
183
+ <Text attributes={TextAttributes.BOLD} fg={colors.textBright} style={desktopSymbolStyle}>
184
+ {symbol}
185
+ </Text>
186
+ <Text fg={colors.textDim}>Waiting for quote...</Text>
187
+ </Box>
188
+ ) : nativePaneChrome ? (
189
+ <Box
190
+ flexGrow={1}
191
+ style={{
192
+ position: "relative",
193
+ zIndex: 1,
194
+ display: "grid",
195
+ gridTemplateColumns: "minmax(130px, 1fr) minmax(88px, auto)",
196
+ gridTemplateRows: "auto 1fr auto",
197
+ columnGap: 12,
198
+ width: "100%",
199
+ height: "100%",
200
+ }}
201
+ >
202
+ <Box
203
+ flexDirection="column"
204
+ minWidth={0}
205
+ style={{
206
+ gridColumn: "1",
207
+ gridRow: "1 / span 2",
208
+ minWidth: 0,
209
+ overflow: "hidden",
210
+ }}
211
+ >
212
+ <Text attributes={TextAttributes.BOLD} fg={colors.textBright} style={desktopSymbolStyle}>
213
+ {symbol}
66
214
  </Text>
67
- <Text fg={colors.textDim}>Waiting for quote...</Text>
215
+ {ticker?.metadata.name && (
216
+ <Text
217
+ fg={colors.textDim}
218
+ style={{
219
+ fontSize: "12px",
220
+ lineHeight: "14px",
221
+ maxWidth: "100%",
222
+ overflow: "hidden",
223
+ textOverflow: "ellipsis",
224
+ whiteSpace: "nowrap",
225
+ }}
226
+ >
227
+ {ticker.metadata.name}
228
+ </Text>
229
+ )}
68
230
  </Box>
69
- ) : (
231
+
70
232
  <Box
71
- flexDirection={compact ? "column" : "row"}
72
- alignItems={compact ? "flex-start" : "center"}
73
- justifyContent="space-between"
74
- gap={compact ? 1 : 2}
75
- flexGrow={1}
233
+ flexDirection="column"
234
+ alignItems="flex-end"
235
+ style={{
236
+ gridColumn: "2",
237
+ gridRow: "1",
238
+ justifySelf: "end",
239
+ backgroundColor: colors.bg,
240
+ borderRadius: 4,
241
+ boxShadow: `0 0 0 2px ${colors.bg}`,
242
+ paddingLeft: 6,
243
+ paddingBottom: 1,
244
+ }}
76
245
  >
77
- <Box flexGrow={1} justifyContent="center">
78
- <Text attributes={TextAttributes.BOLD} fg={colors.textBright}>
79
- {ticker.metadata.ticker}
80
- </Text>
246
+ <Text
247
+ attributes={TextAttributes.BOLD}
248
+ fg={changeColor}
249
+ style={desktopPriceStyle}
250
+ >
251
+ {priceText}
252
+ </Text>
253
+ <Box flexDirection="row" gap={1} justifyContent="flex-end">
254
+ <Text fg={changeColor} style={desktopChangeStyle}>{changePercentText}</Text>
255
+ <Text fg={changeColor} style={desktopChangeStyle}>{changeValueText}</Text>
81
256
  </Box>
257
+ </Box>
82
258
 
83
- <Box flexDirection="column" alignItems={compact ? "flex-start" : "flex-end"}>
84
- <Text attributes={TextAttributes.BOLD} fg={changeColor}>
85
- {formatMarketPriceWithCurrency(display.price, currency, { assetCategory: ticker.metadata.assetCategory })}
259
+ {rangeLabel && (
260
+ <Text
261
+ fg={colors.textDim}
262
+ style={{
263
+ gridColumn: "2",
264
+ gridRow: "3",
265
+ justifySelf: "end",
266
+ alignSelf: "end",
267
+ fontSize: "11px",
268
+ lineHeight: "13px",
269
+ backgroundColor: colors.bg,
270
+ paddingLeft: 6,
271
+ paddingRight: 2,
272
+ boxShadow: `0 0 0 2px ${colors.bg}`,
273
+ }}
274
+ >
275
+ {rangeLabel}
276
+ </Text>
277
+ )}
278
+ </Box>
279
+ ) : (
280
+ <Box flexDirection="column" flexGrow={1} justifyContent="flex-start">
281
+ {stacked ? (
282
+ <Box flexDirection="column">
283
+ <Text attributes={TextAttributes.BOLD} fg={colors.textBright} style={desktopSymbolStyle}>
284
+ {symbol}
86
285
  </Text>
87
- <Box flexDirection="row" gap={1}>
88
- <Text fg={changeColor}>{formatPercentRaw(display.changePercent)}</Text>
89
- <Text fg={changeColor}>{formatSignedMarketPrice(display.change, { assetCategory: ticker.metadata.assetCategory })}</Text>
286
+ <Box flexDirection="column">
287
+ <Text attributes={TextAttributes.BOLD} fg={changeColor} style={desktopPriceStyle}>
288
+ {priceText}
289
+ </Text>
290
+ <Box flexDirection="row" gap={1}>
291
+ <Text fg={changeColor} style={desktopChangeStyle}>{changePercentText}</Text>
292
+ <Text fg={changeColor} style={desktopChangeStyle}>{changeValueText}</Text>
293
+ </Box>
90
294
  </Box>
91
295
  </Box>
296
+ ) : (
297
+ <Box
298
+ flexDirection="row"
299
+ alignItems="flex-start"
300
+ justifyContent="space-between"
301
+ gap={1}
302
+ >
303
+ <Box flexDirection="column" flexGrow={1} minWidth={0}>
304
+ <Text attributes={TextAttributes.BOLD} fg={colors.textBright} style={desktopSymbolStyle}>
305
+ {symbol}
306
+ </Text>
307
+ {nativePaneChrome && ticker?.metadata.name && width >= 36 && (
308
+ <Text
309
+ fg={colors.textDim}
310
+ style={{
311
+ fontSize: "12px",
312
+ lineHeight: "16px",
313
+ overflow: "hidden",
314
+ textOverflow: "ellipsis",
315
+ whiteSpace: "nowrap",
316
+ }}
317
+ maxWidth={nameMaxWidth}
318
+ >
319
+ {ticker.metadata.name}
320
+ </Text>
321
+ )}
322
+ </Box>
323
+ <Box flexDirection="column" alignItems="flex-end">
324
+ <Text
325
+ attributes={TextAttributes.BOLD}
326
+ fg={changeColor}
327
+ style={desktopPriceStyle}
328
+ >
329
+ {priceText}
330
+ </Text>
331
+ <Box flexDirection="row" gap={1} justifyContent="flex-end">
332
+ <Text fg={changeColor} style={desktopChangeStyle}>{changePercentText}</Text>
333
+ <Text fg={changeColor} style={desktopChangeStyle}>{changeValueText}</Text>
334
+ </Box>
335
+ </Box>
336
+ </Box>
337
+ )}
338
+
339
+ <Box height={terminalSparklineHeight} flexDirection="row" alignItems="center" gap={1}>
340
+ <PriceSparkline
341
+ priceHistory={financials?.priceHistory}
342
+ width={sparklineWidth}
343
+ height={terminalSparklineHeight}
344
+ trend={trend}
345
+ period={chartPeriod}
346
+ area={!nativePaneChrome}
347
+ />
348
+ {nativePaneChrome && rangeLabel && (
349
+ <Text fg={colors.textDim} style={{ fontSize: "11px", lineHeight: "14px" }}>
350
+ {rangeLabel}
351
+ </Text>
352
+ )}
92
353
  </Box>
93
- )}
354
+ </Box>
355
+ )}
356
+ </Box>
357
+ );
358
+ }
359
+
360
+ export function QuoteMonitorPane({ paneId, focused, width, height }: PaneProps) {
361
+ const pane = usePaneInstance();
362
+ const { symbol: fallbackSymbol, ticker: fallbackTicker } = usePaneTicker();
363
+ const settings = useMemo(
364
+ () => getQuoteMonitorPaneSettings(pane?.settings, fallbackSymbol),
365
+ [fallbackSymbol, pane?.settings],
366
+ );
367
+ const symbols = settings.symbols;
368
+ const financialsBySymbol = useAppSelector((state) => state.financials);
369
+ const tickersBySymbol = useAppSelector((state) => state.tickers);
370
+ const streamingTargets = useMemo(() => (
371
+ symbols
372
+ .map((symbol) => {
373
+ const ticker = tickersBySymbol.get(symbol) ?? (fallbackTicker?.metadata.ticker === symbol ? fallbackTicker : null);
374
+ const target = quoteSubscriptionTargetFromTicker(ticker, symbol, "provider");
375
+ return target
376
+ ? { ...target, surface: "monitor" as const, visible: true, selected: symbol === fallbackSymbol, weight: symbol === fallbackSymbol ? 100 : 90 }
377
+ : null;
378
+ })
379
+ .filter((target): target is QuoteSubscriptionTarget => target != null)
380
+ ), [fallbackTicker, symbols, tickersBySymbol]);
381
+ useQuoteStreaming(streamingTargets);
382
+ const { pinTicker } = usePluginTickerActions();
383
+ const { openPaneSettings } = usePluginAppActions();
384
+ const { nativePaneChrome } = useUiCapabilities();
385
+ const openTicker = useCallback((nextSymbol: string) => {
386
+ pinTicker(nextSymbol, { paneType: "ticker-detail", floating: true });
387
+ }, [pinTicker]);
388
+ useShortcut((event) => {
389
+ if (!focused || event.name !== "t") return;
390
+ event.preventDefault?.();
391
+ event.stopPropagation?.();
392
+ openPaneSettings(paneId);
393
+ });
394
+
395
+ if (symbols.length === 0) {
396
+ return (
397
+ <Box flexDirection="column" flexGrow={1} paddingX={1}>
398
+ <EmptyState title="No tickers selected." />
94
399
  </Box>
400
+ );
401
+ }
402
+
403
+ const columns = resolveGridColumnCount(symbols.length, width, height, nativePaneChrome);
404
+ const rows = chunkSymbols(symbols, columns);
405
+ const contentWidth = Math.max(1, width - (nativePaneChrome ? 0 : 2));
406
+ const contentHeight = Math.max(3, height);
407
+ const cardHeight = Math.max(nativePaneChrome ? 4 : 3, Math.floor(contentHeight / rows.length));
408
+ const cardWidth = Math.max(1, Math.floor(contentWidth / columns));
409
+
410
+ if (nativePaneChrome) {
411
+ return (
412
+ <Box
413
+ flexGrow={1}
414
+ backgroundColor={colors.bg}
415
+ overflow="hidden"
416
+ style={{
417
+ display: "grid",
418
+ gridTemplateColumns: "repeat(auto-fit, minmax(min(240px, 100%), 1fr))",
419
+ gridAutoRows: "minmax(66px, 1fr)",
420
+ alignItems: "stretch",
421
+ alignContent: "stretch",
422
+ width: "100%",
423
+ height: "100%",
424
+ }}
425
+ >
426
+ {symbols.map((symbol, index) => {
427
+ const ticker = tickersBySymbol.get(symbol) ?? (fallbackTicker?.metadata.ticker === symbol ? fallbackTicker : null);
428
+ return (
429
+ <QuoteMonitorCard
430
+ key={symbol}
431
+ symbol={symbol}
432
+ ticker={ticker}
433
+ cachedFinancials={financialsBySymbol.get(symbol) ?? null}
434
+ width={width}
435
+ height={height}
436
+ showRightDivider={false}
437
+ showBottomDivider
438
+ chartPeriod={settings.chartPeriod}
439
+ onOpen={openTicker}
440
+ />
441
+ );
442
+ })}
443
+ </Box>
444
+ );
445
+ }
446
+
447
+ return (
448
+ <Box
449
+ flexDirection="column"
450
+ flexGrow={1}
451
+ backgroundColor={colors.bg}
452
+ paddingX={nativePaneChrome ? 0 : 1}
453
+ paddingY={0}
454
+ overflow="hidden"
455
+ >
456
+ {rows.map((row, rowIndex) => (
457
+ <Box key={`${rowIndex}:${row.join(",")}`} flexDirection="row" height={cardHeight}>
458
+ {row.map((symbol, columnIndex) => {
459
+ const ticker = tickersBySymbol.get(symbol) ?? (fallbackTicker?.metadata.ticker === symbol ? fallbackTicker : null);
460
+ return (
461
+ <QuoteMonitorCard
462
+ key={symbol}
463
+ symbol={symbol}
464
+ ticker={ticker}
465
+ cachedFinancials={financialsBySymbol.get(symbol) ?? null}
466
+ width={cardWidth}
467
+ height={cardHeight}
468
+ showRightDivider={columnIndex < row.length - 1}
469
+ showBottomDivider={rowIndex < rows.length - 1}
470
+ chartPeriod={settings.chartPeriod}
471
+ onOpen={openTicker}
472
+ />
473
+ );
474
+ })}
475
+ </Box>
476
+ ))}
95
477
  </Box>
96
478
  );
97
479
  }
@@ -8,7 +8,9 @@ import {
8
8
  DEFAULT_TICKER_CHART_RESOLUTION,
9
9
  normalizeChartResolution,
10
10
  } from "../../../components/chart/chart-resolution";
11
+ import type { PriceSparklinePeriod } from "../../../components/price-sparkline-view";
11
12
  import { getSharedRegistry } from "../../registry";
13
+ import { formatTickerListInput, MAX_TICKER_LIST_SIZE, parseTickerListInput } from "../../../utils/ticker-list";
12
14
 
13
15
  type DetailTabSummary = { id: string; name: string; order: number };
14
16
 
@@ -24,6 +26,14 @@ export interface TickerDetailPaneSettings {
24
26
  chartResolution: ReturnType<typeof normalizeChartResolution>;
25
27
  }
26
28
 
29
+ export interface QuoteMonitorPaneSettings {
30
+ symbols: string[];
31
+ symbolsText: string;
32
+ chartPeriod: PriceSparklinePeriod;
33
+ }
34
+
35
+ const DEFAULT_QUOTE_MONITOR_CHART_PERIOD: PriceSparklinePeriod = "1M";
36
+
27
37
  export function getTickerDetailPaneSettings(
28
38
  settings: Record<string, unknown> | undefined,
29
39
  ): TickerDetailPaneSettings {
@@ -100,15 +110,86 @@ export function buildTickerDetailSettingsDef(settings: TickerDetailPaneSettings)
100
110
  };
101
111
  }
102
112
 
113
+ function coerceQuoteMonitorSymbols(value: unknown): string[] {
114
+ if (!Array.isArray(value)) return [];
115
+ const parsed = value.filter((entry): entry is string => typeof entry === "string");
116
+ try {
117
+ return parseTickerListInput(parsed.join(", "), MAX_TICKER_LIST_SIZE);
118
+ } catch {
119
+ return parsed.slice(0, MAX_TICKER_LIST_SIZE);
120
+ }
121
+ }
122
+
123
+ export function buildQuoteMonitorPaneTitle(symbols: string[]): string {
124
+ if (symbols.length === 0) return "Quote Monitor";
125
+ if (symbols.length <= 3) return symbols.join(" · ");
126
+ return `${symbols.slice(0, 2).join(" · ")} +${symbols.length - 2}`;
127
+ }
128
+
129
+ export function getQuoteMonitorPaneSettings(
130
+ settings: Record<string, unknown> | undefined,
131
+ fallbackSymbol?: string | null,
132
+ ): QuoteMonitorPaneSettings {
133
+ const storedSymbols = coerceQuoteMonitorSymbols(settings?.symbols);
134
+ const legacySymbol = typeof settings?.symbol === "string" ? settings.symbol.trim() : "";
135
+ const storedText = typeof settings?.symbolsText === "string" ? settings.symbolsText : "";
136
+ let symbols = storedSymbols;
137
+
138
+ if (symbols.length === 0 && storedText.trim().length > 0) {
139
+ try {
140
+ symbols = parseTickerListInput(storedText, MAX_TICKER_LIST_SIZE);
141
+ } catch {
142
+ symbols = [];
143
+ }
144
+ }
145
+ if (symbols.length === 0 && legacySymbol) {
146
+ try {
147
+ symbols = parseTickerListInput(legacySymbol, MAX_TICKER_LIST_SIZE);
148
+ } catch {
149
+ symbols = [legacySymbol.toUpperCase()];
150
+ }
151
+ }
152
+ if (symbols.length === 0 && fallbackSymbol) {
153
+ symbols = [fallbackSymbol.toUpperCase()];
154
+ }
155
+
156
+ return {
157
+ symbols,
158
+ symbolsText: storedText.trim().length > 0 ? storedText : formatTickerListInput(symbols),
159
+ chartPeriod: settings?.chartPeriod === "1D"
160
+ || settings?.chartPeriod === "1W"
161
+ || settings?.chartPeriod === "1M"
162
+ || settings?.chartPeriod === "1Y"
163
+ ? settings.chartPeriod
164
+ : DEFAULT_QUOTE_MONITOR_CHART_PERIOD,
165
+ };
166
+ }
167
+
103
168
  export function buildQuoteMonitorSettingsDef(): PaneSettingsDef {
104
169
  return {
105
170
  title: "Quote Monitor Settings",
171
+ values: {
172
+ chartPeriod: DEFAULT_QUOTE_MONITOR_CHART_PERIOD,
173
+ },
106
174
  fields: [
107
175
  {
108
- key: "symbol",
109
- label: "Ticker",
176
+ key: "symbolsText",
177
+ label: "Tickers",
178
+ description: `Enter up to ${MAX_TICKER_LIST_SIZE} tickers separated by commas.`,
110
179
  type: "text",
111
- placeholder: "AAPL",
180
+ placeholder: "AAPL, MSFT, NVDA",
181
+ },
182
+ {
183
+ key: "chartPeriod",
184
+ label: "Chart Period",
185
+ description: "Set the period used for quote sparklines and range labels.",
186
+ type: "select",
187
+ options: [
188
+ { value: "1D", label: "1D" },
189
+ { value: "1W", label: "1W" },
190
+ { value: "1M", label: "1M" },
191
+ { value: "1Y", label: "1Y" },
192
+ ],
112
193
  },
113
194
  ],
114
195
  };
@@ -820,7 +820,7 @@ describe("TickerDetailPane", () => {
820
820
  expect(frame).toContain("+25.00%");
821
821
  });
822
822
 
823
- test("falls back to Overview when a hidden active tab becomes unavailable", async () => {
823
+ test("renders an Overview fallback without overwriting a temporarily unavailable active tab", async () => {
824
824
  const gatewayConfig = createDetailConfig("AAPL", [createGatewayInstance()]);
825
825
  const noGatewayConfig = createDetailConfig("AAPL");
826
826
  setSharedRegistryForTests(makeRegistry());
@@ -844,7 +844,7 @@ describe("TickerDetailPane", () => {
844
844
  await flushFrame();
845
845
 
846
846
  const frame = testSetup.captureCharFrame();
847
- expect(frame).toContain("active:overview");
847
+ expect(frame).toContain("active:ibkr-trade");
848
848
  expect(frame).not.toContain("Trade");
849
849
  });
850
850