gloomberb 0.6.1 → 0.7.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 (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -0,0 +1,218 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type ReactNode,
7
+ type RefObject,
8
+ } from "react";
9
+ import { Box, type ScrollBoxRenderable } from "../ui";
10
+
11
+ export interface TableViewKeyEvent {
12
+ name?: string;
13
+ ctrl?: boolean;
14
+ meta?: boolean;
15
+ option?: boolean;
16
+ shift?: boolean;
17
+ preventDefault?: () => void;
18
+ stopPropagation?: () => void;
19
+ }
20
+
21
+ export interface TableViewFrameProps {
22
+ width?: number;
23
+ height?: number;
24
+ backgroundColor?: string;
25
+ before?: ReactNode;
26
+ after?: ReactNode;
27
+ children: ReactNode;
28
+ }
29
+
30
+ export function TableViewFrame({
31
+ width,
32
+ height,
33
+ backgroundColor,
34
+ before,
35
+ after,
36
+ children,
37
+ }: TableViewFrameProps) {
38
+ return (
39
+ <Box
40
+ flexDirection="column"
41
+ flexGrow={1}
42
+ width={width}
43
+ height={height}
44
+ backgroundColor={backgroundColor}
45
+ overflow="hidden"
46
+ >
47
+ {before}
48
+ {children}
49
+ {after}
50
+ </Box>
51
+ );
52
+ }
53
+
54
+ export function isTableActivationKey(name: string | undefined): boolean {
55
+ return name === "enter" || name === "return";
56
+ }
57
+
58
+ export function isNextTableRowKey(name: string | undefined): boolean {
59
+ return name === "j" || name === "down";
60
+ }
61
+
62
+ export function isPreviousTableRowKey(name: string | undefined): boolean {
63
+ return name === "k" || name === "up";
64
+ }
65
+
66
+ export function stopTableKey(event: TableViewKeyEvent) {
67
+ event.stopPropagation?.();
68
+ event.preventDefault?.();
69
+ }
70
+
71
+ export function useTableViewState({
72
+ headerScrollRef,
73
+ scrollRef,
74
+ syncHeaderScroll,
75
+ hoveredIdx,
76
+ setHoveredIdx,
77
+ }: {
78
+ headerScrollRef?: RefObject<ScrollBoxRenderable | null>;
79
+ scrollRef?: RefObject<ScrollBoxRenderable | null>;
80
+ syncHeaderScroll?: () => void;
81
+ hoveredIdx?: number | null;
82
+ setHoveredIdx?: (index: number | null) => void;
83
+ }) {
84
+ const internalHeaderScrollRef = useRef<ScrollBoxRenderable>(null);
85
+ const internalScrollRef = useRef<ScrollBoxRenderable>(null);
86
+ const [internalHoveredIdx, setInternalHoveredIdx] = useState<number | null>(null);
87
+
88
+ const effectiveHeaderScrollRef = headerScrollRef ?? internalHeaderScrollRef;
89
+ const effectiveScrollRef = scrollRef ?? internalScrollRef;
90
+ const effectiveHoveredIdx = hoveredIdx !== undefined ? hoveredIdx : internalHoveredIdx;
91
+ const effectiveSetHoveredIdx = setHoveredIdx ?? setInternalHoveredIdx;
92
+
93
+ const defaultSyncHeaderScroll = useCallback(() => {
94
+ const body = effectiveScrollRef.current;
95
+ const header = effectiveHeaderScrollRef.current;
96
+ if (!body || !header) return;
97
+ if (header.scrollLeft !== body.scrollLeft) {
98
+ header.scrollLeft = body.scrollLeft;
99
+ }
100
+ }, [effectiveHeaderScrollRef, effectiveScrollRef]);
101
+
102
+ return {
103
+ effectiveHeaderScrollRef,
104
+ effectiveScrollRef,
105
+ effectiveHoveredIdx,
106
+ effectiveSetHoveredIdx,
107
+ effectiveSyncHeaderScroll: syncHeaderScroll ?? defaultSyncHeaderScroll,
108
+ };
109
+ }
110
+
111
+ export function useTableBodyScrollActivity({
112
+ onBodyScrollActivity,
113
+ syncHeaderScroll,
114
+ afterScroll,
115
+ }: {
116
+ onBodyScrollActivity?: () => void;
117
+ syncHeaderScroll: () => void;
118
+ afterScroll?: () => void;
119
+ }) {
120
+ return useCallback(() => {
121
+ if (onBodyScrollActivity) {
122
+ onBodyScrollActivity();
123
+ } else {
124
+ queueMicrotask(syncHeaderScroll);
125
+ }
126
+ if (afterScroll) {
127
+ queueMicrotask(afterScroll);
128
+ }
129
+ }, [afterScroll, onBodyScrollActivity, syncHeaderScroll]);
130
+ }
131
+
132
+ export function useScrollBoxScrollActivity({
133
+ scrollRef,
134
+ onVerticalScroll,
135
+ onHorizontalScroll,
136
+ }: {
137
+ scrollRef: RefObject<ScrollBoxRenderable | null>;
138
+ onVerticalScroll?: () => void;
139
+ onHorizontalScroll?: () => void;
140
+ }) {
141
+ const onVerticalScrollRef = useRef(onVerticalScroll);
142
+ const onHorizontalScrollRef = useRef(onHorizontalScroll);
143
+ const verticalScrollScheduledRef = useRef(false);
144
+ const horizontalScrollScheduledRef = useRef(false);
145
+
146
+ useEffect(() => {
147
+ onVerticalScrollRef.current = onVerticalScroll;
148
+ onHorizontalScrollRef.current = onHorizontalScroll;
149
+ }, [onHorizontalScroll, onVerticalScroll]);
150
+
151
+ useEffect(() => {
152
+ const scrollBox = scrollRef.current;
153
+ if (!scrollBox) return;
154
+ let active = true;
155
+
156
+ const handleVerticalChange = () => {
157
+ if (verticalScrollScheduledRef.current) return;
158
+ verticalScrollScheduledRef.current = true;
159
+ queueMicrotask(() => {
160
+ verticalScrollScheduledRef.current = false;
161
+ if (!active) return;
162
+ onVerticalScrollRef.current?.();
163
+ });
164
+ };
165
+ const handleHorizontalChange = () => {
166
+ if (horizontalScrollScheduledRef.current) return;
167
+ horizontalScrollScheduledRef.current = true;
168
+ queueMicrotask(() => {
169
+ horizontalScrollScheduledRef.current = false;
170
+ if (!active) return;
171
+ onHorizontalScrollRef.current?.();
172
+ });
173
+ };
174
+
175
+ scrollBox.verticalScrollBar.on("change", handleVerticalChange);
176
+ scrollBox.horizontalScrollBar.on("change", handleHorizontalChange);
177
+
178
+ return () => {
179
+ active = false;
180
+ scrollBox.verticalScrollBar.off("change", handleVerticalChange);
181
+ scrollBox.horizontalScrollBar.off("change", handleHorizontalChange);
182
+ };
183
+ }, [scrollRef]);
184
+ }
185
+
186
+ export function useResetTableScroll({
187
+ headerScrollRef,
188
+ scrollRef,
189
+ resetScrollKey,
190
+ afterReset,
191
+ }: {
192
+ headerScrollRef: RefObject<ScrollBoxRenderable | null>;
193
+ scrollRef: RefObject<ScrollBoxRenderable | null>;
194
+ resetScrollKey?: unknown;
195
+ afterReset?: () => void;
196
+ }) {
197
+ const afterResetRef = useRef(afterReset);
198
+
199
+ useEffect(() => {
200
+ afterResetRef.current = afterReset;
201
+ }, [afterReset]);
202
+
203
+ useEffect(() => {
204
+ if (resetScrollKey === undefined) return;
205
+ const body = scrollRef.current;
206
+ if (body) {
207
+ body.scrollTop = 0;
208
+ body.scrollLeft = 0;
209
+ }
210
+ const header = headerScrollRef.current;
211
+ if (header) {
212
+ header.scrollLeft = 0;
213
+ }
214
+ if (afterResetRef.current) {
215
+ queueMicrotask(afterResetRef.current);
216
+ }
217
+ }, [headerScrollRef, resetScrollKey, scrollRef]);
218
+ }
@@ -2,15 +2,24 @@ import {
2
2
  useCallback,
3
3
  useEffect,
4
4
  useRef,
5
- useState,
6
5
  type ReactNode,
7
6
  type RefObject,
8
7
  } from "react";
9
- import { Box, type ScrollBoxRenderable } from "../ui";
8
+ import type { ScrollBoxRenderable } from "../ui";
10
9
  import { useShortcut } from "../react/input";
11
10
  import { TickerListTable, type TickerListTableProps } from "./ticker-list-table";
12
11
  import type { TickerRecord } from "../types/ticker";
13
12
  import type { DataTableKeyEvent } from "./data-table-view";
13
+ import {
14
+ isNextTableRowKey,
15
+ isPreviousTableRowKey,
16
+ isTableActivationKey,
17
+ stopTableKey,
18
+ TableViewFrame,
19
+ useResetTableScroll,
20
+ useTableBodyScrollActivity,
21
+ useTableViewState,
22
+ } from "./table-view-shared";
14
23
 
15
24
  export interface TickerListVisibleRange {
16
25
  start: number;
@@ -47,23 +56,6 @@ export interface TickerListTableViewProps extends Omit<
47
56
  resetScrollKey?: unknown;
48
57
  }
49
58
 
50
- function isActivationKey(name: string | undefined): boolean {
51
- return name === "enter" || name === "return";
52
- }
53
-
54
- function isNextRowKey(name: string | undefined): boolean {
55
- return name === "j" || name === "down";
56
- }
57
-
58
- function isPreviousRowKey(name: string | undefined): boolean {
59
- return name === "k" || name === "up";
60
- }
61
-
62
- function stopTableKey(event: DataTableKeyEvent) {
63
- event.stopPropagation?.();
64
- event.preventDefault?.();
65
- }
66
-
67
59
  export function TickerListTableView({
68
60
  focused = false,
69
61
  rootBefore,
@@ -86,20 +78,23 @@ export function TickerListTableView({
86
78
  resetScrollKey,
87
79
  ...tableProps
88
80
  }: TickerListTableViewProps) {
89
- const internalHeaderScrollRef = useRef<ScrollBoxRenderable>(null);
90
- const internalScrollRef = useRef<ScrollBoxRenderable>(null);
91
- const [internalHoveredIdx, setInternalHoveredIdx] = useState<number | null>(
92
- null,
93
- );
94
-
95
- const effectiveHeaderScrollRef = headerScrollRef ?? internalHeaderScrollRef;
96
- const effectiveScrollRef = scrollRef ?? internalScrollRef;
97
- const effectiveHoveredIdx =
98
- hoveredIdx !== undefined ? hoveredIdx : internalHoveredIdx;
99
- const effectiveSetHoveredIdx = setHoveredIdx ?? setInternalHoveredIdx;
81
+ const {
82
+ effectiveHeaderScrollRef,
83
+ effectiveScrollRef,
84
+ effectiveHoveredIdx,
85
+ effectiveSetHoveredIdx,
86
+ effectiveSyncHeaderScroll,
87
+ } = useTableViewState({
88
+ headerScrollRef,
89
+ scrollRef,
90
+ syncHeaderScroll,
91
+ hoveredIdx,
92
+ setHoveredIdx,
93
+ });
100
94
  const selectedIndex = tableProps.tickers.findIndex(
101
95
  (ticker) => ticker.metadata.ticker === tableProps.cursorSymbol,
102
96
  );
97
+ const lastScrollCursorSymbolRef = useRef<string | null | undefined>(undefined);
103
98
 
104
99
  const emitVisibleRange = useCallback(() => {
105
100
  if (!onVisibleRangeChange) return;
@@ -113,24 +108,11 @@ export function TickerListTableView({
113
108
  onVisibleRangeChange({ start, end });
114
109
  }, [effectiveScrollRef, onVisibleRangeChange, tableProps.tickers.length, visibleRangeBuffer]);
115
110
 
116
- const defaultSyncHeaderScroll = useCallback(() => {
117
- const body = effectiveScrollRef.current;
118
- const header = effectiveHeaderScrollRef.current;
119
- if (!body || !header) return;
120
- if (header.scrollLeft !== body.scrollLeft) {
121
- header.scrollLeft = body.scrollLeft;
122
- }
123
- }, [effectiveHeaderScrollRef, effectiveScrollRef]);
124
- const effectiveSyncHeaderScroll = syncHeaderScroll ?? defaultSyncHeaderScroll;
125
-
126
- const handleBodyScrollActivity = useCallback(() => {
127
- if (onBodyScrollActivity) {
128
- onBodyScrollActivity();
129
- } else {
130
- queueMicrotask(effectiveSyncHeaderScroll);
131
- }
132
- queueMicrotask(emitVisibleRange);
133
- }, [effectiveSyncHeaderScroll, emitVisibleRange, onBodyScrollActivity]);
111
+ const handleBodyScrollActivity = useTableBodyScrollActivity({
112
+ onBodyScrollActivity,
113
+ syncHeaderScroll: effectiveSyncHeaderScroll,
114
+ afterScroll: emitVisibleRange,
115
+ });
134
116
 
135
117
  useEffect(() => {
136
118
  const scrollBox = effectiveScrollRef.current;
@@ -139,19 +121,12 @@ export function TickerListTableView({
139
121
  emitVisibleRange();
140
122
  }, [emitVisibleRange, effectiveScrollRef, rootHeight, tableProps.tickers.length]);
141
123
 
142
- useEffect(() => {
143
- if (resetScrollKey === undefined) return;
144
- const body = effectiveScrollRef.current;
145
- if (body) {
146
- body.scrollTop = 0;
147
- body.scrollLeft = 0;
148
- }
149
- const header = effectiveHeaderScrollRef.current;
150
- if (header) {
151
- header.scrollLeft = 0;
152
- }
153
- queueMicrotask(emitVisibleRange);
154
- }, [effectiveHeaderScrollRef, effectiveScrollRef, emitVisibleRange, resetScrollKey]);
124
+ useResetTableScroll({
125
+ headerScrollRef: effectiveHeaderScrollRef,
126
+ scrollRef: effectiveScrollRef,
127
+ resetScrollKey,
128
+ afterReset: emitVisibleRange,
129
+ });
155
130
 
156
131
  const selectIndex = useCallback((index: number) => {
157
132
  if (index < 0 || index >= tableProps.tickers.length) return;
@@ -192,19 +167,19 @@ export function TickerListTableView({
192
167
  if (onRootKeyDown?.(event)) return;
193
168
  if (tableProps.tickers.length === 0) return;
194
169
 
195
- if (isNextRowKey(event.name)) {
170
+ if (isNextTableRowKey(event.name)) {
196
171
  stopTableKey(event);
197
172
  selectByOffset(1);
198
173
  return;
199
174
  }
200
175
 
201
- if (isPreviousRowKey(event.name)) {
176
+ if (isPreviousTableRowKey(event.name)) {
202
177
  stopTableKey(event);
203
178
  selectByOffset(-1);
204
179
  return;
205
180
  }
206
181
 
207
- if (isActivationKey(event.name)) {
182
+ if (isTableActivationKey(event.name)) {
208
183
  stopTableKey(event);
209
184
  activateSelection();
210
185
  }
@@ -212,7 +187,19 @@ export function TickerListTableView({
212
187
 
213
188
  useEffect(() => {
214
189
  const scrollBox = effectiveScrollRef.current;
215
- if (!scrollBox?.viewport || selectedIndex < 0) return;
190
+ if (!scrollBox?.viewport || selectedIndex < 0) {
191
+ queueMicrotask(emitVisibleRange);
192
+ return;
193
+ }
194
+
195
+ const shouldScrollToCursor =
196
+ lastScrollCursorSymbolRef.current !== tableProps.cursorSymbol;
197
+ lastScrollCursorSymbolRef.current = tableProps.cursorSymbol;
198
+
199
+ if (!shouldScrollToCursor) {
200
+ queueMicrotask(emitVisibleRange);
201
+ return;
202
+ }
216
203
 
217
204
  const viewportHeight = Math.max(scrollBox.viewport.height, 1);
218
205
  if (selectedIndex < scrollBox.scrollTop) {
@@ -221,18 +208,16 @@ export function TickerListTableView({
221
208
  scrollBox.scrollTo(selectedIndex - viewportHeight + 1);
222
209
  }
223
210
  queueMicrotask(emitVisibleRange);
224
- }, [effectiveScrollRef, emitVisibleRange, selectedIndex]);
211
+ }, [effectiveScrollRef, emitVisibleRange, selectedIndex, tableProps.cursorSymbol]);
225
212
 
226
213
  return (
227
- <Box
228
- flexDirection="column"
229
- flexGrow={1}
214
+ <TableViewFrame
230
215
  width={rootWidth}
231
216
  height={rootHeight}
232
217
  backgroundColor={rootBackgroundColor}
233
- overflow="hidden"
218
+ before={rootBefore}
219
+ after={rootAfter}
234
220
  >
235
- {rootBefore}
236
221
  <TickerListTable
237
222
  {...tableProps}
238
223
  headerScrollRef={effectiveHeaderScrollRef}
@@ -242,7 +227,6 @@ export function TickerListTableView({
242
227
  hoveredIdx={effectiveHoveredIdx}
243
228
  setHoveredIdx={effectiveSetHoveredIdx}
244
229
  />
245
- {rootAfter}
246
- </Box>
230
+ </TableViewFrame>
247
231
  );
248
232
  }
@@ -1,13 +1,17 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { act, useState } from "react";
2
+ import { act, useEffect, useRef, useState } from "react";
3
3
  import { testRender } from "../renderers/opentui/test-utils";
4
4
  import type { ColumnConfig } from "../types/config";
5
5
  import type { TickerFinancials } from "../types/financials";
6
6
  import type { TickerRecord } from "../types/ticker";
7
+ import type { ScrollBoxRenderable } from "../ui";
7
8
  import { TickerListTable, type TickerTableCell } from "./ticker-list-table";
9
+ import { TickerListTableView } from "./ticker-list-table-view";
8
10
 
9
11
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
10
12
  let setHarnessCursorSymbol: ((symbol: string) => void) | null = null;
13
+ let setHarnessTickers: ((tickers: TickerRecord[]) => void) | null = null;
14
+ let tableScrollRef: ScrollBoxRenderable | null = null;
11
15
  let resolveCellCallCount = 0;
12
16
 
13
17
  const columns: ColumnConfig[] = [
@@ -60,6 +64,16 @@ function TickerListTableHarness() {
60
64
 
61
65
  function LargeTickerListTableHarness() {
62
66
  const [cursorSymbol, setCursorSymbol] = useState("T0");
67
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
68
+
69
+ useEffect(() => {
70
+ tableScrollRef = scrollRef.current;
71
+ return () => {
72
+ if (tableScrollRef === scrollRef.current) {
73
+ tableScrollRef = null;
74
+ }
75
+ };
76
+ });
63
77
 
64
78
  return (
65
79
  <TickerListTable
@@ -71,6 +85,35 @@ function LargeTickerListTableHarness() {
71
85
  setCursorSymbol={setCursorSymbol}
72
86
  resolveCell={resolveCell}
73
87
  financialsMap={financialsMap}
88
+ scrollRef={scrollRef}
89
+ />
90
+ );
91
+ }
92
+
93
+ function ReorderingTickerListTableViewHarness() {
94
+ const [rows, setRows] = useState(manyTickers);
95
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
96
+ setHarnessTickers = setRows;
97
+
98
+ useEffect(() => {
99
+ tableScrollRef = scrollRef.current;
100
+ return () => {
101
+ if (tableScrollRef === scrollRef.current) {
102
+ tableScrollRef = null;
103
+ }
104
+ };
105
+ });
106
+
107
+ return (
108
+ <TickerListTableView
109
+ focused
110
+ columns={columns}
111
+ tickers={rows}
112
+ cursorSymbol="T0"
113
+ setCursorSymbol={() => {}}
114
+ resolveCell={resolveCell}
115
+ financialsMap={financialsMap}
116
+ scrollRef={scrollRef}
74
117
  />
75
118
  );
76
119
  }
@@ -84,6 +127,8 @@ afterEach(async () => {
84
127
  }
85
128
  resolveCellCallCount = 0;
86
129
  setHarnessCursorSymbol = null;
130
+ setHarnessTickers = null;
131
+ tableScrollRef = null;
87
132
  });
88
133
 
89
134
  describe("TickerListTable", () => {
@@ -125,5 +170,41 @@ describe("TickerListTable", () => {
125
170
  expect(frame).toContain("T0");
126
171
  expect(frame).not.toContain("T999");
127
172
  expect(resolveCellCallCount).toBeLessThan(40);
173
+
174
+ await act(async () => {
175
+ for (let index = 0; index < 12; index++) {
176
+ await testSetup!.mockMouse.scroll(2, 2, "down");
177
+ }
178
+ await Promise.resolve();
179
+ await testSetup!.renderOnce();
180
+ });
181
+
182
+ const scrollTop = tableScrollRef?.scrollTop ?? 0;
183
+ expect(scrollTop).toBeGreaterThan(0);
184
+ expect(testSetup.captureCharFrame()).toContain(`T${scrollTop}`);
185
+ });
186
+
187
+ test("preserves manual scroll when market data reorders rows around the same cursor", async () => {
188
+ testSetup = await testRender(
189
+ <ReorderingTickerListTableViewHarness />,
190
+ { width: 20, height: 8 },
191
+ );
192
+
193
+ await act(async () => {
194
+ await testSetup!.renderOnce();
195
+ await testSetup!.renderOnce();
196
+ });
197
+
198
+ tableScrollRef!.scrollTop = 20;
199
+ expect(tableScrollRef?.scrollTop).toBe(20);
200
+
201
+ await act(async () => {
202
+ setHarnessTickers?.([...manyTickers.slice(1), manyTickers[0]!]);
203
+ await Promise.resolve();
204
+ await testSetup!.renderOnce();
205
+ await testSetup!.renderOnce();
206
+ });
207
+
208
+ expect(tableScrollRef?.scrollTop).toBe(20);
128
209
  });
129
210
  });
@@ -1,8 +1,18 @@
1
- import { Box, ScrollBox, Text, tickerContextMenuItems, useContextMenu, useRendererHost, useUiCapabilities } from "../ui";
1
+ import {
2
+ Box,
3
+ ScrollBox,
4
+ Text,
5
+ tickerContextMenuItems,
6
+ useContextMenu,
7
+ useNativeRenderer,
8
+ useRendererHost,
9
+ useUiCapabilities,
10
+ } from "../ui";
2
11
  import { memo, useCallback, useMemo, useRef, useState, type RefObject } from "react";
3
12
  import { TextAttributes, type ScrollBoxRenderable } from "../ui";
4
13
  import { EmptyState } from "./ui";
5
14
  import { colors, hoverBg } from "../theme/colors";
15
+ import { useThemeColors } from "../theme/theme-context";
6
16
  import { getSharedRegistry } from "../plugins/registry";
7
17
  import type { ColumnConfig } from "../types/config";
8
18
  import type { TickerFinancials, PricePoint } from "../types/financials";
@@ -12,8 +22,8 @@ import { renderChart, resolveChartPalette, type StyledContent } from "./chart/ch
12
22
  import type { ProjectedChartPoint } from "./chart/chart-data";
13
23
  import { tableContentWidthProps, useMeasuredTableContentWidth } from "./ui/table-layout";
14
24
  import { useViewport } from "../react/input";
15
- import { useRafCallback } from "../react/use-raf-callback";
16
25
  import { measurePerf } from "../utils/perf-marks";
26
+ import { useScrollBoxScrollActivity } from "./table-view-shared";
17
27
 
18
28
  export interface TickerTableCell {
19
29
  text: string;
@@ -102,6 +112,7 @@ const TickerListHeader = memo(function TickerListHeader({
102
112
  onHeaderClick?: (columnId: string) => void;
103
113
  onSizeChange?: () => void;
104
114
  }) {
115
+ useThemeColors();
105
116
  return (
106
117
  <ScrollBox
107
118
  ref={headerScrollRef}
@@ -211,6 +222,7 @@ const TickerListRow = memo(function TickerListRow({
211
222
  priceHistory?: PricePoint[];
212
223
  contentWidth: number;
213
224
  }) {
225
+ useThemeColors();
214
226
  const lastActivatedAtRef = useRef<number | null>(null);
215
227
  const rowBg = isSelected ? colors.selected : isHovered ? hoverBg() : colors.bg;
216
228
 
@@ -328,6 +340,7 @@ export function TickerListTable({
328
340
  const hoveredIdxRef = useRef(hoveredIdx);
329
341
  const appViewport = useViewport();
330
342
  const renderer = useRendererHost();
343
+ const nativeRenderer = useNativeRenderer();
331
344
  const { showContextMenu } = useContextMenu();
332
345
  const { nativeContextMenu } = useUiCapabilities();
333
346
  const [scrollVersion, setScrollVersion] = useState(0);
@@ -387,12 +400,16 @@ export function TickerListTable({
387
400
  setScrollVersion((current) => current + 1);
388
401
  }
389
402
  onBodyScrollActivity?.();
390
- }, [onBodyScrollActivity, virtualize]);
403
+ nativeRenderer.requestRender();
404
+ }, [nativeRenderer, onBodyScrollActivity, virtualize]);
391
405
  const syncHeader = useCallback(() => {
392
406
  syncHeaderScroll?.();
393
407
  }, [syncHeaderScroll]);
394
- const scheduleBodyScrollActivity = useRafCallback(handleBodyScrollActivity);
395
- const scheduleHeaderScrollSync = useRafCallback(syncHeader);
408
+ useScrollBoxScrollActivity({
409
+ scrollRef: effectiveScrollRef,
410
+ onVerticalScroll: handleBodyScrollActivity,
411
+ onHorizontalScroll: syncHeader,
412
+ });
396
413
  const setHoveredIdxIfChanged = useCallback((index: number | null) => {
397
414
  if (hoveredIdxRef.current === index) return;
398
415
  setHoveredIdx(index);
@@ -448,10 +465,6 @@ export function TickerListTable({
448
465
  scrollX
449
466
  scrollY
450
467
  focusable={false}
451
- onMouseDown={scheduleHeaderScrollSync}
452
- onMouseUp={scheduleBodyScrollActivity}
453
- onMouseDrag={scheduleBodyScrollActivity}
454
- onMouseScroll={scheduleBodyScrollActivity}
455
468
  onSizeChange={measureContentWidth}
456
469
  >
457
470
  {tickers.length === 0 ? (
@@ -0,0 +1,66 @@
1
+ import { useCallback, type ReactNode } from "react";
2
+ import { Box, Text } from "../../ui";
3
+ import { colors } from "../../theme/colors";
4
+ import { type PromptContext, useDialogKeyboard } from "../../ui/dialog";
5
+ import { Button, type ButtonVariant } from "./button";
6
+ import { DialogFrame } from "./frame";
7
+
8
+ export interface ConfirmDialogProps extends PromptContext<boolean> {
9
+ title: string;
10
+ body: ReactNode | string | string[];
11
+ confirmLabel: string;
12
+ cancelLabel?: string;
13
+ confirmVariant?: ButtonVariant;
14
+ width?: number;
15
+ footer?: string;
16
+ }
17
+
18
+ export function ConfirmDialog({
19
+ resolve,
20
+ title,
21
+ body,
22
+ confirmLabel,
23
+ cancelLabel = "Cancel",
24
+ confirmVariant = "danger",
25
+ width = 52,
26
+ footer,
27
+ }: ConfirmDialogProps) {
28
+ const confirm = useCallback(() => resolve(true), [resolve]);
29
+ const cancel = useCallback(() => resolve(false), [resolve]);
30
+
31
+ useDialogKeyboard((event) => {
32
+ event.stopPropagation();
33
+ if (event.name === "enter" || event.name === "return") {
34
+ confirm();
35
+ return;
36
+ }
37
+ if (event.name === "escape") {
38
+ cancel();
39
+ }
40
+ });
41
+
42
+ return (
43
+ <DialogFrame title={title} footer={footer ?? `Enter ${confirmLabel.toLowerCase()} · Esc cancel`}>
44
+ <Box flexDirection="column" width={width}>
45
+ {renderBody(body)}
46
+ <Box height={1} />
47
+ <Box flexDirection="row" gap={1}>
48
+ <Button label={confirmLabel} variant={confirmVariant} onPress={confirm} />
49
+ <Button label={cancelLabel} variant="secondary" onPress={cancel} />
50
+ </Box>
51
+ </Box>
52
+ </DialogFrame>
53
+ );
54
+ }
55
+
56
+ function renderBody(body: ReactNode | string | string[]): ReactNode {
57
+ if (Array.isArray(body)) {
58
+ return body.map((line, index) => (
59
+ <Text key={`${line}:${index}`} fg={index === 0 ? colors.text : colors.textDim}>{line}</Text>
60
+ ));
61
+ }
62
+ if (typeof body === "string") {
63
+ return <Text fg={colors.text}>{body}</Text>;
64
+ }
65
+ return body;
66
+ }