gloomberb 0.7.1 → 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 (131) 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 +47 -7
  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 +67 -11
  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/ui/host.tsx +8 -2
  124. package/src/ui/index.tsx +1 -0
  125. package/src/utils/api-client.test.ts +128 -0
  126. package/src/utils/api-client.ts +112 -7
  127. package/src/utils/fuzzy-search.ts +12 -1
  128. package/src/utils/keyboard.ts +31 -0
  129. package/src/utils/ticker-search.test.ts +2 -2
  130. package/src/utils/ticker-search.ts +48 -1
  131. package/src/version.ts +1 -1
@@ -0,0 +1,463 @@
1
+ import { useMemo } from "react";
2
+ import {
3
+ Box,
4
+ ChartSurface,
5
+ Text,
6
+ useNativeRenderer,
7
+ useUiCapabilities,
8
+ useUiHost,
9
+ type BitmapSurface,
10
+ } from "../ui";
11
+ import { colors } from "../theme/colors";
12
+ import { useThemeColors } from "../theme/theme-context";
13
+ import type { PricePoint } from "../types/financials";
14
+ import { computeBitmapSize } from "./chart/native/chart-rasterizer";
15
+ import { renderPriceSparkline } from "./price-sparkline";
16
+
17
+ export const PRICE_SPARKLINE_COLUMN_ID = "sparkline";
18
+ export const PRICE_SPARKLINE_PERIOD_LABEL = "1M";
19
+
20
+ const SPARKLINE_HEIGHT = 1;
21
+ const SPARKLINE_FALLBACK_POINTS = 22;
22
+ export type PriceSparklineTrend = "positive" | "negative" | "neutral";
23
+ export type PriceSparklinePeriod = "1D" | "1W" | "1M" | "1Y";
24
+
25
+ const PERIOD_WINDOW_DAYS: Record<PriceSparklinePeriod, number> = {
26
+ "1D": 1,
27
+ "1W": 7,
28
+ "1M": 30,
29
+ "1Y": 365,
30
+ };
31
+
32
+ const PERIOD_FALLBACK_POINTS: Record<PriceSparklinePeriod, number> = {
33
+ "1D": 2,
34
+ "1W": 7,
35
+ "1M": SPARKLINE_FALLBACK_POINTS,
36
+ "1Y": 252,
37
+ };
38
+
39
+ interface SparklineSample {
40
+ x: number;
41
+ y: number;
42
+ }
43
+
44
+ interface SparklineBitmapOptions {
45
+ area?: boolean;
46
+ }
47
+
48
+ function closeValue(point: PricePoint): number | null {
49
+ return Number.isFinite(point.close) ? point.close : null;
50
+ }
51
+
52
+ function getPointTime(point: PricePoint): number {
53
+ const value = point.date as Date | string | number | null | undefined;
54
+ if (value instanceof Date) return value.getTime();
55
+ if (value == null) return Number.NaN;
56
+ return new Date(value).getTime();
57
+ }
58
+
59
+ function resolveSparklineHistory(priceHistory: PricePoint[], period: PriceSparklinePeriod = "1M"): PricePoint[] {
60
+ const validHistory = priceHistory.filter((point) => Number.isFinite(getPointTime(point)));
61
+ const latest = validHistory.at(-1);
62
+ if (!latest) return [];
63
+
64
+ const latestTime = getPointTime(latest);
65
+ if (Number.isFinite(latestTime)) {
66
+ const cutoffTime = latestTime - PERIOD_WINDOW_DAYS[period] * 86_400_000;
67
+ const windowHistory = validHistory.filter((point) => getPointTime(point) >= cutoffTime);
68
+ if (windowHistory.length >= 2) return windowHistory;
69
+ }
70
+
71
+ return validHistory.slice(-PERIOD_FALLBACK_POINTS[period]);
72
+ }
73
+
74
+ function sparklineValues(priceHistory: PricePoint[]): number[] {
75
+ return priceHistory
76
+ .map(closeValue)
77
+ .filter((value): value is number => value != null);
78
+ }
79
+
80
+ function sparklineColor(values: number[], trend?: PriceSparklineTrend): string {
81
+ if (trend === "positive") return colors.positive;
82
+ if (trend === "negative") return colors.negative;
83
+ if (trend === "neutral") return colors.textMuted;
84
+
85
+ const first = values[0];
86
+ const last = values.at(-1);
87
+ if (first == null || last == null) return colors.textMuted;
88
+ return last >= first ? colors.positive : colors.negative;
89
+ }
90
+
91
+ function buildSamples(values: number[], width: number, height: number, padding: number): SparklineSample[] {
92
+ if (values.length < 2) return [];
93
+ const min = Math.min(...values);
94
+ const max = Math.max(...values);
95
+ const range = max - min || 1;
96
+ const left = padding;
97
+ const right = Math.max(left, width - padding);
98
+ const top = padding;
99
+ const bottom = Math.max(top, height - padding);
100
+ return values.map((value, index) => ({
101
+ x: left + (index / Math.max(values.length - 1, 1)) * (right - left),
102
+ y: bottom - ((value - min) / range) * (bottom - top),
103
+ }));
104
+ }
105
+
106
+ function svgPath(samples: SparklineSample[]): string {
107
+ return samples
108
+ .map((sample, index) => `${index === 0 ? "M" : "L"}${sample.x.toFixed(2)} ${sample.y.toFixed(2)}`)
109
+ .join(" ");
110
+ }
111
+
112
+ function svgAreaPath(samples: SparklineSample[], baseline: number): string {
113
+ const linePath = svgPath(samples);
114
+ if (!linePath || samples.length === 0) return "";
115
+ const first = samples[0]!;
116
+ const last = samples[samples.length - 1]!;
117
+ return `${linePath} L${last.x.toFixed(2)} ${baseline.toFixed(2)} L${first.x.toFixed(2)} ${baseline.toFixed(2)} Z`;
118
+ }
119
+
120
+ function parseHex(hex: string, alpha = 1) {
121
+ const normalized = hex.replace("#", "");
122
+ return {
123
+ r: parseInt(normalized.slice(0, 2), 16),
124
+ g: parseInt(normalized.slice(2, 4), 16),
125
+ b: parseInt(normalized.slice(4, 6), 16),
126
+ a: Math.round(Math.max(0, Math.min(1, alpha)) * 255),
127
+ };
128
+ }
129
+
130
+ function colorWithAlpha(color: string, alpha: number): string {
131
+ if (!/^#[0-9a-f]{6}$/i.test(color)) return color;
132
+ const parsed = parseHex(color, alpha);
133
+ return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${Math.max(0, Math.min(1, alpha))})`;
134
+ }
135
+
136
+ function blendPixel(
137
+ pixels: Uint8Array,
138
+ width: number,
139
+ height: number,
140
+ x: number,
141
+ y: number,
142
+ color: ReturnType<typeof parseHex>,
143
+ opacity: number,
144
+ ) {
145
+ if (x < 0 || y < 0 || x >= width || y >= height || opacity <= 0) return;
146
+ const alpha = Math.max(0, Math.min(1, (color.a / 255) * opacity));
147
+ const offset = (y * width + x) * 4;
148
+ const dstAlpha = pixels[offset + 3]! / 255;
149
+ const outAlpha = alpha + dstAlpha * (1 - alpha);
150
+ if (outAlpha <= 0) return;
151
+
152
+ const dstFactor = dstAlpha * (1 - alpha);
153
+ pixels[offset] = Math.round((color.r * alpha + pixels[offset]! * dstFactor) / outAlpha);
154
+ pixels[offset + 1] = Math.round((color.g * alpha + pixels[offset + 1]! * dstFactor) / outAlpha);
155
+ pixels[offset + 2] = Math.round((color.b * alpha + pixels[offset + 2]! * dstFactor) / outAlpha);
156
+ pixels[offset + 3] = Math.round(outAlpha * 255);
157
+ }
158
+
159
+ function drawSegment(
160
+ pixels: Uint8Array,
161
+ width: number,
162
+ height: number,
163
+ start: SparklineSample,
164
+ end: SparklineSample,
165
+ color: ReturnType<typeof parseHex>,
166
+ thickness: number,
167
+ ) {
168
+ const half = thickness / 2;
169
+ const minX = Math.floor(Math.min(start.x, end.x) - half - 1);
170
+ const maxX = Math.ceil(Math.max(start.x, end.x) + half + 1);
171
+ const minY = Math.floor(Math.min(start.y, end.y) - half - 1);
172
+ const maxY = Math.ceil(Math.max(start.y, end.y) + half + 1);
173
+ const dx = end.x - start.x;
174
+ const dy = end.y - start.y;
175
+ const lengthSq = dx * dx + dy * dy || 1;
176
+
177
+ for (let y = minY; y <= maxY; y++) {
178
+ for (let x = minX; x <= maxX; x++) {
179
+ const px = x + 0.5;
180
+ const py = y + 0.5;
181
+ const t = Math.max(0, Math.min(1, ((px - start.x) * dx + (py - start.y) * dy) / lengthSq));
182
+ const nearestX = start.x + dx * t;
183
+ const nearestY = start.y + dy * t;
184
+ const distance = Math.hypot(px - nearestX, py - nearestY);
185
+ const coverage = Math.max(0, Math.min(1, half + 0.9 - distance));
186
+ blendPixel(pixels, width, height, x, y, color, coverage);
187
+ }
188
+ }
189
+ }
190
+
191
+ function drawAreaFill(
192
+ pixels: Uint8Array,
193
+ width: number,
194
+ height: number,
195
+ samples: SparklineSample[],
196
+ baseline: number,
197
+ color: ReturnType<typeof parseHex>,
198
+ ) {
199
+ if (samples.length < 2) return;
200
+
201
+ const first = samples[0]!;
202
+ const last = samples[samples.length - 1]!;
203
+ const startX = Math.max(0, Math.floor(first.x));
204
+ const endX = Math.min(width - 1, Math.ceil(last.x));
205
+ let segmentIndex = 0;
206
+
207
+ for (let x = startX; x <= endX; x++) {
208
+ const centerX = x + 0.5;
209
+ while (segmentIndex < samples.length - 2 && samples[segmentIndex + 1]!.x < centerX) {
210
+ segmentIndex++;
211
+ }
212
+
213
+ const start = samples[segmentIndex]!;
214
+ const end = samples[segmentIndex + 1]!;
215
+ const dx = end.x - start.x;
216
+ const t = dx === 0 ? 0 : Math.max(0, Math.min(1, (centerX - start.x) / dx));
217
+ const lineY = start.y + (end.y - start.y) * t;
218
+ const top = Math.max(0, Math.ceil(Math.min(lineY, baseline)));
219
+ const bottom = Math.min(height - 1, Math.floor(Math.max(lineY, baseline)));
220
+ const distance = Math.max(1, bottom - top);
221
+
222
+ for (let y = top; y <= bottom; y++) {
223
+ const fade = 1 - ((y - top) / distance) * 0.55;
224
+ blendPixel(pixels, width, height, x, y, color, fade);
225
+ }
226
+ }
227
+ }
228
+
229
+ function renderSparklineBitmap(
230
+ values: number[],
231
+ width: number,
232
+ height: number,
233
+ color: string,
234
+ options: SparklineBitmapOptions = {},
235
+ ): BitmapSurface | null {
236
+ if (values.length < 2 || width <= 0 || height <= 0) return null;
237
+ const pixels = new Uint8Array(width * height * 4);
238
+ const padding = options.area ? Math.max(1, Math.round(height * 0.06)) : Math.max(1, Math.round(height * 0.18));
239
+ const samples = buildSamples(values, width - 1, height - 1, padding);
240
+ const fillColor = parseHex(color, 0.18);
241
+ const glowColor = parseHex(color, options.area ? 0.06 : 0.18);
242
+ const lineColor = parseHex(color, 0.96);
243
+
244
+ if (options.area) {
245
+ drawAreaFill(pixels, width, height, samples, height - 1, fillColor);
246
+ }
247
+ for (let index = 0; index < samples.length - 1; index++) {
248
+ drawSegment(
249
+ pixels,
250
+ width,
251
+ height,
252
+ samples[index]!,
253
+ samples[index + 1]!,
254
+ glowColor,
255
+ options.area ? Math.max(1.2, height * 0.08) : Math.max(2, height * 0.24),
256
+ );
257
+ }
258
+ for (let index = 0; index < samples.length - 1; index++) {
259
+ drawSegment(
260
+ pixels,
261
+ width,
262
+ height,
263
+ samples[index]!,
264
+ samples[index + 1]!,
265
+ lineColor,
266
+ options.area ? Math.max(1.1, height * 0.055) : Math.max(1.25, height * 0.1),
267
+ );
268
+ }
269
+
270
+ return { width, height, pixels };
271
+ }
272
+
273
+ function DesktopPriceSparkline({
274
+ values,
275
+ width,
276
+ height,
277
+ color,
278
+ }: {
279
+ values: number[];
280
+ width: number;
281
+ height: number;
282
+ color: string;
283
+ }) {
284
+ const svgWidth = Math.max(24, width * 8);
285
+ const svgHeight = Math.max(18, height * 18);
286
+ const path = svgPath(buildSamples(values, svgWidth, svgHeight, 2));
287
+ if (!path) return <Text fg={colors.textMuted}>{" "}</Text>;
288
+
289
+ return (
290
+ <Box width={width} height={height} justifyContent="center" overflow="hidden">
291
+ <svg
292
+ viewBox={`0 0 ${svgWidth} ${svgHeight}`}
293
+ width="100%"
294
+ height="100%"
295
+ aria-hidden="true"
296
+ focusable="false"
297
+ style={{ display: "block" }}
298
+ >
299
+ <path
300
+ d={path}
301
+ fill="none"
302
+ stroke={color}
303
+ strokeWidth="1.7"
304
+ strokeLinecap="round"
305
+ strokeLinejoin="round"
306
+ vectorEffect="non-scaling-stroke"
307
+ />
308
+ </svg>
309
+ </Box>
310
+ );
311
+ }
312
+
313
+ function TerminalPriceSparkline({
314
+ priceHistory,
315
+ values,
316
+ width,
317
+ height,
318
+ color,
319
+ area = false,
320
+ }: {
321
+ priceHistory: PricePoint[];
322
+ values: number[];
323
+ width: number;
324
+ height: number;
325
+ color: string;
326
+ area?: boolean;
327
+ }) {
328
+ const { nativeCharts, cellWidthPx = 8, cellHeightPx = 18, pixelRatio = 1 } = useUiCapabilities();
329
+ const nativeRenderer = useNativeRenderer();
330
+ const rendererResolution = nativeRenderer.resolution;
331
+ const rendererTerminalWidth = nativeRenderer.terminalWidth;
332
+ const rendererTerminalHeight = nativeRenderer.terminalHeight;
333
+ const bitmap = useMemo(() => {
334
+ if (!nativeCharts || values.length < 2) return null;
335
+ const scale = Math.max(1, pixelRatio);
336
+ const bitmapSize = rendererResolution && rendererTerminalWidth > 0 && rendererTerminalHeight > 0
337
+ ? computeBitmapSize(
338
+ { x: 0, y: 0, width, height },
339
+ rendererResolution,
340
+ rendererTerminalWidth,
341
+ rendererTerminalHeight,
342
+ )
343
+ : {
344
+ pixelWidth: Math.max(1, Math.round(width * cellWidthPx * scale)),
345
+ pixelHeight: Math.max(1, Math.round(height * cellHeightPx * scale)),
346
+ };
347
+ return renderSparklineBitmap(values, bitmapSize.pixelWidth, bitmapSize.pixelHeight, color, { area });
348
+ }, [
349
+ area,
350
+ cellHeightPx,
351
+ cellWidthPx,
352
+ color,
353
+ height,
354
+ nativeCharts,
355
+ pixelRatio,
356
+ rendererResolution,
357
+ rendererTerminalHeight,
358
+ rendererTerminalWidth,
359
+ values,
360
+ width,
361
+ ]);
362
+ const fallback = useMemo(
363
+ () => renderPriceSparkline(priceHistory, { width, height, maxPoints: priceHistory.length }),
364
+ [height, priceHistory, width],
365
+ );
366
+
367
+ return (
368
+ <ChartSurface width={width} height={height} flexDirection="column" bitmaps={bitmap ? [bitmap] : null}>
369
+ {fallback ? <Text content={fallback} /> : null}
370
+ </ChartSurface>
371
+ );
372
+ }
373
+
374
+ export function PriceSparkline({
375
+ priceHistory,
376
+ width,
377
+ trend,
378
+ period = "1M",
379
+ height = SPARKLINE_HEIGHT,
380
+ area = false,
381
+ }: {
382
+ priceHistory: PricePoint[] | undefined;
383
+ width: number;
384
+ trend?: PriceSparklineTrend;
385
+ period?: PriceSparklinePeriod;
386
+ height?: number;
387
+ area?: boolean;
388
+ }) {
389
+ useThemeColors();
390
+ const sparklineHistory = useMemo(() => resolveSparklineHistory(priceHistory ?? [], period), [period, priceHistory]);
391
+ const values = useMemo(() => sparklineValues(sparklineHistory), [sparklineHistory]);
392
+ if (values.length < 2) {
393
+ return <Text fg={colors.textMuted}>{" "}</Text>;
394
+ }
395
+
396
+ const color = sparklineColor(values, trend);
397
+ return useUiHost().kind === "desktop-web"
398
+ ? <DesktopPriceSparkline values={values} width={width} height={height} color={color} />
399
+ : <TerminalPriceSparkline priceHistory={sparklineHistory} values={values} width={width} height={height} color={color} area={area} />;
400
+ }
401
+
402
+ export function PriceAreaSparklineBackground({
403
+ priceHistory,
404
+ trend,
405
+ period = "1M",
406
+ }: {
407
+ priceHistory: PricePoint[] | undefined;
408
+ trend?: PriceSparklineTrend;
409
+ period?: PriceSparklinePeriod;
410
+ }) {
411
+ useThemeColors();
412
+ const uiHost = useUiHost();
413
+ const sparklineHistory = useMemo(() => resolveSparklineHistory(priceHistory ?? [], period), [period, priceHistory]);
414
+ const values = useMemo(() => sparklineValues(sparklineHistory), [sparklineHistory]);
415
+ if (uiHost.kind !== "desktop-web" || values.length < 2) return null;
416
+
417
+ const color = sparklineColor(values, trend);
418
+ const baseline = 100;
419
+ const samples = buildSamples(values, 100, baseline, 0);
420
+ const linePath = svgPath(samples);
421
+ const areaPath = svgAreaPath(samples, baseline);
422
+ if (!linePath || !areaPath) return null;
423
+
424
+ return (
425
+ <svg
426
+ viewBox="0 0 100 100"
427
+ preserveAspectRatio="none"
428
+ aria-hidden="true"
429
+ focusable="false"
430
+ style={{
431
+ position: "absolute",
432
+ inset: 0,
433
+ width: "100%",
434
+ height: "100%",
435
+ display: "block",
436
+ pointerEvents: "none",
437
+ }}
438
+ >
439
+ <path d={areaPath} fill={colorWithAlpha(color, 0.08)} />
440
+ <path
441
+ d={linePath}
442
+ fill="none"
443
+ stroke={colorWithAlpha(color, 0.46)}
444
+ strokeWidth="1.8"
445
+ strokeLinecap="round"
446
+ strokeLinejoin="round"
447
+ vectorEffect="non-scaling-stroke"
448
+ />
449
+ </svg>
450
+ );
451
+ }
452
+
453
+ export function resolvePriceSparklineRange(
454
+ priceHistory: PricePoint[] | undefined,
455
+ period: PriceSparklinePeriod = "1M",
456
+ ): { min: number; max: number } | null {
457
+ const values = sparklineValues(resolveSparklineHistory(priceHistory ?? [], period));
458
+ if (values.length < 2) return null;
459
+ return {
460
+ min: Math.min(...values),
461
+ max: Math.max(...values),
462
+ };
463
+ }
@@ -0,0 +1,46 @@
1
+ import type { PricePoint } from "../types/financials";
2
+ import { colors } from "../theme/colors";
3
+ import { renderChart, resolveChartPalette, type StyledContent } from "./chart/chart-renderer";
4
+ import type { ProjectedChartPoint } from "./chart/chart-data";
5
+
6
+ function coercePointDate(value: PricePoint["date"]): Date {
7
+ return value instanceof Date ? value : new Date(value as Date | string | number);
8
+ }
9
+
10
+ export function renderPriceSparkline(
11
+ priceHistory: PricePoint[],
12
+ options: { width?: number; height?: number; maxPoints?: number } = {},
13
+ ): StyledContent | null {
14
+ if (priceHistory.length < 2) return null;
15
+
16
+ const width = Math.max(4, Math.floor(options.width ?? 10));
17
+ const height = Math.max(1, Math.floor(options.height ?? 1));
18
+ const maxPoints = Math.max(1, Math.floor(options.maxPoints ?? Math.max(20, width * 2)));
19
+ const pointsWindow = priceHistory.slice(-maxPoints);
20
+ const points: ProjectedChartPoint[] = pointsWindow.map((pt) => ({
21
+ date: coercePointDate(pt.date),
22
+ open: pt.open ?? pt.close,
23
+ high: pt.high ?? pt.close,
24
+ low: pt.low ?? pt.close,
25
+ close: pt.close,
26
+ volume: pt.volume ?? 0,
27
+ }));
28
+
29
+ const first = pointsWindow[0]?.close ?? 0;
30
+ const last = pointsWindow[pointsWindow.length - 1]?.close ?? 0;
31
+ const trend = last > first ? "positive" : last < first ? "negative" : "neutral";
32
+ const palette = resolveChartPalette(colors, trend);
33
+
34
+ const result = renderChart(points, {
35
+ width,
36
+ height,
37
+ showVolume: false,
38
+ volumeHeight: 0,
39
+ cursorX: null,
40
+ cursorY: null,
41
+ mode: "line",
42
+ colors: palette,
43
+ });
44
+
45
+ return result.lines[0] ?? null;
46
+ }
@@ -7,13 +7,34 @@ import {
7
7
  type RefObject,
8
8
  } from "react";
9
9
  import { Box, type ScrollBoxRenderable } from "../ui";
10
+ import { isPlainKeyboardEvent } from "../utils/keyboard";
11
+
12
+ function listenToScrollBarChange(
13
+ scrollBar: ScrollBoxRenderable["verticalScrollBar"],
14
+ handler: () => void,
15
+ ): (() => void) | null {
16
+ if (
17
+ !scrollBar
18
+ || typeof scrollBar.on !== "function"
19
+ || typeof scrollBar.off !== "function"
20
+ ) {
21
+ return null;
22
+ }
23
+
24
+ scrollBar.on("change", handler);
25
+ return () => scrollBar.off?.("change", handler);
26
+ }
10
27
 
11
28
  export interface TableViewKeyEvent {
12
29
  name?: string;
13
30
  ctrl?: boolean;
14
31
  meta?: boolean;
32
+ super?: boolean;
33
+ alt?: boolean;
15
34
  option?: boolean;
16
35
  shift?: boolean;
36
+ readonly defaultPrevented?: boolean;
37
+ readonly propagationStopped?: boolean;
17
38
  preventDefault?: () => void;
18
39
  stopPropagation?: () => void;
19
40
  }
@@ -38,7 +59,8 @@ export function TableViewFrame({
38
59
  return (
39
60
  <Box
40
61
  flexDirection="column"
41
- flexGrow={1}
62
+ flexGrow={height == null ? 1 : undefined}
63
+ flexShrink={height == null ? undefined : 0}
42
64
  width={width}
43
65
  height={height}
44
66
  backgroundColor={backgroundColor}
@@ -55,11 +77,23 @@ export function isTableActivationKey(name: string | undefined): boolean {
55
77
  return name === "enter" || name === "return";
56
78
  }
57
79
 
58
- export function isNextTableRowKey(name: string | undefined): boolean {
80
+ function getTableKeyName(event: TableViewKeyEvent | string | undefined): string | undefined {
81
+ return typeof event === "string" ? event : event?.name;
82
+ }
83
+
84
+ function isPlainTableKey(event: TableViewKeyEvent | string | undefined): boolean {
85
+ return typeof event !== "object" || isPlainKeyboardEvent(event);
86
+ }
87
+
88
+ export function isNextTableRowKey(event: TableViewKeyEvent | string | undefined): boolean {
89
+ const name = getTableKeyName(event);
90
+ if (!isPlainTableKey(event)) return false;
59
91
  return name === "j" || name === "down";
60
92
  }
61
93
 
62
- export function isPreviousTableRowKey(name: string | undefined): boolean {
94
+ export function isPreviousTableRowKey(event: TableViewKeyEvent | string | undefined): boolean {
95
+ const name = getTableKeyName(event);
96
+ if (!isPlainTableKey(event)) return false;
63
97
  return name === "k" || name === "up";
64
98
  }
65
99
 
@@ -172,13 +206,19 @@ export function useScrollBoxScrollActivity({
172
206
  });
173
207
  };
174
208
 
175
- scrollBox.verticalScrollBar.on("change", handleVerticalChange);
176
- scrollBox.horizontalScrollBar.on("change", handleHorizontalChange);
209
+ const removeVerticalListener = listenToScrollBarChange(
210
+ scrollBox.verticalScrollBar,
211
+ handleVerticalChange,
212
+ );
213
+ const removeHorizontalListener = listenToScrollBarChange(
214
+ scrollBox.horizontalScrollBar,
215
+ handleHorizontalChange,
216
+ );
177
217
 
178
218
  return () => {
179
219
  active = false;
180
- scrollBox.verticalScrollBar.off("change", handleVerticalChange);
181
- scrollBox.horizontalScrollBar.off("change", handleHorizontalChange);
220
+ removeVerticalListener?.();
221
+ removeHorizontalListener?.();
182
222
  };
183
223
  }, [scrollRef]);
184
224
  }
@@ -162,18 +162,19 @@ export function TickerListTableView({
162
162
  }, [activateIndex, selectedIndex, tableProps.tickers.length]);
163
163
 
164
164
  useShortcut((event) => {
165
+ if (event.defaultPrevented || event.propagationStopped) return;
165
166
  if (!focused || !keyboardNavigation) return;
166
167
 
167
168
  if (onRootKeyDown?.(event)) return;
168
169
  if (tableProps.tickers.length === 0) return;
169
170
 
170
- if (isNextTableRowKey(event.name)) {
171
+ if (isNextTableRowKey(event)) {
171
172
  stopTableKey(event);
172
173
  selectByOffset(1);
173
174
  return;
174
175
  }
175
176
 
176
- if (isPreviousTableRowKey(event.name)) {
177
+ if (isPreviousTableRowKey(event)) {
177
178
  stopTableKey(event);
178
179
  selectByOffset(-1);
179
180
  return;