gloomberb 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +2 -2
  3. package/package.json +2 -1
  4. package/src/api-client/auth.ts +8 -0
  5. package/src/api-client/index.ts +70 -0
  6. package/src/api-client/types.ts +49 -0
  7. package/src/app/runtime/desktop-deeplink.test.ts +8 -0
  8. package/src/app/runtime/desktop-deeplink.ts +14 -1
  9. package/src/components/chart/composite/composite-chart.test.tsx +340 -17
  10. package/src/components/chart/composite/composite-chart.tsx +859 -68
  11. package/src/components/chart/composite/interactions.test.ts +78 -22
  12. package/src/components/chart/composite/interactions.ts +45 -17
  13. package/src/components/chart/composite/rasterizer.ts +8 -2
  14. package/src/components/chart/composite/renderers.test.ts +61 -1
  15. package/src/components/chart/composite/scene.ts +14 -4
  16. package/src/components/chart/composite/tools.test.ts +371 -0
  17. package/src/components/chart/composite/tools.ts +508 -0
  18. package/src/components/chart/composite/types.ts +4 -0
  19. package/src/components/chart/core/pointer.ts +2 -0
  20. package/src/components/chart/native/raster/primitives.ts +18 -0
  21. package/src/components/chart/price-axis-labels.tsx +42 -2
  22. package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
  23. package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
  24. package/src/components/command-bar/action-runtime.ts +3 -0
  25. package/src/components/command-bar/assist/inventory.test.ts +93 -0
  26. package/src/components/command-bar/assist/inventory.ts +92 -0
  27. package/src/components/command-bar/assist/model.test.ts +104 -0
  28. package/src/components/command-bar/assist/model.ts +169 -0
  29. package/src/components/command-bar/assist/runtime.ts +185 -0
  30. package/src/components/command-bar/commands/direct/index.ts +1 -1
  31. package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
  32. package/src/components/command-bar/list/model.ts +15 -2
  33. package/src/components/command-bar/list/navigation.ts +6 -0
  34. package/src/components/command-bar/list/view.tsx +16 -3
  35. package/src/components/command-bar/pane-templates/workflow.ts +1 -0
  36. package/src/components/command-bar/panel/body.tsx +2 -0
  37. package/src/components/command-bar/panel/index.tsx +3 -0
  38. package/src/components/command-bar/panel/runtime.ts +6 -0
  39. package/src/components/command-bar/panel/types.ts +1 -0
  40. package/src/components/command-bar/routes/root/results.ts +28 -2
  41. package/src/components/command-bar/routes/root/runtime.ts +66 -36
  42. package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
  43. package/src/components/command-bar/routing/navigation-state.ts +17 -0
  44. package/src/components/command-bar/selection-runtime.ts +26 -0
  45. package/src/components/command-bar/surface/assist.test.tsx +276 -0
  46. package/src/components/command-bar/surface/index.tsx +93 -2
  47. package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
  48. package/src/components/command-bar/view-model.ts +7 -1
  49. package/src/components/command-bar/workflow/broker.test.ts +22 -0
  50. package/src/components/command-bar/workflow/broker.ts +1 -0
  51. package/src/components/index.ts +1 -1
  52. package/src/components/layout/detached-pane-shell.tsx +40 -2
  53. package/src/components/layout/floating-pane.tsx +4 -1
  54. package/src/components/layout/pane/header.tsx +64 -6
  55. package/src/components/layout/pane/index.tsx +4 -1
  56. package/src/components/layout/shell/index.tsx +23 -0
  57. package/src/components/layout/shell/pane/layers.tsx +5 -0
  58. package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
  59. package/src/components/onboarding/account-step/form-panel.tsx +157 -0
  60. package/src/components/onboarding/account-step/index.tsx +58 -0
  61. package/src/components/onboarding/account-step/model.test.ts +67 -0
  62. package/src/components/onboarding/account-step/model.ts +126 -0
  63. package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
  64. package/src/components/onboarding/onboarding-steps.tsx +91 -55
  65. package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
  66. package/src/components/onboarding/onboarding-wizard.tsx +93 -9
  67. package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
  68. package/src/components/onboarding/wizard-account.ts +235 -0
  69. package/src/components/onboarding/wizard-keyboard.ts +67 -1
  70. package/src/components/onboarding/wizard-model.ts +2 -2
  71. package/src/components/ui/data-table/index.tsx +1 -0
  72. package/src/components/ui/data-table/opentui/index.tsx +44 -2
  73. package/src/components/ui/data-table/types.ts +10 -0
  74. package/src/components/ui/data-table/visible-range.test.ts +29 -0
  75. package/src/components/ui/data-table/visible-range.ts +21 -0
  76. package/src/components/ui/ui.test.tsx +23 -1
  77. package/src/i18n/es.ts +1093 -0
  78. package/src/i18n/index.test.ts +5 -2
  79. package/src/i18n/index.ts +3 -0
  80. package/src/i18n/ja.ts +66 -3
  81. package/src/i18n/ko.ts +66 -3
  82. package/src/i18n/languages.test.ts +4 -1
  83. package/src/i18n/languages.ts +12 -1
  84. package/src/i18n/zh-cn.ts +66 -3
  85. package/src/i18n/zh-tw.ts +66 -3
  86. package/src/market-data/coordinator/index.ts +1 -1
  87. package/src/market-data/coordinator/quotes.ts +64 -1
  88. package/src/market-data/coordinator/subscriptions.test.ts +72 -0
  89. package/src/market-data/quotes/cadence.ts +2 -0
  90. package/src/plugins/builtin/account-management/footer.ts +9 -3
  91. package/src/plugins/builtin/account-management/model.ts +53 -1
  92. package/src/plugins/builtin/account-management/pane.tsx +107 -24
  93. package/src/plugins/builtin/ai/index.tsx +7 -2
  94. package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
  95. package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
  96. package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
  97. package/src/plugins/builtin/chart-composer/index.tsx +10 -2
  98. package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
  99. package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
  100. package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
  101. package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
  102. package/src/plugins/builtin/cloud/plugin.tsx +9 -1
  103. package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
  104. package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
  105. package/src/plugins/builtin/help/index.tsx +38 -0
  106. package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
  107. package/src/plugins/builtin/options/footer.test.ts +20 -112
  108. package/src/plugins/builtin/options/footer.ts +35 -78
  109. package/src/plugins/builtin/options/index.tsx +6 -0
  110. package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
  111. package/src/plugins/builtin/options/live-quotes.ts +93 -39
  112. package/src/plugins/builtin/options/view.test.tsx +5 -2
  113. package/src/plugins/builtin/options/view.tsx +49 -9
  114. package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
  115. package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
  116. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
  117. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
  118. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
  119. package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
  120. package/src/plugins/builtin/shared/live-streaming.ts +60 -0
  121. package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
  122. package/src/plugins/builtin/shared/plan-access.ts +109 -0
  123. package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
  124. package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
  125. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
  126. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
  127. package/src/plugins/registry/index.test.ts +51 -0
  128. package/src/plugins/registry/index.ts +12 -0
  129. package/src/plugins/registry/pane-settings.ts +29 -0
  130. package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
  131. package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
  132. package/src/renderers/electrobun/view/host/box.tsx +3 -0
  133. package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
  134. package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
  135. package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
  136. package/src/renderers/electrobun/view/host/input.tsx +16 -1
  137. package/src/renderers/electrobun/view/host/mouse.ts +1 -0
  138. package/src/renderers/electrobun/view/host/style.ts +1 -1
  139. package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
  140. package/src/renderers/electrobun/view/main.tsx +2 -0
  141. package/src/renderers/opentui/chart-surface.tsx +2 -0
  142. package/src/renderers/opentui/host.tsx +3 -0
  143. package/src/sources/gloomberb-cloud/index.test.ts +8 -0
  144. package/src/sources/gloomberb-cloud/index.ts +4 -4
  145. package/src/sources/provider-router/history.test.ts +3 -3
  146. package/src/sources/provider-router/history.ts +15 -4
  147. package/src/sources/yahoo-finance/history.test.ts +47 -0
  148. package/src/sources/yahoo-finance/history.ts +5 -1
  149. package/src/state/hooks/quote-streaming.test.tsx +181 -1
  150. package/src/state/hooks/quote-streaming.ts +93 -7
  151. package/src/theme/colors.ts +20 -1
  152. package/src/theme/themes.test.ts +3 -0
  153. package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
  154. package/src/time-series/resolution.test.ts +51 -0
  155. package/src/time-series/resolution.ts +41 -0
  156. package/src/time-series/resolve.test.ts +207 -1
  157. package/src/time-series/resolve.ts +196 -29
  158. package/src/time-series/spec-studies.test.ts +4 -0
  159. package/src/time-series/spec.ts +15 -1
  160. package/src/time-series/types.ts +7 -0
  161. package/src/time-series/use-chart-resolution.test.tsx +37 -0
  162. package/src/time-series/use-chart-resolution.ts +17 -1
  163. package/src/types/plugin.ts +9 -0
  164. package/src/ui/host.tsx +17 -0
@@ -0,0 +1,235 @@
1
+ import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from "react";
2
+ import { apiClient } from "../../api-client";
3
+ import { chatController } from "../../plugins/builtin/chat/controller";
4
+ import { debugLog } from "../../utils/debug-log";
5
+ import {
6
+ advanceAccountField,
7
+ classifyAccountError,
8
+ deriveUsernameFromEmail,
9
+ isUsernameConflictError,
10
+ validateAccountEmail,
11
+ validateAccountPassword,
12
+ type AccountMode,
13
+ type AccountOutcome,
14
+ type AccountSub,
15
+ type AccountSubmitError,
16
+ } from "./account-step/model";
17
+
18
+ const onboardingLog = debugLog.createLogger("onboarding");
19
+
20
+ export interface OnboardingAccountState {
21
+ accountSub: AccountSub;
22
+ accountChoiceIdx: number;
23
+ accountEmail: string;
24
+ accountPassword: string;
25
+ accountFieldIdx: number;
26
+ accountSubmitting: boolean;
27
+ accountSubmitError: AccountSubmitError | null;
28
+ accountValidationError: string | null;
29
+ accountOutcome: AccountOutcome | null;
30
+ setAccountChoiceIdx: Dispatch<SetStateAction<number>>;
31
+ setAccountEmail: (value: string) => void;
32
+ setAccountPassword: (value: string) => void;
33
+ beginAccountMode: (mode: AccountMode) => void;
34
+ returnToAccountChooser: () => void;
35
+ switchToAccountLogin: () => void;
36
+ submitAccountField: () => void;
37
+ submitAccount: () => void;
38
+ syncExistingAccountSession: () => void;
39
+ }
40
+
41
+ /**
42
+ * Owns the account step's sub-state machine and the auth calls behind it.
43
+ * Skipping never routes through here, so a failed or hung request can't keep
44
+ * the user from finishing onboarding.
45
+ */
46
+ export function useOnboardingAccount({
47
+ nextStep,
48
+ setEditingField,
49
+ }: {
50
+ nextStep: () => void;
51
+ setEditingField: (editing: boolean) => void;
52
+ }): OnboardingAccountState {
53
+ const attemptRef = useRef(0);
54
+ // The text input re-emits its value on enter, so compare before clearing an
55
+ // error the same keypress just produced.
56
+ const emailRef = useRef("");
57
+ const passwordRef = useRef("");
58
+ const [accountSub, setAccountSub] = useState<AccountSub>("choose");
59
+ const [accountChoiceIdx, setAccountChoiceIdx] = useState(0);
60
+ const [accountEmail, setAccountEmailValue] = useState("");
61
+ const [accountPassword, setAccountPasswordValue] = useState("");
62
+ const [accountFieldIdx, setAccountFieldIdx] = useState(0);
63
+ const [accountSubmitting, setAccountSubmitting] = useState(false);
64
+ const [accountSubmitError, setAccountSubmitError] = useState<AccountSubmitError | null>(null);
65
+ const [accountValidationError, setAccountValidationError] = useState<string | null>(null);
66
+ const [accountOutcome, setAccountOutcome] = useState<AccountOutcome | null>(null);
67
+
68
+ const clearErrors = useCallback(() => {
69
+ setAccountSubmitError(null);
70
+ setAccountValidationError(null);
71
+ }, []);
72
+
73
+ const setAccountEmail = useCallback((value: string) => {
74
+ if (emailRef.current === value) return;
75
+ emailRef.current = value;
76
+ setAccountEmailValue(value);
77
+ clearErrors();
78
+ }, [clearErrors]);
79
+
80
+ const setAccountPassword = useCallback((value: string) => {
81
+ if (passwordRef.current === value) return;
82
+ passwordRef.current = value;
83
+ setAccountPasswordValue(value);
84
+ clearErrors();
85
+ }, [clearErrors]);
86
+
87
+ const resetAccountPassword = useCallback(() => {
88
+ passwordRef.current = "";
89
+ setAccountPasswordValue("");
90
+ }, []);
91
+
92
+ const beginAccountMode = useCallback((mode: AccountMode) => {
93
+ attemptRef.current += 1;
94
+ clearErrors();
95
+ setAccountSubmitting(false);
96
+ setAccountFieldIdx(0);
97
+ setAccountSub(mode);
98
+ setEditingField(true);
99
+ }, [clearErrors, setEditingField]);
100
+
101
+ const returnToAccountChooser = useCallback(() => {
102
+ attemptRef.current += 1;
103
+ clearErrors();
104
+ setAccountSubmitting(false);
105
+ setAccountFieldIdx(0);
106
+ setAccountSub("choose");
107
+ setEditingField(false);
108
+ }, [clearErrors, setEditingField]);
109
+
110
+ const switchToAccountLogin = useCallback(() => {
111
+ attemptRef.current += 1;
112
+ clearErrors();
113
+ setAccountSubmitting(false);
114
+ resetAccountPassword();
115
+ setAccountFieldIdx(1);
116
+ setAccountSub("login");
117
+ setEditingField(true);
118
+ }, [clearErrors, resetAccountPassword, setEditingField]);
119
+
120
+ const submitAccount = useCallback(() => {
121
+ if (accountSubmitting) return;
122
+ const mode: AccountMode = accountSub === "login" ? "login" : "signup";
123
+ const email = accountEmail.trim();
124
+ const password = accountPassword;
125
+
126
+ const emailError = validateAccountEmail(email);
127
+ if (emailError) {
128
+ setAccountFieldIdx(0);
129
+ setAccountValidationError(emailError);
130
+ setEditingField(true);
131
+ return;
132
+ }
133
+ const passwordError = validateAccountPassword(password, mode);
134
+ if (passwordError) {
135
+ setAccountFieldIdx(1);
136
+ setAccountValidationError(passwordError);
137
+ setEditingField(true);
138
+ return;
139
+ }
140
+
141
+ const attemptId = attemptRef.current + 1;
142
+ attemptRef.current = attemptId;
143
+ setEditingField(false);
144
+ setAccountSubmitting(true);
145
+ clearErrors();
146
+
147
+ void (async () => {
148
+ try {
149
+ if (mode === "signup") {
150
+ try {
151
+ const username = deriveUsernameFromEmail(email);
152
+ await apiClient.signUp(email, username, username, password);
153
+ } catch (error) {
154
+ if (!isUsernameConflictError(error)) throw error;
155
+ const retryUsername = deriveUsernameFromEmail(email, 1);
156
+ await apiClient.signUp(email, retryUsername, retryUsername, password);
157
+ }
158
+ await apiClient.sendVerification().catch(() => {});
159
+ } else {
160
+ await apiClient.signIn(email, password);
161
+ }
162
+
163
+ // Hands the freshly captured session cookie to the chat controller so it
164
+ // reaches plugin persistence instead of living only in this process.
165
+ chatController.clearSession();
166
+ await chatController.refreshSession().catch(() => {});
167
+
168
+ if (attemptRef.current !== attemptId) return;
169
+ onboardingLog.info("Onboarding account step completed", { mode });
170
+ setAccountSubmitting(false);
171
+ resetAccountPassword();
172
+ setAccountOutcome({ mode, email });
173
+ setAccountSub("signed-in");
174
+ nextStep();
175
+ } catch (error) {
176
+ if (attemptRef.current !== attemptId) return;
177
+ const submitError = classifyAccountError(error, mode);
178
+ onboardingLog.error("Onboarding account step failed", { mode, error: submitError.message });
179
+ setAccountSubmitting(false);
180
+ setAccountSubmitError(submitError);
181
+ }
182
+ })();
183
+ }, [accountEmail, accountPassword, accountSub, accountSubmitting, clearErrors, nextStep, resetAccountPassword, setEditingField]);
184
+
185
+ const submitAccountField = useCallback(() => {
186
+ if (accountSub !== "signup" && accountSub !== "login") return;
187
+ const advance = advanceAccountField({
188
+ mode: accountSub,
189
+ email: accountEmail.trim(),
190
+ password: accountPassword,
191
+ fieldIdx: accountFieldIdx,
192
+ });
193
+
194
+ if (advance.action === "invalid") {
195
+ setAccountValidationError(advance.message);
196
+ setEditingField(true);
197
+ return;
198
+ }
199
+ if (advance.action === "next-field") {
200
+ setAccountValidationError(null);
201
+ setAccountFieldIdx(advance.fieldIdx);
202
+ setEditingField(true);
203
+ return;
204
+ }
205
+ submitAccount();
206
+ }, [accountEmail, accountFieldIdx, accountPassword, accountSub, setEditingField, submitAccount]);
207
+
208
+ const syncExistingAccountSession = useCallback(() => {
209
+ if (accountOutcome || accountSub !== "choose" || !apiClient.getSessionToken()) return;
210
+ const user = apiClient.getCurrentUser();
211
+ setAccountOutcome({ mode: "login", email: user?.email?.trim() || user?.username?.trim() || "" });
212
+ setAccountSub("signed-in");
213
+ }, [accountOutcome, accountSub]);
214
+
215
+ return {
216
+ accountSub,
217
+ accountChoiceIdx,
218
+ accountEmail,
219
+ accountPassword,
220
+ accountFieldIdx,
221
+ accountSubmitting,
222
+ accountSubmitError,
223
+ accountValidationError,
224
+ accountOutcome,
225
+ setAccountChoiceIdx,
226
+ setAccountEmail,
227
+ setAccountPassword,
228
+ beginAccountMode,
229
+ returnToAccountChooser,
230
+ switchToAccountLogin,
231
+ submitAccountField,
232
+ submitAccount,
233
+ syncExistingAccountSession,
234
+ };
235
+ }
@@ -4,6 +4,12 @@ import { isBackNavigationKey, isPlainEscape } from "../../utils/back-navigation"
4
4
  import { isPlainKey } from "../../utils/keyboard";
5
5
  import type { ListViewItem } from "../ui";
6
6
  import { useShortcut } from "../../react/input";
7
+ import {
8
+ ACCOUNT_CHOICE_IDS,
9
+ type AccountMode,
10
+ type AccountSub,
11
+ type AccountSubmitError,
12
+ } from "./account-step/model";
7
13
  import type { PortfolioSub } from "./onboarding-steps";
8
14
  import type { BrokerOption, OnboardingStep } from "./wizard-model";
9
15
 
@@ -29,6 +35,16 @@ export function useOnboardingKeyboard({
29
35
  setEditingField,
30
36
  isBrokerSyncing,
31
37
  brokerSyncError,
38
+ accountSub,
39
+ accountChoiceIdx,
40
+ setAccountChoiceIdx,
41
+ accountSubmitting,
42
+ accountSubmitError,
43
+ beginAccountMode,
44
+ returnToAccountChooser,
45
+ switchToAccountLogin,
46
+ submitAccountField,
47
+ submitAccount,
32
48
  isFinishing,
33
49
  nextStep,
34
50
  prevStep,
@@ -58,6 +74,16 @@ export function useOnboardingKeyboard({
58
74
  setEditingField: Dispatch<SetStateAction<boolean>>;
59
75
  isBrokerSyncing: boolean;
60
76
  brokerSyncError: string | null;
77
+ accountSub: AccountSub;
78
+ accountChoiceIdx: number;
79
+ setAccountChoiceIdx: Dispatch<SetStateAction<number>>;
80
+ accountSubmitting: boolean;
81
+ accountSubmitError: AccountSubmitError | null;
82
+ beginAccountMode: (mode: AccountMode) => void;
83
+ returnToAccountChooser: () => void;
84
+ switchToAccountLogin: () => void;
85
+ submitAccountField: () => void;
86
+ submitAccount: () => void;
61
87
  isFinishing: boolean;
62
88
  nextStep: () => void;
63
89
  prevStep: () => void;
@@ -74,6 +100,10 @@ export function useOnboardingKeyboard({
74
100
  if (editingField) {
75
101
  if (event.name === "enter" || event.name === "return") {
76
102
  setEditingField(false);
103
+ if (step === "account") {
104
+ submitAccountField();
105
+ return;
106
+ }
77
107
  if (portfolioSub === "broker-fields" && selectedBrokerId) {
78
108
  const currentField = activeBrokerFields[brokerFieldIdx];
79
109
  if (!currentField) {
@@ -102,7 +132,9 @@ export function useOnboardingKeyboard({
102
132
  }
103
133
  } else if (isPlainEscape(event)) {
104
134
  setEditingField(false);
105
- if (portfolioSub === "broker-fields") {
135
+ if (step === "account") {
136
+ returnToAccountChooser();
137
+ } else if (portfolioSub === "broker-fields") {
106
138
  setPortfolioSub("broker-setup");
107
139
  } else {
108
140
  setPortfolioSub("choose");
@@ -117,6 +149,30 @@ export function useOnboardingKeyboard({
117
149
  finish();
118
150
  return;
119
151
  }
152
+ if (step === "account") {
153
+ if (accountSubmitting) return;
154
+ if (accountSub === "choose") {
155
+ const choice = ACCOUNT_CHOICE_IDS[accountChoiceIdx];
156
+ if (!choice || choice === "skip") {
157
+ nextStep();
158
+ return;
159
+ }
160
+ beginAccountMode(choice);
161
+ return;
162
+ }
163
+ if (accountSub === "signup" || accountSub === "login") {
164
+ if (accountSubmitError?.kind === "switch-to-login") {
165
+ switchToAccountLogin();
166
+ } else if (accountSubmitError) {
167
+ submitAccount();
168
+ } else {
169
+ setEditingField(true);
170
+ }
171
+ return;
172
+ }
173
+ nextStep();
174
+ return;
175
+ }
120
176
  if (step === "portfolio") {
121
177
  if (portfolioSub === "choose") {
122
178
  const choice = portfolioChoices[portfolioOptionIdx]!;
@@ -189,6 +245,10 @@ export function useOnboardingKeyboard({
189
245
  }
190
246
  nextStep();
191
247
  } else if (isBackNavigationKey(event) || event.name === "left") {
248
+ if (step === "account" && (accountSub === "signup" || accountSub === "login")) {
249
+ returnToAccountChooser();
250
+ return;
251
+ }
192
252
  if (step === "portfolio" && portfolioSub === "broker-sync") {
193
253
  resetBrokerSync();
194
254
  setPortfolioSub("broker-fields");
@@ -219,6 +279,12 @@ export function useOnboardingKeyboard({
219
279
  } else if (isPlainKey(event, "down", "j")) {
220
280
  setThemeIdx((index) => Math.min(themeIds.length - 1, index + 1));
221
281
  }
282
+ } else if (step === "account" && accountSub === "choose") {
283
+ if (isPlainKey(event, "up", "k")) {
284
+ setAccountChoiceIdx((index) => Math.max(0, index - 1));
285
+ } else if (isPlainKey(event, "down", "j")) {
286
+ setAccountChoiceIdx((index) => Math.min(ACCOUNT_CHOICE_IDS.length - 1, index + 1));
287
+ }
222
288
  } else if (step === "portfolio" && portfolioSub === "choose") {
223
289
  if (isPlainKey(event, "up", "k")) {
224
290
  setPortfolioOptionIdx((index) => Math.max(0, index - 1));
@@ -1,8 +1,8 @@
1
1
  import type { BrokerAdapter } from "../../types/broker";
2
2
 
3
- export type OnboardingStep = "welcome" | "theme" | "portfolio" | "shortcuts" | "ready";
3
+ export type OnboardingStep = "welcome" | "theme" | "portfolio" | "shortcuts" | "account" | "ready";
4
4
 
5
- export const ONBOARDING_STEPS: OnboardingStep[] = ["welcome", "theme", "portfolio", "shortcuts", "ready"];
5
+ export const ONBOARDING_STEPS: OnboardingStep[] = ["welcome", "theme", "portfolio", "shortcuts", "account", "ready"];
6
6
 
7
7
  export interface BrokerOption {
8
8
  id: string;
@@ -13,6 +13,7 @@ export type {
13
13
  DataTableColumn,
14
14
  DataTableProps,
15
15
  DataTableSectionHeader,
16
+ DataTableVisibleRange,
16
17
  } from "./types";
17
18
 
18
19
  export function DataTable<T, C extends DataTableColumn = DataTableColumn>(
@@ -19,7 +19,9 @@ import {
19
19
  import type {
20
20
  DataTableColumn,
21
21
  DataTableProps,
22
+ DataTableVisibleRange,
22
23
  } from "../types";
24
+ import { resolveDataTableVisibleRange } from "../visible-range";
23
25
  import {
24
26
  resolveDataTableScrollTop,
25
27
  resolveDataTableVisibleWindow,
@@ -47,6 +49,8 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
47
49
  onSelect,
48
50
  onActivate,
49
51
  onTableMouseDown,
52
+ visibleRangeKey,
53
+ onVisibleRangeChange,
50
54
  onRowMouseDown,
51
55
  onRowContextMenu,
52
56
  rowContextMenuSurface = false,
@@ -74,6 +78,10 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
74
78
  const nativeRenderer = useNativeRenderer();
75
79
  const [scrollVersion, setScrollVersion] = useState(0);
76
80
  const lastAppliedScrollRequestRef = useRef<string | null>(null);
81
+ const lastVisibleRangeRef = useRef<{
82
+ key: string | number | undefined;
83
+ range: DataTableVisibleRange;
84
+ } | null>(null);
77
85
  const scrollTop = virtualize ? (scrollRef.current?.scrollTop ?? 0) : 0;
78
86
  const measuredViewportHeight = scrollRef.current?.viewport?.height;
79
87
  const tableWindow = useMemo(
@@ -123,6 +131,25 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
123
131
  () => expandTableColumns(columns, contentWidth, columnGap, horizontalPadding),
124
132
  [columnGap, columns, contentWidth, horizontalPadding],
125
133
  );
134
+ const emitVisibleRange = useCallback(() => {
135
+ if (!onVisibleRangeChange) return;
136
+ const scrollBox = scrollRef.current;
137
+ const range = resolveDataTableVisibleRange({
138
+ itemCount: items.length,
139
+ rowSize: 1,
140
+ scrollOffset: scrollBox?.scrollTop ?? scrollTop,
141
+ viewportSize: scrollBox?.viewport?.height ?? viewportHeight,
142
+ });
143
+ const previous = lastVisibleRangeRef.current;
144
+ if (
145
+ previous !== null
146
+ && previous.key === visibleRangeKey
147
+ && previous.range.start === range.start
148
+ && previous.range.end === range.end
149
+ ) return;
150
+ lastVisibleRangeRef.current = { key: visibleRangeKey, range };
151
+ onVisibleRangeChange(range);
152
+ }, [items.length, onVisibleRangeChange, scrollRef, scrollTop, viewportHeight, visibleRangeKey]);
126
153
  const handleRowMouseDown =
127
154
  useDoubleClickActivation<DataTableRowPointerTarget<T>>({
128
155
  onSelect: ({ item, index }) => {
@@ -140,13 +167,26 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
140
167
  setScrollVersion((current) => current + 1);
141
168
  }
142
169
  onBodyScrollActivity();
170
+ emitVisibleRange();
143
171
  nativeRenderer.requestRender();
144
- }, [nativeRenderer, onBodyScrollActivity, virtualize]);
172
+ }, [emitVisibleRange, nativeRenderer, onBodyScrollActivity, virtualize]);
145
173
  useScrollBoxScrollActivity({
146
174
  scrollRef,
147
175
  onVerticalScroll: handleBodyScrollActivity,
148
176
  onHorizontalScroll: syncHeaderScroll,
149
177
  });
178
+ const handleBodySizeChange = useCallback(() => {
179
+ measureContentWidth();
180
+ if (virtualize) {
181
+ setScrollVersion((current) => current + 1);
182
+ }
183
+ queueMicrotask(emitVisibleRange);
184
+ }, [emitVisibleRange, measureContentWidth, virtualize]);
185
+
186
+ useEffect(() => {
187
+ emitVisibleRange();
188
+ queueMicrotask(emitVisibleRange);
189
+ }, [emitVisibleRange]);
150
190
  const focusPane = useCallback(() => {
151
191
  if (!paneInstanceId) return;
152
192
  dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
@@ -178,6 +218,7 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
178
218
  setScrollVersion((current) => current + 1);
179
219
  }
180
220
  syncHeaderScroll();
221
+ queueMicrotask(emitVisibleRange);
181
222
  return true;
182
223
  }, [
183
224
  appViewport.height,
@@ -185,6 +226,7 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
185
226
  scrollRef,
186
227
  scrollToIndex,
187
228
  scrollToIndexAlign,
229
+ emitVisibleRange,
188
230
  syncHeaderScroll,
189
231
  virtualize,
190
232
  ]);
@@ -324,7 +366,7 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
324
366
  focusPane();
325
367
  onTableMouseDown?.({});
326
368
  }}
327
- onSizeChange={measureContentWidth}
369
+ onSizeChange={handleBodySizeChange}
328
370
  >
329
371
  {items.length === 0 ? (
330
372
  emptyContent ?? (
@@ -33,6 +33,13 @@ export interface DataTableRowState {
33
33
  selected: boolean;
34
34
  }
35
35
 
36
+ export interface DataTableVisibleRange {
37
+ /** First visible row index. */
38
+ start: number;
39
+ /** Exclusive end of the visible row range. */
40
+ end: number;
41
+ }
42
+
36
43
  export interface DataTableProps<
37
44
  T,
38
45
  C extends DataTableColumn = DataTableColumn,
@@ -53,6 +60,9 @@ export interface DataTableProps<
53
60
  onSelect: (item: T, index: number) => void;
54
61
  onActivate?: (item: T, index: number) => void;
55
62
  onTableMouseDown?: (event: any) => void;
63
+ /** Changes when the meaning of row indexes changes without changing table geometry. */
64
+ visibleRangeKey?: string | number;
65
+ onVisibleRangeChange?: (range: DataTableVisibleRange) => void;
56
66
  onRowMouseDown?: (item: T, index: number, event: any) => boolean | void;
57
67
  onRowContextMenu?: (item: T, index: number, event: any) => void;
58
68
  rowContextMenuSurface?: boolean;
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveDataTableVisibleRange } from "./visible-range";
3
+
4
+ describe("data table visible range", () => {
5
+ test("includes partially visible desktop rows without counting the sticky header", () => {
6
+ expect(resolveDataTableVisibleRange({
7
+ itemCount: 100,
8
+ rowSize: 20,
9
+ scrollOffset: 10,
10
+ viewportSize: 180,
11
+ })).toEqual({ start: 0, end: 10 });
12
+
13
+ expect(resolveDataTableVisibleRange({
14
+ itemCount: 100,
15
+ rowSize: 20,
16
+ scrollOffset: 30,
17
+ viewportSize: 180,
18
+ })).toEqual({ start: 1, end: 11 });
19
+ });
20
+
21
+ test("clamps a terminal range to the available rows", () => {
22
+ expect(resolveDataTableVisibleRange({
23
+ itemCount: 8,
24
+ rowSize: 1,
25
+ scrollOffset: 6,
26
+ viewportSize: 5,
27
+ })).toEqual({ start: 6, end: 8 });
28
+ });
29
+ });
@@ -0,0 +1,21 @@
1
+ import type { DataTableVisibleRange } from "./types";
2
+
3
+ export function resolveDataTableVisibleRange({
4
+ itemCount,
5
+ rowSize,
6
+ scrollOffset,
7
+ viewportSize,
8
+ }: {
9
+ itemCount: number;
10
+ rowSize: number;
11
+ scrollOffset: number;
12
+ viewportSize: number;
13
+ }): DataTableVisibleRange {
14
+ const count = Math.max(0, Math.floor(itemCount));
15
+ const size = Number.isFinite(rowSize) && rowSize > 0 ? rowSize : 1;
16
+ const offset = Number.isFinite(scrollOffset) ? Math.max(0, scrollOffset) : 0;
17
+ const viewport = Number.isFinite(viewportSize) ? Math.max(0, viewportSize) : 0;
18
+ const start = Math.min(count, Math.floor(offset / size));
19
+ const end = Math.min(count, Math.max(start, Math.ceil((offset + viewport) / size)));
20
+ return { start, end };
21
+ }
@@ -3,7 +3,7 @@ import { act, createRef, useEffect, useRef, useState } from "react";
3
3
  import { TestDialogProvider, testRender } from "../../renderers/opentui/test-utils";
4
4
  import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
5
5
  import { ChoiceDialog } from "./choice-dialog";
6
- import { DataTable, type DataTableColumn } from "./data-table";
6
+ import { DataTable, type DataTableColumn, type DataTableVisibleRange } from "./data-table";
7
7
  import { TextField } from "./fields";
8
8
  import { ListView } from "./list-view";
9
9
  import { MultiSelectDialogButton, type MultiSelectDialogButtonHandle } from "./multi-select/dialog";
@@ -26,6 +26,8 @@ let setListSelection: ((index: number) => void) | null = null;
26
26
  let selectedTableRow: string | null = null;
27
27
  let activatedTableRow: string | null = null;
28
28
  let tableScrollBoxForTest: ScrollBoxRenderable | null = null;
29
+ let tableVisibleRanges: DataTableVisibleRange[] = [];
30
+ let setTableVisibleRangeKey: ((key: string) => void) | null = null;
29
31
  let closedTab: string | null = null;
30
32
  let addedTab = false;
31
33
  let resolvedChoice: string | null = null;
@@ -176,6 +178,7 @@ function DataTableHorizontalScrollHarness({
176
178
  function DataTableVirtualizationHarness() {
177
179
  const headerScrollRef = useRef<ScrollBoxRenderable>(null);
178
180
  const scrollRef = useRef<ScrollBoxRenderable>(null);
181
+ const [visibleRangeKey, setVisibleRangeKey] = useState("first");
179
182
  const rows = Array.from({ length: 100 }, (_, index) => ({
180
183
  id: `row-${index}`,
181
184
  name: `Row ${index}`,
@@ -183,6 +186,7 @@ function DataTableVirtualizationHarness() {
183
186
 
184
187
  useEffect(() => {
185
188
  tableScrollBoxForTest = scrollRef.current;
189
+ setTableVisibleRangeKey = setVisibleRangeKey;
186
190
  return () => {
187
191
  if (tableScrollBoxForTest === scrollRef.current) {
188
192
  tableScrollBoxForTest = null;
@@ -201,6 +205,8 @@ function DataTableVirtualizationHarness() {
201
205
  scrollRef={scrollRef}
202
206
  syncHeaderScroll={() => {}}
203
207
  onBodyScrollActivity={() => {}}
208
+ visibleRangeKey={visibleRangeKey}
209
+ onVisibleRangeChange={(range) => tableVisibleRanges.push(range)}
204
210
  getItemKey={(row) => row.id}
205
211
  isSelected={() => false}
206
212
  onSelect={() => {}}
@@ -287,6 +293,8 @@ afterEach(async () => {
287
293
  selectedTableRow = null;
288
294
  activatedTableRow = null;
289
295
  tableScrollBoxForTest = null;
296
+ tableVisibleRanges = [];
297
+ setTableVisibleRangeKey = null;
290
298
  closedTab = null;
291
299
  addedTab = false;
292
300
  resolvedChoice = null;
@@ -906,6 +914,16 @@ describe("shared UI kit", () => {
906
914
  expect(frame).toContain("Row 0");
907
915
  expect(frame).not.toContain("Row 99");
908
916
  expect(tableScrollBoxForTest?.scrollTop).toBe(0);
917
+ expect(tableVisibleRanges.at(-1)).toEqual({ start: 0, end: 5 });
918
+
919
+ const initialRangeCount = tableVisibleRanges.length;
920
+ await act(async () => {
921
+ setTableVisibleRangeKey!("second");
922
+ await Promise.resolve();
923
+ await testSetup!.renderOnce();
924
+ });
925
+ expect(tableVisibleRanges.length).toBeGreaterThan(initialRangeCount);
926
+ expect(tableVisibleRanges.at(-1)).toEqual({ start: 0, end: 5 });
909
927
 
910
928
  await act(async () => {
911
929
  for (let index = 0; index < 12; index++) {
@@ -919,6 +937,10 @@ describe("shared UI kit", () => {
919
937
 
920
938
  const scrollTop = tableScrollBoxForTest?.scrollTop ?? 0;
921
939
  expect(scrollTop).toBeGreaterThan(0);
940
+ expect(tableVisibleRanges.at(-1)).toEqual({
941
+ start: scrollTop,
942
+ end: Math.min(100, scrollTop + 5),
943
+ });
922
944
 
923
945
  const scrolledFrame = testSetup.captureCharFrame();
924
946
  expect(scrolledFrame).toContain(`Row ${scrollTop}`);