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
@@ -1,9 +1,13 @@
1
+ import { useMemo } from "react";
1
2
  import { AsciiText, Box, Span, Strong, Text, TextAttributes, useUiHost } from "../../ui";
2
3
  import { colors } from "../../theme/colors";
3
4
  import { t, tf } from "../../i18n";
4
5
  import { themes } from "../../theme/themes";
5
6
  import { detectShortcutPlatform, formatPrimaryShortcut, getShortcutDisplayMode } from "../../utils/shortcut-labels";
7
+ import { ListView, type ListViewItem } from "../ui";
8
+ import type { AccountOutcome } from "./account-step/model";
6
9
  export { PortfolioStep, type PortfolioSub } from "./portfolio-step";
10
+ export { AccountStep } from "./account-step";
7
11
 
8
12
  export interface BrokerSyncSummary {
9
13
  portfolioId: string | null;
@@ -30,12 +34,24 @@ export function WelcomeStep() {
30
34
  );
31
35
  }
32
36
 
33
- export function ThemeStep({ themeIds, selectedIdx, height }: { themeIds: string[]; selectedIdx: number; height: number }) {
34
- const maxVisible = Math.min(themeIds.length, Math.max(6, height - 12));
35
- const halfWindow = Math.floor(maxVisible / 2);
36
- let windowStart = Math.max(0, Math.min(selectedIdx - halfWindow, themeIds.length - maxVisible));
37
- if (windowStart < 0) windowStart = 0;
38
- const windowEnd = Math.min(themeIds.length, windowStart + maxVisible);
37
+ // Rows the theme step spends on everything that is not the theme list itself.
38
+ const THEME_CHROME_ROWS = 8;
39
+
40
+ export function ThemeStep({
41
+ themeIds,
42
+ selectedIdx,
43
+ onSelect,
44
+ height,
45
+ }: {
46
+ themeIds: string[];
47
+ selectedIdx: number;
48
+ onSelect: (index: number) => void;
49
+ height: number;
50
+ }) {
51
+ const items = useMemo<ListViewItem[]>(
52
+ () => themeIds.map((id) => ({ id, label: themes[id]!.name })),
53
+ [themeIds],
54
+ );
39
55
 
40
56
  return (
41
57
  <Box flexDirection="column" paddingX={2}>
@@ -48,7 +64,7 @@ export function ThemeStep({ themeIds, selectedIdx, height }: { themeIds: string[
48
64
  <Text fg={colors.text} attributes={TextAttributes.BOLD}>{"TH"}</Text>
49
65
  </Box>
50
66
  <Box height={1} />
51
- <Box height={1}>
67
+ <Box height={1} flexDirection="row">
52
68
  <Text fg={colors.positive}>{" \u2588\u2588 "}</Text>
53
69
  <Text fg={colors.negative}>{" \u2588\u2588 "}</Text>
54
70
  <Text fg={colors.text}>{" \u2588\u2588 "}</Text>
@@ -58,33 +74,13 @@ export function ThemeStep({ themeIds, selectedIdx, height }: { themeIds: string[
58
74
  </Box>
59
75
  <Box height={1} />
60
76
 
61
- {windowStart > 0 && (
62
- <Box height={1}>
63
- <Text fg={colors.textMuted}>{"\u2191 more"}</Text>
64
- </Box>
65
- )}
66
-
67
- {themeIds.slice(windowStart, windowEnd).map((id, index) => {
68
- const theme = themes[id]!;
69
- const globalIdx = windowStart + index;
70
- const isSelected = globalIdx === selectedIdx;
71
- return (
72
- <Box key={id} height={1} backgroundColor={isSelected ? colors.selected : colors.bg}>
73
- <Text fg={isSelected ? colors.selectedText : colors.textDim}>
74
- {isSelected ? "\u25b8 " : " "}
75
- </Text>
76
- <Text fg={isSelected ? colors.text : colors.textDim} attributes={isSelected ? TextAttributes.BOLD : 0}>
77
- {theme.name}
78
- </Text>
79
- </Box>
80
- );
81
- })}
82
-
83
- {windowEnd < themeIds.length && (
84
- <Box height={1}>
85
- <Text fg={colors.textMuted}>{"\u2193 more"}</Text>
86
- </Box>
87
- )}
77
+ <ListView
78
+ items={items}
79
+ selectedIndex={selectedIdx}
80
+ onSelect={onSelect}
81
+ scrollable
82
+ height={Math.max(3, height - THEME_CHROME_ROWS)}
83
+ />
88
84
 
89
85
  <Box height={1} />
90
86
  <Box height={1}>
@@ -94,7 +90,7 @@ export function ThemeStep({ themeIds, selectedIdx, height }: { themeIds: string[
94
90
  );
95
91
  }
96
92
 
97
- export function ShortcutsStep() {
93
+ export function ShortcutsStep({ height }: { height: number }) {
98
94
  const uiHost = useUiHost();
99
95
  const shortcutPlatform = detectShortcutPlatform();
100
96
  const shortcutDisplayMode = getShortcutDisplayMode(uiHost.kind);
@@ -117,6 +113,11 @@ export function ShortcutsStep() {
117
113
 
118
114
  const COL = 20;
119
115
 
116
+ // Shed the least useful rows first so the wizard footer stays on screen in an 80x24 terminal.
117
+ const gap = height < 21 ? 1 : 2;
118
+ const showTagline = height >= 19;
119
+ const showPrefixHint = height >= 17;
120
+
120
121
  return (
121
122
  <Box flexDirection="column" paddingX={2}>
122
123
  <Box height={1}>
@@ -131,15 +132,19 @@ export function ShortcutsStep() {
131
132
  </Box>
132
133
  ))}
133
134
 
134
- <Box height={2} />
135
+ <Box height={gap} />
135
136
  <Box height={1}>
136
137
  <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{t("Basic command prefixes")}</Text>
137
138
  </Box>
138
139
  <Box height={1} />
139
- <Box height={1}>
140
- <Text fg={colors.textDim}>{t("Type these in the command bar (")}<Span fg={colors.text}><Strong>{"Ctrl+P"}</Strong></Span>{"):"}</Text>
141
- </Box>
142
- <Box height={1} />
140
+ {showPrefixHint && (
141
+ <>
142
+ <Box height={1}>
143
+ <Text fg={colors.textDim}>{t("Type these in the command bar (")}<Span fg={colors.text}><Strong>{"Ctrl+P"}</Strong></Span>{"):"}</Text>
144
+ </Box>
145
+ <Box height={1} />
146
+ </>
147
+ )}
143
148
 
144
149
  {commandPrefixes.map((shortcut) => (
145
150
  <Box key={shortcut.key} height={1} flexDirection="row">
@@ -148,10 +153,14 @@ export function ShortcutsStep() {
148
153
  </Box>
149
154
  ))}
150
155
 
151
- <Box height={2} />
152
- <Box height={1}>
153
- <Text fg={colors.textDim}>{t("Everything is searchable, just type what you want.")}</Text>
154
- </Box>
156
+ {showTagline && (
157
+ <>
158
+ <Box height={gap} />
159
+ <Box height={1}>
160
+ <Text fg={colors.textDim}>{t("Everything is searchable, just type what you want.")}</Text>
161
+ </Box>
162
+ </>
163
+ )}
155
164
  </Box>
156
165
  );
157
166
  }
@@ -160,17 +169,20 @@ export function ReadyStep({
160
169
  brokerName,
161
170
  portfolioName,
162
171
  brokerSyncSummary,
172
+ accountOutcome,
163
173
  isFinishing,
164
174
  error,
165
175
  }: {
166
176
  brokerName: string | null;
167
177
  portfolioName: string;
168
178
  brokerSyncSummary: BrokerSyncSummary | null;
179
+ accountOutcome: AccountOutcome | null;
169
180
  isFinishing: boolean;
170
181
  error: string | null;
171
182
  }) {
172
183
  const positionsImported = brokerSyncSummary?.positionsImported ?? 0;
173
184
  const positionLabel = positionsImported === 1 ? "position" : "positions";
185
+ const accountEmail = accountOutcome?.email ?? "";
174
186
 
175
187
  return (
176
188
  <Box flexDirection="column" paddingX={2}>
@@ -179,23 +191,47 @@ export function ReadyStep({
179
191
  <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{t("You're all set")}</Text>
180
192
  </Box>
181
193
  <Box height={2} />
182
- <Box height={1}>
183
- <Text fg={colors.positive} attributes={TextAttributes.BOLD}>{"\u2713"}</Text>
184
- <Text fg={colors.text}>{` ${t("Theme configured")}`}</Text>
194
+ <Box height={1} flexDirection="row">
195
+ <Text fg={colors.positive} attributes={TextAttributes.BOLD}>{"\u2713 "}</Text>
196
+ <Text fg={colors.text}>{t("Theme configured")}</Text>
185
197
  </Box>
186
- <Box height={1}>
187
- <Text fg={colors.positive} attributes={TextAttributes.BOLD}>{"\u2713"}</Text>
198
+ <Box height={1} flexDirection="row">
199
+ <Text fg={colors.positive} attributes={TextAttributes.BOLD}>{"\u2713 "}</Text>
188
200
  <Text fg={colors.text}>
189
201
  {brokerName
190
- ? ` ${tf("{broker} connected. Imported {count} {label}", { broker: brokerName, count: positionsImported, label: t(positionLabel) })}`
191
- : ` ${tf('Portfolio "{name}" created', { name: portfolioName })}`}
202
+ ? tf("{broker} connected. Imported {count} {label}", { broker: brokerName, count: positionsImported, label: t(positionLabel) })
203
+ : tf('Portfolio "{name}" created', { name: portfolioName })}
192
204
  </Text>
193
205
  </Box>
194
- <Box height={1}>
195
- <Text fg={colors.positive} attributes={TextAttributes.BOLD}>{"\u2713"}</Text>
196
- <Text fg={colors.text}>{` ${t("Plugins enabled")}`}</Text>
206
+ <Box height={1} flexDirection="row">
207
+ <Text fg={colors.positive} attributes={TextAttributes.BOLD}>{"\u2713 "}</Text>
208
+ <Text fg={colors.text}>{t("Plugins enabled")}</Text>
197
209
  </Box>
198
- <Box height={2} />
210
+ {accountOutcome && (
211
+ <Box height={1} flexDirection="row">
212
+ <Text fg={colors.positive} attributes={TextAttributes.BOLD}>{"\u2713 "}</Text>
213
+ <Text fg={colors.text}>
214
+ {!accountEmail
215
+ ? t("Signed in to Gloom Cloud")
216
+ : accountOutcome.mode === "signup"
217
+ ? tf("Account created for {email}", { email: accountEmail })
218
+ : tf("Signed in as {email}", { email: accountEmail })}
219
+ </Text>
220
+ </Box>
221
+ )}
222
+ <Box height={1} />
223
+ {accountOutcome?.mode === "signup" ? (
224
+ <Box height={2}>
225
+ <Text fg={colors.text}>
226
+ {t("Check your inbox to verify your email. Then start your free 7-day Pro trial anytime \u2014 type UPGRADE.")}
227
+ </Text>
228
+ </Box>
229
+ ) : accountOutcome ? null : (
230
+ <Box height={1}>
231
+ <Text fg={colors.textMuted}>{t("Create an account anytime: Ctrl+P \u2192 Sign Up.")}</Text>
232
+ </Box>
233
+ )}
234
+ <Box height={1} />
199
235
  {brokerName ? (
200
236
  <Box height={1}>
201
237
  <Text fg={isFinishing ? colors.text : colors.textDim}>
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "fs/promises";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { act } from "react";
6
+ import { setCloudApiFetchTransport } from "../../api-client";
6
7
  import { testRender } from "../../renderers/opentui/test-utils";
7
8
  import { OnboardingWizard } from "./onboarding-wizard";
8
9
  import { createDefaultConfig, type AppConfig } from "../../types/config";
@@ -80,6 +81,7 @@ async function waitForFrameToContain(text: string, attempts = 8): Promise<string
80
81
  }
81
82
 
82
83
  afterEach(async () => {
84
+ setCloudApiFetchTransport(null);
83
85
  if (testSetup) {
84
86
  await act(async () => {
85
87
  testSetup!.renderer.destroy();
@@ -223,12 +225,10 @@ describe("OnboardingWizard", () => {
223
225
  frame = testSetup.captureCharFrame();
224
226
  expect(frame).toContain("After launch shortcuts");
225
227
 
226
- for (let index = 0; index < 3 && !frame.includes("Imported 1 position"); index += 1) {
227
- await emitKeypress(testSetup, { name: "return", sequence: "\r" });
228
- frame = testSetup.captureCharFrame();
229
- }
230
-
231
- expect(frame).toContain("Imported 1 position");
228
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
229
+ await waitForFrameToContain("Create your free Gloom Cloud account");
230
+ await emitKeypress(testSetup, { name: "right", sequence: "" });
231
+ frame = await waitForFrameToContain("Imported 1 position");
232
232
  expect(completedConfig).toBeNull();
233
233
 
234
234
  await emitKeypress(testSetup, { name: "return", sequence: "\r" });
@@ -252,4 +252,71 @@ describe("OnboardingWizard", () => {
252
252
  .toBe("broker:demo-demo-broker:ACC-1");
253
253
  expect((await tickerRepository.loadAllTickers()).map((ticker) => ticker.metadata.ticker)).toContain("AAPL");
254
254
  });
255
+
256
+ test("account step validates locally and stays skippable all the way to launch", async () => {
257
+ tempDataDir = await mkdtemp(join(tmpdir(), "gloomberb-onboarding-account-"));
258
+ const requestedUrls: string[] = [];
259
+ setCloudApiFetchTransport(async (url) => {
260
+ requestedUrls.push(url);
261
+ throw new Error("network is disabled in tests");
262
+ });
263
+
264
+ const pluginRegistry = {
265
+ allPlugins: new Map(),
266
+ brokers: new Map(),
267
+ paneTemplates: new Map(),
268
+ getPaneTemplatePluginId: () => undefined,
269
+ tickerRepository: createTickerRepository(),
270
+ persistence: { resources: undefined },
271
+ } as unknown as PluginRegistry;
272
+
273
+ let completedConfig: AppConfig | null = null;
274
+ testSetup = await testRender(
275
+ <OnboardingWizard
276
+ config={createDefaultConfig(tempDataDir)}
277
+ pluginRegistry={pluginRegistry}
278
+ onComplete={(nextConfig) => {
279
+ completedConfig = nextConfig;
280
+ }}
281
+ />,
282
+ { width: 90, height: 28 },
283
+ );
284
+ await testSetup.renderOnce();
285
+
286
+ // welcome -> theme -> portfolio -> shortcuts -> account
287
+ for (let index = 0; index < 4; index += 1) {
288
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
289
+ }
290
+ let frame = await waitForFrameToContain("Create your free Gloom Cloud account");
291
+ expect(frame).toContain("Try Pro free for 7 days");
292
+
293
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
294
+ await waitForFrameToContain("Create your free account");
295
+
296
+ // Submitting an empty form must be caught locally instead of hitting the API.
297
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
298
+ frame = await waitForFrameToContain("Enter your email address.");
299
+ expect(requestedUrls).toEqual([]);
300
+
301
+ await emitKeypress(testSetup, { name: "escape", sequence: "\u001b" });
302
+ await waitForFrameToContain("Skip for now");
303
+
304
+ await emitKeypress(testSetup, { name: "down", sequence: "\u001b[B" });
305
+ await emitKeypress(testSetup, { name: "down", sequence: "\u001b[B" });
306
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
307
+ frame = await waitForFrameToContain("Create an account anytime");
308
+ expect(frame).not.toContain("Signed in as");
309
+
310
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
311
+ for (let index = 0; index < 20 && !completedConfig; index += 1) {
312
+ await act(async () => {
313
+ await Promise.resolve();
314
+ await new Promise((resolve) => setTimeout(resolve, 0));
315
+ await testSetup!.renderOnce();
316
+ });
317
+ }
318
+
319
+ expect(completedConfig).not.toBeNull();
320
+ expect(requestedUrls).toEqual([]);
321
+ });
255
322
  });
@@ -10,6 +10,7 @@ import type { PluginRegistry } from "../../plugins/registry";
10
10
  import { resolveBrokerConfigFields, type BrokerConfigField } from "../../types/broker";
11
11
  import type { ListViewItem } from "../ui";
12
12
  import {
13
+ AccountStep,
13
14
  PortfolioStep,
14
15
  ReadyStep,
15
16
  ShortcutsStep,
@@ -17,6 +18,8 @@ import {
17
18
  WelcomeStep,
18
19
  type PortfolioSub,
19
20
  } from "./onboarding-steps";
21
+ import { ACCOUNT_CHOICE_IDS } from "./account-step/model";
22
+ import { useOnboardingAccount } from "./wizard-account";
20
23
  import { finishOnboarding, summarizeOnboardingError, useOnboardingBrokerSync } from "./wizard-broker-sync";
21
24
  import { useOnboardingKeyboard } from "./wizard-keyboard";
22
25
  import {
@@ -26,6 +29,9 @@ import {
26
29
  type OnboardingStep,
27
30
  } from "./wizard-model";
28
31
 
32
+ // Blank spacer + progress dots + hint line.
33
+ const FOOTER_ROWS = 3;
34
+
29
35
  interface OnboardingWizardProps {
30
36
  config: AppConfig;
31
37
  pluginRegistry: PluginRegistry;
@@ -83,12 +89,6 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
83
89
  applyTheme(themeIds[themeIdx]!);
84
90
  }
85
91
 
86
- useEffect(() => {
87
- if (!editingField) return;
88
- const focusTimer = setTimeout(() => inputRef.current?.focus?.(), 10);
89
- return () => clearTimeout(focusTimer);
90
- }, [editingField, portfolioSub, brokerFieldIdx]);
91
-
92
92
  useEffect(() => {
93
93
  if (!selectedBrokerId) return;
94
94
  const field = activeBrokerFields[brokerFieldIdx];
@@ -119,6 +119,32 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
119
119
  }
120
120
  }, [step]);
121
121
 
122
+ const account = useOnboardingAccount({ nextStep, setEditingField });
123
+
124
+ // Re-focus whenever the active field changes so enter keeps moving through
125
+ // the broker and account forms without a mouse.
126
+ useEffect(() => {
127
+ if (!editingField) return;
128
+ const focusTimer = setTimeout(() => inputRef.current?.focus?.(), 10);
129
+ return () => clearTimeout(focusTimer);
130
+ }, [editingField, portfolioSub, brokerFieldIdx, account.accountSub, account.accountFieldIdx]);
131
+
132
+ const { beginAccountMode, syncExistingAccountSession } = account;
133
+ useEffect(() => {
134
+ if (step !== "account") return;
135
+ syncExistingAccountSession();
136
+ }, [step, syncExistingAccountSession]);
137
+
138
+ // Clicking a chooser row has to do exactly what enter does on it.
139
+ const activateAccountChoice = useCallback((index: number) => {
140
+ const choice = ACCOUNT_CHOICE_IDS[index];
141
+ if (!choice || choice === "skip") {
142
+ nextStep();
143
+ return;
144
+ }
145
+ beginAccountMode(choice);
146
+ }, [beginAccountMode, nextStep]);
147
+
122
148
  const {
123
149
  isBrokerSyncing,
124
150
  brokerSyncError,
@@ -202,6 +228,16 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
202
228
  setEditingField,
203
229
  isBrokerSyncing,
204
230
  brokerSyncError,
231
+ accountSub: account.accountSub,
232
+ accountChoiceIdx: account.accountChoiceIdx,
233
+ setAccountChoiceIdx: account.setAccountChoiceIdx,
234
+ accountSubmitting: account.accountSubmitting,
235
+ accountSubmitError: account.accountSubmitError,
236
+ beginAccountMode: account.beginAccountMode,
237
+ returnToAccountChooser: account.returnToAccountChooser,
238
+ switchToAccountLogin: account.switchToAccountLogin,
239
+ submitAccountField: account.submitAccountField,
240
+ submitAccount: account.submitAccount,
205
241
  isFinishing,
206
242
  nextStep,
207
243
  prevStep,
@@ -213,7 +249,9 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
213
249
 
214
250
  const contentWidth = Math.min(60, termWidth - 4);
215
251
  const contentLeft = Math.floor((termWidth - contentWidth) / 2);
216
- const contentTop = Math.max(1, Math.floor((termHeight - 24) / 2));
252
+ // The footer is pinned to the last FOOTER_ROWS rows, so steps get whatever is left.
253
+ const contentTop = Math.max(0, Math.min(Math.floor((termHeight - 24) / 2), termHeight - FOOTER_ROWS - 1));
254
+ const contentHeight = Math.max(1, termHeight - contentTop - FOOTER_ROWS);
217
255
  const progressDots = ONBOARDING_STEPS.map((_, index) => {
218
256
  if (index < stepIdx) return "\u2501";
219
257
  if (index === stepIdx) return "\u25cf";
@@ -227,6 +265,15 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
227
265
  if (step === "ready") {
228
266
  hintText = t("enter to launch");
229
267
  }
268
+ if (step === "account") {
269
+ if (account.accountSubmitting) {
270
+ hintText = account.accountSub === "login" ? t("signing in...") : t("creating account...");
271
+ } else if (account.accountSubmitError?.kind === "switch-to-login") {
272
+ hintText = t("enter to log in");
273
+ } else if (account.accountSubmitError) {
274
+ hintText = t("enter to retry");
275
+ }
276
+ }
230
277
  if (step === "portfolio" && portfolioSub === "broker-sync") {
231
278
  hintText = isBrokerSyncing ? "syncing broker..." : "enter to retry";
232
279
  } else if (isFinishing) {
@@ -248,10 +295,18 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
248
295
  top={contentTop}
249
296
  left={contentLeft}
250
297
  width={contentWidth}
298
+ height={contentHeight}
251
299
  flexDirection="column"
252
300
  >
253
301
  {step === "welcome" && <WelcomeStep />}
254
- {step === "theme" && <ThemeStep themeIds={themeIds} selectedIdx={themeIdx} height={termHeight - contentTop - 4} />}
302
+ {step === "theme" && (
303
+ <ThemeStep
304
+ themeIds={themeIds}
305
+ selectedIdx={themeIdx}
306
+ onSelect={setThemeIdx}
307
+ height={contentHeight}
308
+ />
309
+ )}
255
310
  {step === "portfolio" && (
256
311
  <PortfolioStep
257
312
  sub={portfolioSub}
@@ -271,18 +326,47 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
271
326
  />
272
327
  )}
273
328
  {step === "shortcuts" && (
274
- <ShortcutsStep />
329
+ <ShortcutsStep height={contentHeight} />
330
+ )}
331
+ {step === "account" && (
332
+ <AccountStep
333
+ sub={account.accountSub}
334
+ choiceIdx={account.accountChoiceIdx}
335
+ onChoiceSelect={account.setAccountChoiceIdx}
336
+ onChoiceActivate={activateAccountChoice}
337
+ email={account.accountEmail}
338
+ password={account.accountPassword}
339
+ fieldIdx={account.accountFieldIdx}
340
+ editing={editingField}
341
+ inputRef={inputRef}
342
+ submitting={account.accountSubmitting}
343
+ submitError={account.accountSubmitError}
344
+ validationError={account.accountValidationError}
345
+ outcome={account.accountOutcome}
346
+ onEmailChange={account.setAccountEmail}
347
+ onPasswordChange={account.setAccountPassword}
348
+ height={contentHeight}
349
+ />
275
350
  )}
276
351
  {step === "ready" && (
277
352
  <ReadyStep
278
353
  brokerName={connectedBrokerName ?? null}
279
354
  portfolioName={portfolioName}
280
355
  brokerSyncSummary={brokerSyncSummary}
356
+ accountOutcome={account.accountOutcome}
281
357
  isFinishing={isFinishing}
282
358
  error={finishError}
283
359
  />
284
360
  )}
361
+ </Box>
285
362
 
363
+ <Box
364
+ position="absolute"
365
+ top={termHeight - FOOTER_ROWS}
366
+ left={contentLeft}
367
+ width={contentWidth}
368
+ flexDirection="column"
369
+ >
286
370
  <Box height={1} />
287
371
  <Box height={1} flexDirection="row" width={contentWidth}>
288
372
  <Box flexGrow={1}>
@@ -45,7 +45,7 @@ export function BrokerFieldsPanel({
45
45
 
46
46
  if (!isActive && value) {
47
47
  return (
48
- <Box key={field.key} height={1}>
48
+ <Box key={field.key} height={1} flexDirection="row">
49
49
  <Text fg={colors.positive}>{"\u2713 "}</Text>
50
50
  <Text fg={colors.text}>{`${field.label}: ${formatBrokerFieldValue(field, value)}`}</Text>
51
51
  </Box>
@@ -58,7 +58,7 @@ export function BrokerFieldsPanel({
58
58
  return (
59
59
  <Box key={field.key} flexDirection="column">
60
60
  {index > 0 && <Box height={1} />}
61
- <Box height={1}>
61
+ <Box height={1} flexDirection="row">
62
62
  <Text fg={colors.text} attributes={TextAttributes.BOLD}>
63
63
  {tf("Step {step}: ", { step: index + 1 })}
64
64
  </Text>
@@ -97,7 +97,7 @@ export function BrokerFieldsPanel({
97
97
  const selected = optionIdx === brokerSelectIdx;
98
98
  return (
99
99
  <Box key={option.value} flexDirection="column" backgroundColor={selected ? colors.selected : colors.bg}>
100
- <Box height={1}>
100
+ <Box height={1} flexDirection="row">
101
101
  <Text fg={selected ? colors.selectedText : colors.textDim}>{selected ? "\u25b8 " : " "}</Text>
102
102
  <Text
103
103
  fg={selected ? colors.text : colors.textDim}