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
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { act, type ReactElement } from "react";
2
+ import { act, useState, type ReactElement } from "react";
3
3
  import { createTestRenderer } from "@opentui/core/testing";
4
4
  import { createOpenTuiTestRoot as createRoot } from "../../renderers/opentui/test-utils";
5
5
  import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
@@ -301,7 +301,6 @@ describe("comparisonChartPlugin", () => {
301
301
  expect(frame).toContain("1:1D");
302
302
  expect(frame).toContain("2:1W");
303
303
  expect(frame).toContain("1D");
304
- expect(frame).toContain("view:");
305
304
  expect(frame).toContain("[t]ickers");
306
305
  expect(frame).toContain("[m]ode");
307
306
  expect(frame).toContain("[r]es");
@@ -312,6 +311,119 @@ describe("comparisonChartPlugin", () => {
312
311
  expect(frame).not.toContain("side by side");
313
312
  });
314
313
 
314
+ test("updates every legend value from the crosshair position", async () => {
315
+ const provider = createProvider({
316
+ AAPL: [100, 102, 104],
317
+ MSFT: [200, 202, 204],
318
+ }, {
319
+ AAPL: "USD",
320
+ MSFT: "USD",
321
+ });
322
+ setSharedMarketDataForTests(provider);
323
+ sharedCoordinator = new MarketDataCoordinator(provider);
324
+ setSharedMarketDataCoordinator(sharedCoordinator);
325
+ setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
326
+
327
+ await mountComparisonHarness({
328
+ axisMode: "percent",
329
+ symbols: ["AAPL", "MSFT"],
330
+ symbolsText: "AAPL, MSFT",
331
+ }, [
332
+ makeTicker("AAPL", "USD"),
333
+ makeTicker("MSFT", "USD"),
334
+ ], [
335
+ ["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
336
+ ["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
337
+ ]);
338
+
339
+ await flushFrames();
340
+
341
+ let frame = testSetup!.captureCharFrame();
342
+ expect(frame).not.toContain("AAPL - 1D");
343
+ expect(frame).toContain("AAPL $104.00 +4.00%");
344
+ expect(frame).toContain("MSFT $204.00 +2.00%");
345
+
346
+ await act(async () => {
347
+ await testSetup!.mockMouse.moveTo(2, 4);
348
+ await testSetup!.renderOnce();
349
+ });
350
+ await flushFrames();
351
+
352
+ frame = testSetup!.captureCharFrame();
353
+ expect(frame).toContain("AAPL $100.00 0.00%");
354
+ expect(frame).toContain("MSFT $200.00 0.00%");
355
+ });
356
+
357
+ test("does not refetch resolution support when only comparison prices update", async () => {
358
+ let supportCalls = 0;
359
+ const provider = createProvider({
360
+ AAPL: [100, 102, 104],
361
+ MSFT: [200, 202, 204],
362
+ }, {
363
+ AAPL: "USD",
364
+ MSFT: "USD",
365
+ });
366
+ provider.getChartResolutionCapabilities = async () => {
367
+ supportCalls += 1;
368
+ return ["5m", "15m", "1h", "1d", "1wk", "1mo"];
369
+ };
370
+ setSharedMarketDataForTests(provider);
371
+ sharedCoordinator = new MarketDataCoordinator(provider);
372
+ setSharedMarketDataCoordinator(sharedCoordinator);
373
+ setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
374
+
375
+ const settings = {
376
+ axisMode: "percent",
377
+ symbols: ["AAPL", "MSFT"],
378
+ symbolsText: "AAPL, MSFT",
379
+ };
380
+ const tickers = [
381
+ makeTicker("AAPL", "USD"),
382
+ makeTicker("MSFT", "USD"),
383
+ ];
384
+ const spy = { selected: [] as string[], focused: [] as string[] };
385
+ const runtime = createRuntimeSpy(spy);
386
+ let updateFinancials: ((rows: Array<[string, TickerFinancials]>) => void) | null = null;
387
+ function PriceUpdateHarness({ initialFinancials }: { initialFinancials: Array<[string, TickerFinancials]> }) {
388
+ const [rows, setRows] = useState(initialFinancials);
389
+ updateFinancials = setRows;
390
+ return createComparisonHarness(settings, tickers, rows, runtime);
391
+ }
392
+
393
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
394
+ testSetup = await createTestRenderer({ width: 120, height: 20 });
395
+ root = createRoot(testSetup.renderer);
396
+ await act(async () => {
397
+ root!.render(
398
+ <PriceUpdateHarness
399
+ initialFinancials={[
400
+ ["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
401
+ ["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
402
+ ]}
403
+ />,
404
+ );
405
+ await Promise.resolve();
406
+ await new Promise((resolve) => setTimeout(resolve, 0));
407
+ await testSetup!.renderOnce();
408
+ });
409
+ await flushFrames();
410
+
411
+ expect(supportCalls).toBe(2);
412
+
413
+ await act(async () => {
414
+ updateFinancials!([
415
+ ["AAPL", makeFinancials("AAPL", "USD", [101, 103, 105])],
416
+ ["MSFT", makeFinancials("MSFT", "USD", [201, 203, 205])],
417
+ ]);
418
+ await Promise.resolve();
419
+ await new Promise((resolve) => setTimeout(resolve, 0));
420
+ await testSetup!.renderOnce();
421
+ });
422
+ await flushFrames();
423
+
424
+ expect(supportCalls).toBe(2);
425
+ });
426
+
315
427
  test("keeps controls visible when comparison history is empty", async () => {
316
428
  const provider = createProvider({
317
429
  AAPL: [],
@@ -346,7 +458,7 @@ describe("comparisonChartPlugin", () => {
346
458
  expect(frame).toContain("AUTO");
347
459
  });
348
460
 
349
- test("moves legend selection with the keyboard and opens the selected ticker on Enter", async () => {
461
+ test("moves legend selection with arrow and h/l keys, then opens the selected ticker on Enter", async () => {
350
462
  const spy = { selected: [] as string[], focused: [] as string[] };
351
463
  const provider = createProvider({
352
464
  AAPL: [100, 102, 104],
@@ -378,6 +490,8 @@ describe("comparisonChartPlugin", () => {
378
490
 
379
491
  await flushFrames();
380
492
  await pressComparisonInput(() => testSetup!.mockInput.pressArrow("right"));
493
+ await pressComparisonInput(() => testSetup!.mockInput.pressKey("l"));
494
+ await pressComparisonInput(() => testSetup!.mockInput.pressKey("h"));
381
495
  await pressComparisonInput(() => testSetup!.mockInput.pressEnter());
382
496
 
383
497
  expect(spy.selected).toEqual(["MSFT"]);
@@ -0,0 +1,39 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { applyTheme } from "../../../theme/colors";
3
+ import { contrastRatio } from "../../../theme/color-utils";
4
+ import { DEFAULT_THEME, themes } from "../../../theme/themes";
5
+ import { resolveCorrelationHeatmapCellColors } from "./colors";
6
+
7
+ const CORRELATION_VALUES = [-1, -0.75, -0.5, -0.25, 0, 0.13, 0.24, 0.39, 0.49, 0.75, 1] as const;
8
+ const CELL_TEXT_MIN_CONTRAST = 4.5;
9
+ const MUTED_TEXT_MIN_CONTRAST = 3.6;
10
+
11
+ afterEach(() => {
12
+ applyTheme(DEFAULT_THEME);
13
+ });
14
+
15
+ describe("correlation heatmap colors", () => {
16
+ test("keeps populated cells readable in every theme", () => {
17
+ for (const themeId of Object.keys(themes)) {
18
+ applyTheme(themeId);
19
+ for (const correlation of CORRELATION_VALUES) {
20
+ const cell = resolveCorrelationHeatmapCellColors(correlation);
21
+ expect(
22
+ contrastRatio(cell.foreground, cell.background),
23
+ `${themeId} correlation ${correlation}`,
24
+ ).toBeGreaterThanOrEqual(CELL_TEXT_MIN_CONTRAST);
25
+ }
26
+ }
27
+ });
28
+
29
+ test("keeps empty cells readable in every theme", () => {
30
+ for (const themeId of Object.keys(themes)) {
31
+ applyTheme(themeId);
32
+ const cell = resolveCorrelationHeatmapCellColors(null);
33
+ expect(
34
+ contrastRatio(cell.foreground, cell.background),
35
+ `${themeId} empty cell`,
36
+ ).toBeGreaterThanOrEqual(MUTED_TEXT_MIN_CONTRAST);
37
+ }
38
+ });
39
+ });
@@ -0,0 +1,87 @@
1
+ import { colors } from "../../../theme/colors";
2
+ import { blendHex, contrastRatio } from "../../../theme/color-utils";
3
+
4
+ const HEATMAP_BASE_BLEND = 0.35;
5
+ const HEATMAP_TEXT_MIN_CONTRAST = 4.5;
6
+ const HEATMAP_MUTED_MIN_CONTRAST = 3.6;
7
+ const HEATMAP_TINT_STEPS = [0.56, 0.5, 0.44, 0.38, 0.32, 0.28, 0.24, 0.2, 0.16, 0.12] as const;
8
+
9
+ export interface CorrelationHeatmapCellColors {
10
+ background: string;
11
+ foreground: string;
12
+ }
13
+
14
+ function highestContrast(candidates: readonly string[], background: string): string {
15
+ return candidates.reduce((best, candidate) => (
16
+ contrastRatio(candidate, background) > contrastRatio(best, background) ? candidate : best
17
+ ));
18
+ }
19
+
20
+ function textCandidates(): string[] {
21
+ return [
22
+ colors.text,
23
+ colors.textDim,
24
+ colors.textBright,
25
+ colors.textMuted,
26
+ colors.selectedText,
27
+ colors.headerText,
28
+ colors.bg,
29
+ colors.panel,
30
+ colors.header,
31
+ colors.commandBg,
32
+ ];
33
+ }
34
+
35
+ function heatmapBaseBackground(): string {
36
+ return blendHex(colors.panel, colors.bg, HEATMAP_BASE_BLEND);
37
+ }
38
+
39
+ function heatmapSemanticColor(correlation: number): string {
40
+ const clamped = Math.max(-1, Math.min(1, correlation));
41
+ return clamped < 0
42
+ ? blendHex(colors.negative, colors.warning, clamped + 1)
43
+ : blendHex(colors.warning, colors.positive, clamped);
44
+ }
45
+
46
+ function heatmapTintSteps(targetStrength: number): number[] {
47
+ const steps = HEATMAP_TINT_STEPS.filter((step) => step <= targetStrength);
48
+ return steps[0] === targetStrength ? steps : [targetStrength, ...steps];
49
+ }
50
+
51
+ function readableForeground(background: string): string {
52
+ return highestContrast(textCandidates(), background);
53
+ }
54
+
55
+ export function resolveCorrelationHeatmapCellColors(correlation: number | null): CorrelationHeatmapCellColors {
56
+ const baseBackground = heatmapBaseBackground();
57
+
58
+ if (correlation === null) {
59
+ const muted = colors.textMuted;
60
+ return {
61
+ background: baseBackground,
62
+ foreground: contrastRatio(muted, baseBackground) >= HEATMAP_MUTED_MIN_CONTRAST
63
+ ? muted
64
+ : readableForeground(baseBackground),
65
+ };
66
+ }
67
+
68
+ const clamped = Math.max(-1, Math.min(1, correlation));
69
+ const semanticColor = heatmapSemanticColor(clamped);
70
+ const targetStrength = 0.3 + Math.abs(clamped) * 0.26;
71
+ let fallback: CorrelationHeatmapCellColors | null = null;
72
+
73
+ for (const tintStrength of heatmapTintSteps(targetStrength)) {
74
+ const background = blendHex(baseBackground, semanticColor, tintStrength);
75
+ const foreground = readableForeground(background);
76
+ const cellColors = { background, foreground };
77
+ fallback = cellColors;
78
+ if (contrastRatio(foreground, background) >= HEATMAP_TEXT_MIN_CONTRAST) {
79
+ return cellColors;
80
+ }
81
+ }
82
+
83
+ return fallback ?? {
84
+ background: baseBackground,
85
+ foreground: readableForeground(baseBackground),
86
+ };
87
+ }
@@ -0,0 +1,151 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act, type ReactElement } from "react";
3
+ import { Box } from "../../../ui";
4
+ import { testRender } from "../../../renderers/opentui/test-utils";
5
+ import {
6
+ AppContext,
7
+ createInitialState,
8
+ PaneInstanceProvider,
9
+ } from "../../../state/app-context";
10
+ import { cloneLayout, createDefaultConfig } from "../../../types/config";
11
+ import type { TickerRecord } from "../../../types/ticker";
12
+ import { createTestPluginRuntime } from "../../../test-support/plugin-runtime";
13
+ import { PluginRenderProvider, type PluginRuntimeAccess } from "../../plugin-runtime";
14
+ import { correlationPlugin } from ".";
15
+
16
+ const TEST_PANE_ID = "correlation:test";
17
+
18
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
19
+
20
+ function makeTicker(symbol: string): TickerRecord {
21
+ return {
22
+ metadata: {
23
+ ticker: symbol,
24
+ exchange: "NASDAQ",
25
+ currency: "USD",
26
+ name: symbol,
27
+ portfolios: [],
28
+ watchlists: [],
29
+ positions: [],
30
+ broker_contracts: [],
31
+ custom: {},
32
+ tags: [],
33
+ },
34
+ };
35
+ }
36
+
37
+ function CorrelationHarness({ runtime }: { runtime: PluginRuntimeAccess }) {
38
+ const layout = {
39
+ dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
40
+ instances: [{
41
+ instanceId: TEST_PANE_ID,
42
+ paneId: "correlation",
43
+ settings: {
44
+ rangePreset: "1Y",
45
+ symbols: ["AAPL", "MSFT"],
46
+ symbolsText: "AAPL, MSFT",
47
+ },
48
+ }],
49
+ floating: [],
50
+ detached: [],
51
+ };
52
+ const config = {
53
+ ...createDefaultConfig("/tmp/gloomberb-correlation-test"),
54
+ layout,
55
+ layouts: [{ name: "Default", layout: cloneLayout(layout) }],
56
+ };
57
+ const state = createInitialState(config);
58
+ state.focusedPaneId = TEST_PANE_ID;
59
+ state.tickers = new Map([
60
+ ["AAPL", makeTicker("AAPL")],
61
+ ["MSFT", makeTicker("MSFT")],
62
+ ]);
63
+
64
+ const CorrelationPane = correlationPlugin.panes?.[0]?.component as (props: {
65
+ paneId: string;
66
+ paneType: string;
67
+ focused: boolean;
68
+ width: number;
69
+ height: number;
70
+ }) => ReactElement;
71
+
72
+ return (
73
+ <AppContext value={{ state, dispatch: () => {} }}>
74
+ <PaneInstanceProvider paneId={TEST_PANE_ID}>
75
+ <PluginRenderProvider pluginId="correlation" runtime={runtime}>
76
+ <Box width={60} height={8}>
77
+ <CorrelationPane
78
+ paneId={TEST_PANE_ID}
79
+ paneType="correlation"
80
+ focused
81
+ width={60}
82
+ height={8}
83
+ />
84
+ </Box>
85
+ </PluginRenderProvider>
86
+ </PaneInstanceProvider>
87
+ </AppContext>
88
+ );
89
+ }
90
+
91
+ afterEach(async () => {
92
+ if (testSetup) {
93
+ await act(async () => {
94
+ testSetup!.renderer.destroy();
95
+ await Promise.resolve();
96
+ });
97
+ }
98
+ testSetup = undefined;
99
+ });
100
+
101
+ describe("correlationPlugin", () => {
102
+ test("opens tickers from row and column labels", async () => {
103
+ const opened: Array<{ symbol: string; options: { floating?: boolean; paneType?: string } | undefined }> = [];
104
+ const runtime = createTestPluginRuntime({
105
+ pinTicker: (symbol, options) => opened.push({ symbol, options }),
106
+ navigateTicker: () => {
107
+ throw new Error("known correlation tickers should open directly");
108
+ },
109
+ });
110
+
111
+ await act(async () => {
112
+ testSetup = await testRender(<CorrelationHarness runtime={runtime} />, {
113
+ width: 60,
114
+ height: 8,
115
+ });
116
+ });
117
+ await act(async () => {
118
+ await testSetup!.renderOnce();
119
+ await Promise.resolve();
120
+ await testSetup!.renderOnce();
121
+ });
122
+
123
+ const lines = testSetup!.captureCharFrame().split("\n");
124
+ const headerY = lines.findIndex((line) => line.includes("AAPL") && line.includes("MSFT"));
125
+ const headerCol = lines[headerY]?.indexOf("AAPL") ?? -1;
126
+ expect(headerY).toBeGreaterThanOrEqual(0);
127
+ expect(headerCol).toBeGreaterThanOrEqual(0);
128
+
129
+ await act(async () => {
130
+ await testSetup!.mockMouse.click(headerCol + 1, headerY);
131
+ await Promise.resolve();
132
+ await testSetup!.renderOnce();
133
+ });
134
+
135
+ const rowY = lines.findIndex((line, index) => index > headerY && line.includes("MSFT"));
136
+ const rowCol = lines[rowY]?.indexOf("MSFT") ?? -1;
137
+ expect(rowY).toBeGreaterThanOrEqual(0);
138
+ expect(rowCol).toBeGreaterThanOrEqual(0);
139
+
140
+ await act(async () => {
141
+ await testSetup!.mockMouse.click(rowCol + 1, rowY);
142
+ await Promise.resolve();
143
+ await testSetup!.renderOnce();
144
+ });
145
+
146
+ expect(opened).toEqual([
147
+ { symbol: "AAPL", options: { floating: true, paneType: "ticker-detail" } },
148
+ { symbol: "MSFT", options: { floating: true, paneType: "ticker-detail" } },
149
+ ]);
150
+ });
151
+ });
@@ -1,9 +1,9 @@
1
1
  import { Box, ScrollBox, Text } from "../../../ui";
2
- import { useMemo } from "react";
2
+ import { useCallback, useMemo, useState } from "react";
3
3
  import { TextAttributes } from "../../../ui";
4
4
  import { usePaneFooter } from "../../../components";
5
5
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
6
- import { colors } from "../../../theme/colors";
6
+ import { colors, hoverBg } from "../../../theme/colors";
7
7
  import { usePluginTickerActions } from "../../plugin-runtime";
8
8
  import { useAppSelector, usePaneInstance } from "../../../state/app-context";
9
9
  import { useChartQueries } from "../../../market-data/hooks";
@@ -15,7 +15,6 @@ import {
15
15
  computeDatedReturns,
16
16
  correlateDatedReturns,
17
17
  formatCorrelation,
18
- correlationColor,
19
18
  type CorrelationResult,
20
19
  type DatedReturn,
21
20
  } from "./compute";
@@ -26,6 +25,7 @@ import {
26
25
  getCorrelationPaneSettings,
27
26
  type CorrelationRangePreset,
28
27
  } from "./settings";
28
+ import { resolveCorrelationHeatmapCellColors } from "./colors";
29
29
 
30
30
  const ROW_HEADER_WIDTH = 7;
31
31
  const MATRIX_CELL_WIDTH = 10;
@@ -142,10 +142,63 @@ function buildCorrelationPaneTitle(symbols: string[], rangePreset: CorrelationRa
142
142
  return `${symbols.slice(0, 2).join(" · ")} +${symbols.length - 2} ${rangePreset}`;
143
143
  }
144
144
 
145
+ function handleSymbolMouseDown(event: {
146
+ preventDefault?: () => void;
147
+ stopPropagation?: () => void;
148
+ }, openSymbol: () => void): void {
149
+ event.preventDefault?.();
150
+ event.stopPropagation?.();
151
+ openSymbol();
152
+ }
153
+
154
+ function SymbolLabelCell({
155
+ symbol,
156
+ width,
157
+ color,
158
+ align = "flex-start",
159
+ hovered,
160
+ onHover,
161
+ onLeave,
162
+ onOpen,
163
+ }: {
164
+ symbol: string;
165
+ width: number;
166
+ color: string;
167
+ align?: "flex-start" | "flex-end";
168
+ hovered: boolean;
169
+ onHover: (symbol: string) => void;
170
+ onLeave: (symbol: string) => void;
171
+ onOpen: (symbol: string) => void;
172
+ }) {
173
+ const openSymbol = useCallback(() => onOpen(symbol), [onOpen, symbol]);
174
+ return (
175
+ <Box
176
+ width={width}
177
+ flexShrink={0}
178
+ justifyContent={align}
179
+ paddingRight={align === "flex-end" ? 1 : undefined}
180
+ overflow="hidden"
181
+ backgroundColor={hovered ? hoverBg() : undefined}
182
+ style={{ cursor: "pointer" }}
183
+ onMouseMove={() => onHover(symbol)}
184
+ onMouseOut={() => onLeave(symbol)}
185
+ onMouseDown={(event: any) => handleSymbolMouseDown(event, openSymbol)}
186
+ >
187
+ <Text
188
+ fg={hovered ? colors.textBright : color}
189
+ attributes={TextAttributes.BOLD | TextAttributes.UNDERLINE}
190
+ >
191
+ {displaySymbol(symbol)}
192
+ </Text>
193
+ </Box>
194
+ );
195
+ }
196
+
145
197
  function CorrelationMatrixPane({ width, height }: PaneProps) {
146
198
  const pane = usePaneInstance();
147
- const { navigateTicker } = usePluginTickerActions();
199
+ const { navigateTicker, pinTicker } = usePluginTickerActions();
148
200
  const tickers = useAppSelector((state) => state.tickers);
201
+ const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
149
202
  const settings = useMemo(() => getCorrelationPaneSettings(pane?.settings), [pane?.settings]);
150
203
 
151
204
  const instruments = useMemo(() => {
@@ -234,6 +287,18 @@ function CorrelationMatrixPane({ width, height }: PaneProps) {
234
287
  ],
235
288
  }), [settings.rangePreset, settings.symbolsError, statusSummary, symbols.length]);
236
289
 
290
+ const openSymbol = useCallback((symbol: string) => {
291
+ if (tickers.has(symbol)) {
292
+ pinTicker(symbol, { floating: true, paneType: "ticker-detail" });
293
+ return;
294
+ }
295
+ navigateTicker(symbol);
296
+ }, [navigateTicker, pinTicker, tickers]);
297
+
298
+ const clearHoveredSymbol = useCallback((symbol: string) => {
299
+ setHoveredSymbol((current) => (current === symbol ? null : current));
300
+ }, []);
301
+
237
302
  if (settings.symbolsError) {
238
303
  return (
239
304
  <Box flexDirection="column" width={width} height={height} paddingX={2} paddingY={1}>
@@ -265,11 +330,17 @@ function CorrelationMatrixPane({ width, height }: PaneProps) {
265
330
  <Box flexDirection="row" paddingX={1} height={1} backgroundColor={headerBg}>
266
331
  <Box width={rowHeaderWidth} flexShrink={0} />
267
332
  {symbols.map((sym) => (
268
- <Box key={sym} width={cellWidth} justifyContent="flex-end" paddingRight={1} overflow="hidden">
269
- <Text fg={colors.textDim} attributes={TextAttributes.BOLD}>
270
- {displaySymbol(sym)}
271
- </Text>
272
- </Box>
333
+ <SymbolLabelCell
334
+ key={sym}
335
+ symbol={sym}
336
+ width={cellWidth}
337
+ align="flex-end"
338
+ color={colors.textDim}
339
+ hovered={hoveredSymbol === sym}
340
+ onHover={setHoveredSymbol}
341
+ onLeave={clearHoveredSymbol}
342
+ onOpen={openSymbol}
343
+ />
273
344
  ))}
274
345
  </Box>
275
346
 
@@ -283,14 +354,16 @@ function CorrelationMatrixPane({ width, height }: PaneProps) {
283
354
  width={rowHeaderWidth}
284
355
  flexShrink={0}
285
356
  overflow="hidden"
286
- onMouseDown={() => navigateTicker(rowSym)}
287
357
  >
288
- <Text
289
- fg={rowHeaderColor(seriesBySymbol.get(rowSym)?.status ?? "loading")}
290
- attributes={TextAttributes.BOLD | TextAttributes.UNDERLINE}
291
- >
292
- {displaySymbol(rowSym)}
293
- </Text>
358
+ <SymbolLabelCell
359
+ symbol={rowSym}
360
+ width={rowHeaderWidth}
361
+ color={rowHeaderColor(seriesBySymbol.get(rowSym)?.status ?? "loading")}
362
+ hovered={hoveredSymbol === rowSym}
363
+ onHover={setHoveredSymbol}
364
+ onLeave={clearHoveredSymbol}
365
+ onOpen={openSymbol}
366
+ />
294
367
  </Box>
295
368
  {/* Cells */}
296
369
  {symbols.map((colSym) => {
@@ -301,9 +374,7 @@ function CorrelationMatrixPane({ width, height }: PaneProps) {
301
374
  } else {
302
375
  r = matrix.results.get(pairKey(rowSym, colSym))?.correlation ?? null;
303
376
  }
304
- const cellColor = isDiag
305
- ? colors.textDim
306
- : correlationColor(r, colors.positive, colors.negative, colors.textMuted);
377
+ const cellColors = resolveCorrelationHeatmapCellColors(r);
307
378
  const text = isDiag ? " 1.00" : formatCorrelation(r);
308
379
  return (
309
380
  <Box
@@ -311,9 +382,9 @@ function CorrelationMatrixPane({ width, height }: PaneProps) {
311
382
  width={cellWidth}
312
383
  justifyContent="flex-end"
313
384
  paddingRight={1}
314
- backgroundColor={isDiag ? headerBg : undefined}
385
+ backgroundColor={cellColors.background}
315
386
  >
316
- <Text fg={cellColor}>{text}</Text>
387
+ <Text fg={cellColors.foreground}>{text}</Text>
317
388
  </Box>
318
389
  );
319
390
  })}
@@ -7,6 +7,7 @@ import { usePaneFooter } from "../../components";
7
7
  import { usePluginAppActions } from "../plugin-runtime";
8
8
  import { colors, hoverBg } from "../../theme/colors";
9
9
  import { debugLog, type LogEntry, type LogLevel } from "../../utils/debug-log";
10
+ import { isPlainKey } from "../../utils/keyboard";
10
11
  import { wrapTextLines } from "../../utils/text-wrap";
11
12
  import { writeFileSync } from "fs";
12
13
  import { join } from "path";
@@ -37,7 +38,7 @@ function levelColor(level: LogLevel): string {
37
38
  switch (level) {
38
39
  case "debug": return colors.textDim;
39
40
  case "info": return colors.positive;
40
- case "warn": return "#e5c07b";
41
+ case "warn": return colors.warning;
41
42
  case "error": return colors.negative;
42
43
  }
43
44
  }
@@ -154,12 +155,12 @@ function DebugPane({ focused, width, height }: PaneProps) {
154
155
  }
155
156
 
156
157
  // Navigation
157
- if (event.name === "j" || event.name === "down") {
158
+ if (isPlainKey(event, "j", "down")) {
158
159
  setAutoScroll(false);
159
160
  setSelectedIdx((prev) => Math.min(prev + 1, entries.length - 1));
160
161
  return;
161
162
  }
162
- if (event.name === "k" || event.name === "up") {
163
+ if (isPlainKey(event, "k", "up")) {
163
164
  setAutoScroll(false);
164
165
  setSelectedIdx((prev) => Math.max(prev - 1, 0));
165
166
  return;
@@ -12,6 +12,7 @@ import { resolveFredMapping } from "./fred-series-map";
12
12
  import { resolveChartPalette } from "../../../components/chart/chart-renderer";
13
13
  import type { ProjectedChartPoint } from "../../../components/chart/chart-data";
14
14
  import { apiClient, type CloudFredObservationPayload, type CloudFredSeriesInfoPayload } from "../../../utils/api-client";
15
+ import { isPlainKey } from "../../../utils/keyboard";
15
16
 
16
17
  const CACHE_TTL_MS = 15 * 60 * 1000;
17
18
 
@@ -202,11 +203,11 @@ function EconDetailView({ event, width, height, focused }: EconDetailViewProps)
202
203
 
203
204
  useShortcut((ev) => {
204
205
  if (!focused) return;
205
- if (ev.name === "j" || ev.name === "down") {
206
+ if (isPlainKey(ev, "j", "down")) {
206
207
  ev.stopPropagation?.();
207
208
  ev.preventDefault?.();
208
209
  scrollDetailBy(1);
209
- } else if (ev.name === "k" || ev.name === "up") {
210
+ } else if (isPlainKey(ev, "k", "up")) {
210
211
  ev.stopPropagation?.();
211
212
  ev.preventDefault?.();
212
213
  scrollDetailBy(-1);
@@ -81,7 +81,7 @@ describe("HelpPane", () => {
81
81
  paneId: "ticker-detail",
82
82
  label: "Quote Monitor",
83
83
  description: "Open ticker detail",
84
- shortcut: { prefix: "QQ", argPlaceholder: "ticker" },
84
+ shortcut: { prefix: "QQ", argPlaceholder: "tickers", argKind: "ticker-list" },
85
85
  },
86
86
  ]]),
87
87
  shortcuts: new Map([[
@@ -110,7 +110,7 @@ describe("HelpPane", () => {
110
110
  const frame = testSetup!.captureCharFrame();
111
111
  expect(frame).toMatch(/AW\s+<ticker>/);
112
112
  expect(frame).toMatch(/SA\s+<symbol condition price>/);
113
- expect(frame).toMatch(/QQ\s+<ticker>/);
113
+ expect(frame).toMatch(/QQ\s+<tickers>/);
114
114
  expect(frame).toContain("Add Alert (Alerts)");
115
115
  expect(frame).toContain("Shift+C");
116
116
  expect(frame).toContain("Toggle chat (Gloomberb Cloud)");