gloomberb 0.3.7 → 0.4.1

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 (143) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +45 -69
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +363 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +4 -2
  37. package/src/components/layout/header.tsx +3 -3
  38. package/src/components/layout/pane-header.tsx +26 -2
  39. package/src/components/layout/pane-sizing.ts +6 -0
  40. package/src/components/layout/pane.tsx +5 -1
  41. package/src/components/layout/shell.test.tsx +77 -5
  42. package/src/components/layout/shell.tsx +67 -52
  43. package/src/components/layout/status-bar.test.tsx +66 -0
  44. package/src/components/layout/status-bar.tsx +26 -5
  45. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  46. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  47. package/src/components/ticker-list-table.test.tsx +83 -0
  48. package/src/components/ticker-list-table.tsx +181 -97
  49. package/src/components/ui/data-table.tsx +50 -5
  50. package/src/components/ui/index.ts +1 -0
  51. package/src/components/ui/page-stack-view.tsx +60 -0
  52. package/src/components/use-double-click-activation.ts +31 -0
  53. package/src/index.tsx +10 -10
  54. package/src/market-data/coordinator.test.ts +92 -4
  55. package/src/market-data/coordinator.ts +32 -5
  56. package/src/market-data/hooks.test.tsx +120 -0
  57. package/src/market-data/hooks.tsx +53 -16
  58. package/src/market-data/request-types.ts +4 -2
  59. package/src/market-data/selectors.ts +24 -58
  60. package/src/plugins/builtin/chat-controller.test.ts +400 -3
  61. package/src/plugins/builtin/chat-controller.ts +328 -35
  62. package/src/plugins/builtin/chat.test.tsx +118 -6
  63. package/src/plugins/builtin/chat.tsx +180 -116
  64. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  65. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  66. package/src/plugins/builtin/layout-manager.tsx +9 -0
  67. package/src/plugins/builtin/news.tsx +1 -11
  68. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  69. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  70. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  71. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  72. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  73. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  74. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  75. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  76. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  77. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  78. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  79. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  80. package/src/plugins/builtin/sec.tsx +1 -11
  81. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  82. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  83. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  84. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  85. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  86. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  87. package/src/plugins/catalog.ts +65 -0
  88. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  89. package/src/plugins/ibkr/gateway-service.test.ts +67 -1
  90. package/src/plugins/ibkr/gateway-service.ts +147 -7
  91. package/src/plugins/ibkr/index.tsx +7 -1
  92. package/src/plugins/pane-manager.test.ts +1 -1
  93. package/src/plugins/plugin-runtime.tsx +27 -7
  94. package/src/plugins/prediction-markets/cli.ts +28 -0
  95. package/src/plugins/prediction-markets/controller-data.ts +132 -17
  96. package/src/plugins/prediction-markets/controller.ts +142 -122
  97. package/src/plugins/prediction-markets/index.tsx +7 -1
  98. package/src/plugins/prediction-markets/launch.ts +51 -16
  99. package/src/plugins/prediction-markets/metrics.ts +9 -5
  100. package/src/plugins/prediction-markets/pane.test.tsx +197 -57
  101. package/src/plugins/prediction-markets/pane.tsx +80 -155
  102. package/src/plugins/prediction-markets/services/fetch.ts +4 -0
  103. package/src/plugins/prediction-markets/table.tsx +6 -3
  104. package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/plugins/registry.ts +23 -0
  107. package/src/sources/gloomberb-cloud.test.ts +29 -0
  108. package/src/sources/gloomberb-cloud.ts +89 -1
  109. package/src/sources/provider-router.test.ts +265 -3
  110. package/src/sources/provider-router.ts +239 -11
  111. package/src/sources/yahoo-finance.test.ts +26 -0
  112. package/src/sources/yahoo-finance.ts +54 -3
  113. package/src/state/app-context-selector.test.tsx +99 -0
  114. package/src/state/app-context.test.ts +18 -0
  115. package/src/state/app-context.tsx +170 -65
  116. package/src/state/selectors.ts +25 -0
  117. package/src/theme/colors.ts +8 -8
  118. package/src/theme/themes.test.ts +2 -0
  119. package/src/theme/themes.ts +22 -10
  120. package/src/types/broker.ts +21 -0
  121. package/src/types/config.ts +17 -1
  122. package/src/types/data-provider.ts +18 -0
  123. package/src/types/financials.ts +31 -1
  124. package/src/types/plugin.ts +70 -0
  125. package/src/updater.test.ts +2 -0
  126. package/src/utils/api-client.test.ts +57 -0
  127. package/src/utils/api-client.ts +47 -12
  128. package/src/utils/back-navigation.ts +27 -0
  129. package/src/utils/format.test.ts +14 -0
  130. package/src/utils/format.ts +3 -3
  131. package/src/utils/market-status.ts +46 -1
  132. package/src/utils/quote-freshness.ts +127 -0
  133. package/src/utils/quote-resolution.test.ts +277 -0
  134. package/src/utils/quote-resolution.ts +641 -0
  135. package/src/utils/slugify.ts +3 -0
  136. package/src/utils/timestamp.ts +25 -0
  137. package/src/version.ts +1 -1
  138. package/src/cli/commands/portfolio.ts +0 -210
  139. package/src/cli/commands/watchlist.ts +0 -210
  140. package/src/plugins/builtin/data-providers.ts +0 -16
  141. package/src/sources/exchange-rates.ts +0 -18
  142. package/src/utils/ascii-chart.ts +0 -172
  143. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -65,6 +65,20 @@ async function emitKeypress(
65
65
  });
66
66
  }
67
67
 
68
+ async function waitForFrameToContain(text: string, attempts = 8): Promise<string> {
69
+ for (let index = 0; index < attempts; index += 1) {
70
+ const frame = testSetup!.captureCharFrame();
71
+ if (frame.includes(text)) {
72
+ return frame;
73
+ }
74
+ await act(async () => {
75
+ await Promise.resolve();
76
+ await testSetup!.renderOnce();
77
+ });
78
+ }
79
+ throw new Error(`Timed out waiting for "${text}".`);
80
+ }
81
+
68
82
  afterEach(async () => {
69
83
  if (testSetup) {
70
84
  await act(async () => {
@@ -79,6 +93,57 @@ afterEach(async () => {
79
93
  });
80
94
 
81
95
  describe("OnboardingWizard", () => {
96
+ test("uses backspace to go back when not editing and keeps backspace for text deletion while editing", async () => {
97
+ const pluginRegistry = {
98
+ allPlugins: new Map(),
99
+ brokers: new Map(),
100
+ paneTemplates: new Map(),
101
+ getPaneTemplatePluginId: () => undefined,
102
+ tickerRepository: createTickerRepository(),
103
+ persistence: { resources: undefined },
104
+ } as unknown as PluginRegistry;
105
+
106
+ testSetup = await testRender(
107
+ <OnboardingWizard
108
+ config={createDefaultConfig("/tmp/gloomberb-onboarding-backspace")}
109
+ pluginRegistry={pluginRegistry}
110
+ onComplete={() => {}}
111
+ />,
112
+ { width: 90, height: 28 },
113
+ );
114
+ await testSetup.renderOnce();
115
+
116
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
117
+ let frame = await waitForFrameToContain("Theme");
118
+ expect(frame).toContain("Theme");
119
+
120
+ await act(async () => {
121
+ testSetup!.mockInput.pressBackspace();
122
+ await testSetup!.renderOnce();
123
+ await testSetup!.renderOnce();
124
+ });
125
+ frame = await waitForFrameToContain("Let's set things up (~30s).");
126
+ expect(frame).toContain("Let's set things up (~30s).");
127
+
128
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
129
+ frame = await waitForFrameToContain("Theme");
130
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
131
+ frame = await waitForFrameToContain("Create Manual Portfolio");
132
+ expect(frame).toContain("Create Manual Portfolio");
133
+
134
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
135
+ frame = await waitForFrameToContain("Main Portfolio");
136
+ expect(frame).toContain("Main Portfolio");
137
+
138
+ await act(async () => {
139
+ testSetup!.mockInput.pressBackspace();
140
+ await testSetup!.renderOnce();
141
+ });
142
+ frame = testSetup.captureCharFrame();
143
+ expect(frame).toContain("Main Portfoli");
144
+ expect(frame).not.toContain("Create Manual Portfolio");
145
+ });
146
+
82
147
  test("waits for broker positions to import before completing onboarding", async () => {
83
148
  tempDataDir = await mkdtemp(join(tmpdir(), "gloomberb-onboarding-"));
84
149
  const importDeferred = createDeferred<any[]>();
@@ -10,6 +10,7 @@ import type { PluginRegistry } from "../../plugins/registry";
10
10
  import { resolveBrokerConfigFields, type BrokerAdapter, type BrokerConfigField } from "../../types/broker";
11
11
  import { buildIbkrConfigFromValues } from "../../plugins/ibkr/config";
12
12
  import { createBrokerInstanceId } from "../../utils/broker-instances";
13
+ import { isBackNavigationKey, isPlainEscape } from "../../utils/back-navigation";
13
14
  import { syncBrokerInstance } from "../../brokers/sync-broker-instance";
14
15
  import { debugLog } from "../../utils/debug-log";
15
16
  import { ToggleList, type ToggleListItem } from "../toggle-list";
@@ -415,7 +416,7 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
415
416
  }
416
417
  }
417
418
  }
418
- } else if (event.name === "escape") {
419
+ } else if (isPlainEscape(event)) {
419
420
  setEditingField(false);
420
421
  if (portfolioSub === "broker-fields") {
421
422
  setPortfolioSub("broker-setup");
@@ -506,7 +507,7 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
506
507
  }
507
508
  }
508
509
  nextStep();
509
- } else if (event.name === "escape") {
510
+ } else if (isBackNavigationKey(event)) {
510
511
  if (step === "portfolio" && portfolioSub === "broker-sync") {
511
512
  resetBrokerSync();
512
513
  setPortfolioSub("broker-fields");
@@ -693,9 +694,7 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
693
694
  </box>
694
695
  </box>
695
696
  <box height={1} flexDirection="row" width={contentWidth}>
696
- <box flexGrow={1}>
697
- {stepIdx > 0 && <text fg={colors.textMuted}>{"<- back"}</text>}
698
- </box>
697
+ <box flexGrow={1} />
699
698
  <box>
700
699
  <text fg={colors.textMuted}>{hintText}</text>
701
700
  </box>
@@ -1024,7 +1023,7 @@ function PortfolioStep({
1024
1023
  <text fg={colors.textDim}>
1025
1024
  {brokerSyncing
1026
1025
  ? "This happens now so your portfolio is ready before onboarding finishes."
1027
- : "Press Enter to retry, or Esc to edit the broker settings."}
1026
+ : "Press Enter to retry, or Backspace to edit the broker settings."}
1028
1027
  </text>
1029
1028
  </box>
1030
1029
  </box>
@@ -0,0 +1,83 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act, useState } from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import type { ColumnConfig } from "../types/config";
5
+ import type { TickerFinancials } from "../types/financials";
6
+ import type { TickerRecord } from "../types/ticker";
7
+ import { TickerListTable, type TickerTableCell } from "./ticker-list-table";
8
+
9
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
10
+ let setHarnessCursorSymbol: ((symbol: string) => void) | null = null;
11
+ let resolveCellCallCount = 0;
12
+
13
+ const columns: ColumnConfig[] = [
14
+ { id: "ticker", label: "Ticker", width: 6, align: "left" },
15
+ ];
16
+ const financialsMap = new Map<string, TickerFinancials>();
17
+ const tickers: TickerRecord[] = [
18
+ { metadata: { ticker: "AAPL", exchange: "NASDAQ", currency: "USD", name: "Apple", portfolios: [], watchlists: [], positions: [], custom: {}, tags: [] } },
19
+ { metadata: { ticker: "MSFT", exchange: "NASDAQ", currency: "USD", name: "Microsoft", portfolios: [], watchlists: [], positions: [], custom: {}, tags: [] } },
20
+ { metadata: { ticker: "NVDA", exchange: "NASDAQ", currency: "USD", name: "NVIDIA", portfolios: [], watchlists: [], positions: [], custom: {}, tags: [] } },
21
+ ];
22
+
23
+ function noop(_index: number | null): void {}
24
+
25
+ function resolveCell(_column: ColumnConfig, ticker: TickerRecord, _financials: TickerFinancials | undefined): TickerTableCell {
26
+ resolveCellCallCount += 1;
27
+ return { text: ticker.metadata.ticker };
28
+ }
29
+
30
+ function TickerListTableHarness() {
31
+ const [cursorSymbol, setCursorSymbol] = useState("AAPL");
32
+ setHarnessCursorSymbol = (symbol: string) => setCursorSymbol(symbol);
33
+
34
+ return (
35
+ <TickerListTable
36
+ columns={columns}
37
+ tickers={tickers}
38
+ cursorSymbol={cursorSymbol}
39
+ hoveredIdx={null}
40
+ setHoveredIdx={noop}
41
+ setCursorSymbol={setCursorSymbol}
42
+ resolveCell={resolveCell}
43
+ financialsMap={financialsMap}
44
+ />
45
+ );
46
+ }
47
+
48
+ afterEach(async () => {
49
+ if (testSetup) {
50
+ await act(async () => {
51
+ testSetup!.renderer.destroy();
52
+ });
53
+ testSetup = undefined;
54
+ }
55
+ resolveCellCallCount = 0;
56
+ setHarnessCursorSymbol = null;
57
+ });
58
+
59
+ describe("TickerListTable", () => {
60
+ test("recomputes cells only for rows whose selection state changed", async () => {
61
+ testSetup = await testRender(
62
+ <TickerListTableHarness />,
63
+ { width: 20, height: 6 },
64
+ );
65
+
66
+ await act(async () => {
67
+ await testSetup!.renderOnce();
68
+ });
69
+
70
+ const initialCallCount = resolveCellCallCount;
71
+
72
+ await act(async () => {
73
+ setHarnessCursorSymbol?.("MSFT");
74
+ await Promise.resolve();
75
+ });
76
+ await act(async () => {
77
+ await testSetup!.renderOnce();
78
+ });
79
+
80
+ expect(initialCallCount).toBeGreaterThan(0);
81
+ expect(resolveCellCallCount - initialCallCount).toBe(2);
82
+ });
83
+ });
@@ -1,4 +1,4 @@
1
- import { useRef, type RefObject } from "react";
1
+ import { memo, useRef, type RefObject } from "react";
2
2
  import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
3
  import { EmptyState } from "./ui";
4
4
  import { colors, hoverBg } from "../theme/colors";
@@ -13,6 +13,11 @@ export interface TickerTableCell {
13
13
  }
14
14
 
15
15
  export type QuoteFlashDirection = "up" | "down" | "flat";
16
+ type ResolveTickerTableCell = (
17
+ column: ColumnConfig,
18
+ ticker: TickerRecord,
19
+ financials: TickerFinancials | undefined,
20
+ ) => TickerTableCell;
16
21
 
17
22
  const FLASHABLE_QUOTE_COLUMN_IDS = new Set([
18
23
  "price",
@@ -39,6 +44,160 @@ function resolveQuoteFlashColor(direction: QuoteFlashDirection, fallbackColor: s
39
44
  }
40
45
  }
41
46
 
47
+ interface TickerListTableProps {
48
+ columns: ColumnConfig[];
49
+ tickers: TickerRecord[];
50
+ cursorSymbol: string | null;
51
+ hoveredIdx: number | null;
52
+ setHoveredIdx: (index: number | null) => void;
53
+ setCursorSymbol: (symbol: string) => void;
54
+ resolveCell: ResolveTickerTableCell;
55
+ financialsMap: Map<string, TickerFinancials>;
56
+ headerScrollRef?: RefObject<ScrollBoxRenderable | null>;
57
+ scrollRef?: RefObject<ScrollBoxRenderable | null>;
58
+ syncHeaderScroll?: () => void;
59
+ onBodyScrollActivity?: () => void;
60
+ flashSymbols?: Map<string, QuoteFlashDirection>;
61
+ sortColumnId?: string | null;
62
+ sortDirection?: "asc" | "desc";
63
+ onHeaderClick?: (columnId: string) => void;
64
+ onRowActivate?: (ticker: TickerRecord) => void;
65
+ emptyTitle?: string;
66
+ emptyHint?: string;
67
+ }
68
+
69
+ const TickerListHeader = memo(function TickerListHeader({
70
+ columns,
71
+ headerScrollRef,
72
+ sortColumnId,
73
+ sortDirection,
74
+ onHeaderClick,
75
+ }: {
76
+ columns: ColumnConfig[];
77
+ headerScrollRef?: RefObject<ScrollBoxRenderable | null>;
78
+ sortColumnId?: string | null;
79
+ sortDirection?: "asc" | "desc";
80
+ onHeaderClick?: (columnId: string) => void;
81
+ }) {
82
+ return (
83
+ <scrollbox
84
+ ref={headerScrollRef}
85
+ height={1}
86
+ scrollX
87
+ focusable={false}
88
+ >
89
+ <box
90
+ flexDirection="row"
91
+ height={1}
92
+ width="100%"
93
+ paddingX={1}
94
+ backgroundColor={colors.panel}
95
+ >
96
+ {columns.map((column) => {
97
+ const isSorted = sortColumnId === column.id;
98
+ const indicator = isSorted ? (sortDirection === "asc" ? " \u25B2" : " \u25BC") : "";
99
+ const labelText = column.label + indicator;
100
+ return (
101
+ <box
102
+ key={column.id}
103
+ width={column.width + 1}
104
+ backgroundColor={colors.panel}
105
+ onMouseDown={onHeaderClick ? (event) => {
106
+ event.preventDefault();
107
+ onHeaderClick(column.id);
108
+ } : undefined}
109
+ >
110
+ <text attributes={TextAttributes.BOLD} fg={isSorted ? colors.text : colors.textDim}>
111
+ {padTo(labelText, column.width, column.align)}
112
+ </text>
113
+ </box>
114
+ );
115
+ })}
116
+ </box>
117
+ </scrollbox>
118
+ );
119
+ });
120
+
121
+ const TickerListRow = memo(function TickerListRow({
122
+ columns,
123
+ ticker,
124
+ index,
125
+ isSelected,
126
+ isHovered,
127
+ financials,
128
+ flashDirection,
129
+ setHoveredIdx,
130
+ setCursorSymbol,
131
+ resolveCell,
132
+ onRowActivate,
133
+ }: {
134
+ columns: ColumnConfig[];
135
+ ticker: TickerRecord;
136
+ index: number;
137
+ isSelected: boolean;
138
+ isHovered: boolean;
139
+ financials: TickerFinancials | undefined;
140
+ flashDirection: QuoteFlashDirection | undefined;
141
+ setHoveredIdx: (index: number | null) => void;
142
+ setCursorSymbol: (symbol: string) => void;
143
+ resolveCell: ResolveTickerTableCell;
144
+ onRowActivate?: (ticker: TickerRecord) => void;
145
+ }) {
146
+ const lastActivatedAtRef = useRef<number | null>(null);
147
+ const rowBg = isSelected ? colors.selected : isHovered ? hoverBg() : colors.bg;
148
+
149
+ return (
150
+ <box
151
+ flexDirection="row"
152
+ height={1}
153
+ paddingX={1}
154
+ backgroundColor={rowBg}
155
+ onMouseMove={() => setHoveredIdx(index)}
156
+ onMouseDown={(event) => {
157
+ event.preventDefault();
158
+ setCursorSymbol(ticker.metadata.ticker);
159
+ if (!onRowActivate) return;
160
+ const now = Date.now();
161
+ if (lastActivatedAtRef.current != null && now - lastActivatedAtRef.current <= 350) {
162
+ lastActivatedAtRef.current = null;
163
+ onRowActivate(ticker);
164
+ return;
165
+ }
166
+ lastActivatedAtRef.current = now;
167
+ }}
168
+ >
169
+ {columns.map((column) => {
170
+ const { text, color } = resolveCell(column, ticker, financials);
171
+ const baseFg = color || (isSelected ? colors.selectedText : colors.text);
172
+ const shouldFlash = flashDirection != null && FLASHABLE_QUOTE_COLUMN_IDS.has(column.id);
173
+ const cellFg = shouldFlash
174
+ ? resolveQuoteFlashColor(flashDirection, baseFg)
175
+ : baseFg;
176
+
177
+ return (
178
+ <box key={column.id} width={column.width + 1}>
179
+ <text fg={cellFg}>
180
+ {padTo(text, column.width, column.align)}
181
+ </text>
182
+ </box>
183
+ );
184
+ })}
185
+ </box>
186
+ );
187
+ }, (previous, next) => (
188
+ previous.columns === next.columns
189
+ && previous.ticker === next.ticker
190
+ && previous.index === next.index
191
+ && previous.isSelected === next.isSelected
192
+ && previous.isHovered === next.isHovered
193
+ && previous.financials === next.financials
194
+ && previous.flashDirection === next.flashDirection
195
+ && previous.setHoveredIdx === next.setHoveredIdx
196
+ && previous.setCursorSymbol === next.setCursorSymbol
197
+ && previous.resolveCell === next.resolveCell
198
+ && previous.onRowActivate === next.onRowActivate
199
+ ));
200
+
42
201
  export function TickerListTable({
43
202
  columns,
44
203
  tickers,
@@ -59,60 +218,18 @@ export function TickerListTable({
59
218
  onRowActivate,
60
219
  emptyTitle = "No tickers.",
61
220
  emptyHint = "Press Ctrl+P to add one.",
62
- }: {
63
- columns: ColumnConfig[];
64
- tickers: TickerRecord[];
65
- cursorSymbol: string | null;
66
- hoveredIdx: number | null;
67
- setHoveredIdx: (index: number | null) => void;
68
- setCursorSymbol: (symbol: string) => void;
69
- resolveCell: (column: ColumnConfig, ticker: TickerRecord, financials: TickerFinancials | undefined) => TickerTableCell;
70
- financialsMap: Map<string, TickerFinancials>;
71
- headerScrollRef?: RefObject<ScrollBoxRenderable | null>;
72
- scrollRef?: RefObject<ScrollBoxRenderable | null>;
73
- syncHeaderScroll?: () => void;
74
- onBodyScrollActivity?: () => void;
75
- flashSymbols?: Map<string, QuoteFlashDirection>;
76
- sortColumnId?: string | null;
77
- sortDirection?: "asc" | "desc";
78
- onHeaderClick?: (columnId: string) => void;
79
- onRowActivate?: (ticker: TickerRecord) => void;
80
- emptyTitle?: string;
81
- emptyHint?: string;
82
- }) {
221
+ }: TickerListTableProps) {
83
222
  const safeFlashSymbols = flashSymbols ?? new Map<string, QuoteFlashDirection>();
84
- const lastActivatedRef = useRef<{ symbol: string; at: number } | null>(null);
85
223
 
86
224
  return (
87
225
  <>
88
- <scrollbox
89
- ref={headerScrollRef}
90
- height={1}
91
- scrollX
92
- focusable={false}
93
- >
94
- <box flexDirection="row" height={1} paddingX={1}>
95
- {columns.map((column) => {
96
- const isSorted = sortColumnId === column.id;
97
- const indicator = isSorted ? (sortDirection === "asc" ? " \u25B2" : " \u25BC") : "";
98
- const labelText = column.label + indicator;
99
- return (
100
- <box
101
- key={column.id}
102
- width={column.width + 1}
103
- onMouseDown={onHeaderClick ? (event) => {
104
- event.preventDefault();
105
- onHeaderClick(column.id);
106
- } : undefined}
107
- >
108
- <text attributes={TextAttributes.BOLD} fg={isSorted ? colors.text : colors.textDim}>
109
- {padTo(labelText, column.width, column.align)}
110
- </text>
111
- </box>
112
- );
113
- })}
114
- </box>
115
- </scrollbox>
226
+ <TickerListHeader
227
+ columns={columns}
228
+ headerScrollRef={headerScrollRef}
229
+ sortColumnId={sortColumnId}
230
+ sortDirection={sortDirection}
231
+ onHeaderClick={onHeaderClick}
232
+ />
116
233
 
117
234
  <scrollbox
118
235
  ref={scrollRef}
@@ -131,54 +248,21 @@ export function TickerListTable({
131
248
  </box>
132
249
  ) : (
133
250
  tickers.map((ticker, index) => {
134
- const isSelected = ticker.metadata.ticker === cursorSymbol;
135
- const isHovered = index === hoveredIdx && !isSelected;
136
- const financials = financialsMap.get(ticker.metadata.ticker);
137
- const rowBg = isSelected ? colors.selected : isHovered ? hoverBg() : colors.bg;
138
- const flashDirection = safeFlashSymbols.get(ticker.metadata.ticker);
139
-
140
251
  return (
141
- <box
252
+ <TickerListRow
142
253
  key={ticker.metadata.ticker}
143
- flexDirection="row"
144
- height={1}
145
- paddingX={1}
146
- backgroundColor={rowBg}
147
- onMouseMove={() => setHoveredIdx(index)}
148
- onMouseDown={(event) => {
149
- event.preventDefault();
150
- setCursorSymbol(ticker.metadata.ticker);
151
- if (!onRowActivate) return;
152
- const now = Date.now();
153
- const last = lastActivatedRef.current;
154
- if (last?.symbol === ticker.metadata.ticker && now - last.at <= 350) {
155
- lastActivatedRef.current = null;
156
- onRowActivate(ticker);
157
- return;
158
- }
159
- lastActivatedRef.current = {
160
- symbol: ticker.metadata.ticker,
161
- at: now,
162
- };
163
- }}
164
- >
165
- {columns.map((column) => {
166
- const { text, color } = resolveCell(column, ticker, financials);
167
- const baseFg = color || (isSelected ? colors.selectedText : colors.text);
168
- const shouldFlash = flashDirection != null && FLASHABLE_QUOTE_COLUMN_IDS.has(column.id);
169
- const cellFg = shouldFlash
170
- ? resolveQuoteFlashColor(flashDirection, baseFg)
171
- : baseFg;
172
-
173
- return (
174
- <box key={column.id} width={column.width + 1}>
175
- <text fg={cellFg}>
176
- {padTo(text, column.width, column.align)}
177
- </text>
178
- </box>
179
- );
180
- })}
181
- </box>
254
+ columns={columns}
255
+ ticker={ticker}
256
+ index={index}
257
+ isSelected={ticker.metadata.ticker === cursorSymbol}
258
+ isHovered={index === hoveredIdx && ticker.metadata.ticker !== cursorSymbol}
259
+ financials={financialsMap.get(ticker.metadata.ticker)}
260
+ flashDirection={safeFlashSymbols.get(ticker.metadata.ticker)}
261
+ setHoveredIdx={setHoveredIdx}
262
+ setCursorSymbol={setCursorSymbol}
263
+ resolveCell={resolveCell}
264
+ onRowActivate={onRowActivate}
265
+ />
182
266
  );
183
267
  })
184
268
  )}
@@ -1,8 +1,10 @@
1
- import { useMemo, useState, type RefObject } from "react";
1
+ import { useCallback, useMemo, useState, type RefObject } from "react";
2
2
  import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
3
  import { colors, hoverBg } from "../../theme/colors";
4
4
  import type { ColumnConfig } from "../../types/config";
5
+ import { useAppDispatch, usePaneInstance } from "../../state/app-context";
5
6
  import { padTo } from "../../utils/format";
7
+ import { useDoubleClickActivation } from "../use-double-click-activation";
6
8
  import { EmptyState } from "./status";
7
9
 
8
10
  export type DataTableColumn = Pick<
@@ -34,6 +36,7 @@ export interface DataTableProps<
34
36
  getItemKey: (item: T, index: number) => string;
35
37
  isSelected: (item: T, index: number) => boolean;
36
38
  onSelect: (item: T, index: number) => void;
39
+ onActivate?: (item: T, index: number) => void;
37
40
  renderCell: (
38
41
  item: T,
39
42
  column: C,
@@ -46,6 +49,11 @@ export interface DataTableProps<
46
49
  overscan?: number;
47
50
  }
48
51
 
52
+ interface DataTableRowPointerTarget<T> {
53
+ item: T;
54
+ index: number;
55
+ }
56
+
49
57
  export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
50
58
  columns,
51
59
  items,
@@ -61,12 +69,15 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
61
69
  getItemKey,
62
70
  isSelected,
63
71
  onSelect,
72
+ onActivate,
64
73
  renderCell,
65
74
  emptyStateTitle,
66
75
  emptyStateHint,
67
76
  virtualize = false,
68
77
  overscan = 3,
69
78
  }: DataTableProps<T, C>) {
79
+ const dispatch = useAppDispatch();
80
+ const paneInstanceId = usePaneInstance()?.instanceId ?? null;
70
81
  const [scrollVersion, setScrollVersion] = useState(0);
71
82
  const scrollTop = virtualize ? (scrollRef.current?.scrollTop ?? 0) : 0;
72
83
  const viewportHeight = virtualize
@@ -80,6 +91,17 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
80
91
  () => items.slice(startIndex, endIndex),
81
92
  [endIndex, items, scrollVersion, startIndex],
82
93
  );
94
+ const handleRowMouseDown =
95
+ useDoubleClickActivation<DataTableRowPointerTarget<T>>({
96
+ onSelect: ({ item, index }) => {
97
+ onSelect(item, index);
98
+ },
99
+ onActivate: onActivate
100
+ ? ({ item, index }) => {
101
+ onActivate(item, index);
102
+ }
103
+ : undefined,
104
+ });
83
105
 
84
106
  const handleBodyScrollActivity = () => {
85
107
  if (virtualize) {
@@ -87,11 +109,21 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
87
109
  }
88
110
  onBodyScrollActivity();
89
111
  };
112
+ const focusPane = useCallback(() => {
113
+ if (!paneInstanceId) return;
114
+ dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
115
+ }, [dispatch, paneInstanceId]);
90
116
 
91
117
  return (
92
118
  <>
93
119
  <scrollbox ref={headerScrollRef} height={1} scrollX focusable={false}>
94
- <box flexDirection="row" height={1} paddingX={1}>
120
+ <box
121
+ flexDirection="row"
122
+ height={1}
123
+ width="100%"
124
+ paddingX={1}
125
+ backgroundColor={colors.panel}
126
+ >
95
127
  {columns.map((column) => {
96
128
  const isSorted = sortColumnId === column.id;
97
129
  const indicator = isSorted
@@ -108,7 +140,9 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
108
140
  <box
109
141
  key={column.id}
110
142
  width={column.width + 1}
143
+ backgroundColor={colors.panel}
111
144
  onMouseDown={(event) => {
145
+ focusPane();
112
146
  event.preventDefault();
113
147
  onHeaderClick(column.id);
114
148
  }}
@@ -131,7 +165,10 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
131
165
  scrollX
132
166
  scrollY
133
167
  focusable={false}
134
- onMouseDown={() => queueMicrotask(syncHeaderScroll)}
168
+ onMouseDown={() => {
169
+ focusPane();
170
+ queueMicrotask(syncHeaderScroll);
171
+ }}
135
172
  onMouseUp={() => queueMicrotask(handleBodyScrollActivity)}
136
173
  onMouseDrag={() => queueMicrotask(handleBodyScrollActivity)}
137
174
  onMouseScroll={() => queueMicrotask(handleBodyScrollActivity)}
@@ -162,8 +199,12 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
162
199
  backgroundColor={rowBg}
163
200
  onMouseMove={() => setHoveredIdx(index)}
164
201
  onMouseDown={(event) => {
202
+ focusPane();
165
203
  event.preventDefault();
166
- onSelect(item, index);
204
+ handleRowMouseDown(getItemKey(item, index), {
205
+ item,
206
+ index,
207
+ });
167
208
  }}
168
209
  >
169
210
  {columns.map((column) => {
@@ -176,13 +217,17 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
176
217
  key={column.id}
177
218
  width={column.width + 1}
178
219
  onMouseDown={(event) => {
220
+ focusPane();
179
221
  if (cell.onMouseDown) {
180
222
  cell.onMouseDown(event);
181
223
  return;
182
224
  }
183
225
  event.preventDefault();
184
226
  event.stopPropagation?.();
185
- onSelect(item, index);
227
+ handleRowMouseDown(getItemKey(item, index), {
228
+ item,
229
+ index,
230
+ });
186
231
  }}
187
232
  >
188
233
  <text
@@ -43,6 +43,7 @@ export { Section, FieldRow, DialogFrame } from "./frame";
43
43
  export type { SectionProps, FieldRowProps, DialogFrameProps } from "./frame";
44
44
 
45
45
  export { ExternalLink, openUrl } from "./external-link";
46
+ export { PageStackView } from "./page-stack-view";
46
47
 
47
48
  export { Spinner, ProgressBar, SkeletonRow, LoadingBlock } from "./loading";
48
49
  export type {