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
@@ -5,6 +5,7 @@ import type { DataProvider } from "../../../../types/data-provider";
5
5
  import type { CommandDef, PaneTemplateCreateOptions, PaneTemplateDef } from "../../../../types/plugin";
6
6
  import type { TickerRecord } from "../../../../types/ticker";
7
7
  import type { TickerSearchCandidate } from "../../../../tickers/search";
8
+ import type { AssistRowHandlers } from "../../assist/model";
8
9
  import { matchPrefix, type Command } from "../../commands/registry";
9
10
  import type { ResultItem } from "../../list/model";
10
11
  import type { CommandBarRoute } from "../../workflow/types";
@@ -12,13 +13,23 @@ import { useTickerSearchRouteResults } from "../ticker-search/route";
12
13
  import { buildRootResultModel, type RootResultModel } from "./results";
13
14
  import { useRootProviderSearch } from "./provider-search";
14
15
  import { buildRootShortcutFeedback } from "./shortcut-feedback";
15
- import { parseRootShortcutIntent } from "./shortcuts";
16
+ import type { ShortcutIntent } from "./shortcuts";
17
+
18
+ function clampSelectedIdx(index: number, length: number): number {
19
+ return Math.max(0, Math.min(index, length - 1));
20
+ }
21
+
22
+ /** A row plain Enter can run, so the untouched selection may rest on it. */
23
+ function isDefaultSelectable(item: ResultItem): boolean {
24
+ return item.disabled !== true && item.defaultSelectable !== false;
25
+ }
16
26
 
17
27
  interface UseCommandBarRootRuntimeOptions {
18
28
  activeCollectionId: string | null;
19
29
  activePortfolio?: AppState["config"]["portfolios"][number];
20
30
  activeTickerData: TickerRecord | null | undefined;
21
31
  activeTickerSymbol: string | null;
32
+ assist: AssistRowHandlers;
22
33
  availableCommands: Command[];
23
34
  buildLayoutItems(query: string, options?: { confirmDangerousActions?: boolean }): ResultItem[];
24
35
  buildPaneSettingItems(paneId: string | null, query: string): ResultItem[];
@@ -39,7 +50,6 @@ interface UseCommandBarRootRuntimeOptions {
39
50
  rawInput?: string,
40
51
  ): void | Promise<void>;
41
52
  getAvailablePaneShortcutTemplates(query: string): PaneTemplateDef[];
42
- getAvailablePluginCommands(): CommandDef[];
43
53
  getTickers(): AppState["tickers"];
44
54
  hasPaneSettings(paneId: string): boolean;
45
55
  localTickerSearchResultItems(query?: string, options?: { category?: string; limit?: number }): ResultItem[];
@@ -60,6 +70,8 @@ interface UseCommandBarRootRuntimeOptions {
60
70
  ): TickerSearchCandidate[] | null;
61
71
  rootModeKind: string;
62
72
  rootQuery: string;
73
+ rootSelectionNavigatedRef: RefObject<boolean>;
74
+ rootShortcutIntent: ShortcutIntent;
63
75
  runDirectCommand(command: Command, arg: string): void;
64
76
  runSecurityDescriptionShortcut(query?: string): void | Promise<void>;
65
77
  setRootHoveredIdx: Dispatch<SetStateAction<number | null>>;
@@ -80,6 +92,7 @@ export function useCommandBarRootRuntime({
80
92
  activePortfolio,
81
93
  activeTickerData,
82
94
  activeTickerSymbol,
95
+ assist,
83
96
  availableCommands,
84
97
  buildLayoutItems,
85
98
  buildPaneSettingItems,
@@ -92,7 +105,6 @@ export function useCommandBarRootRuntime({
92
105
  dataProvider,
93
106
  executeCollectionCommand,
94
107
  getAvailablePaneShortcutTemplates,
95
- getAvailablePluginCommands,
96
108
  getTickers,
97
109
  hasPaneSettings,
98
110
  localTickerSearchResultItems,
@@ -105,6 +117,8 @@ export function useCommandBarRootRuntime({
105
117
  readTickerSearchCache,
106
118
  rootModeKind,
107
119
  rootQuery,
120
+ rootSelectionNavigatedRef,
121
+ rootShortcutIntent,
108
122
  runDirectCommand,
109
123
  runSecurityDescriptionShortcut,
110
124
  setRootHoveredIdx,
@@ -121,21 +135,14 @@ export function useCommandBarRootRuntime({
121
135
  rootSearching: boolean;
122
136
  rootSectionOrder: ReturnType<typeof useRootProviderSearch>["rootSectionOrder"];
123
137
  rootShortcutFeedback: string | null;
124
- rootShortcutIntent: ReturnType<typeof parseRootShortcutIntent>;
138
+ rootShortcutIntent: ShortcutIntent;
125
139
  tickerSearchPending: boolean;
126
140
  tickerSearchResults: ResultItem[];
127
141
  } {
128
142
  const previousRootSelectionContextRef = useRef<{ query: string; mode: string } | null>(null);
143
+ const previousRootResultIdsRef = useRef<string[]>([]);
129
144
  const activeMatch = matchPrefix(rootQuery, availableCommands);
130
145
 
131
- const rootShortcutIntent = useMemo(() => parseRootShortcutIntent({
132
- query: rootQuery,
133
- commands: availableCommands,
134
- pluginCommands: getAvailablePluginCommands(),
135
- paneTemplates: getAvailablePaneShortcutTemplates(rootQuery),
136
- activeTicker: activeTickerSymbol,
137
- }), [activeTickerSymbol, availableCommands, getAvailablePaneShortcutTemplates, getAvailablePluginCommands, rootQuery]);
138
-
139
146
  const tickerSearchRouteQuery = currentRoute?.kind === "mode" && currentRoute.screen === "ticker-search"
140
147
  ? currentRoute.query
141
148
  : null;
@@ -160,6 +167,7 @@ export function useCommandBarRootRuntime({
160
167
  activeCollectionId,
161
168
  activeTickerData,
162
169
  activeTickerSymbol,
170
+ assist,
163
171
  availableCommands,
164
172
  buildLayoutItems,
165
173
  buildPaneSettingItems,
@@ -187,6 +195,7 @@ export function useCommandBarRootRuntime({
187
195
  activeCollectionId,
188
196
  activeTickerData,
189
197
  activeTickerSymbol,
198
+ assist,
190
199
  availableCommands,
191
200
  buildLayoutItems,
192
201
  buildPaneSettingItems,
@@ -217,30 +226,6 @@ export function useCommandBarRootRuntime({
217
226
  : null;
218
227
  const rootTickerSearchArg = rootSecurityDescriptionArg;
219
228
 
220
- useEffect(() => {
221
- if (currentRoute) return;
222
-
223
- setRootHoveredIdx((current) => (current != null && current < rootResultModel.items.length ? current : null));
224
- const selectionContextChanged =
225
- previousRootSelectionContextRef.current?.query !== rootQuery
226
- || previousRootSelectionContextRef.current?.mode !== rootModeKind;
227
- if (activeMatch?.command.id === "plugins" || !selectionContextChanged) {
228
- setRootSelectedIdx((current) => Math.max(0, Math.min(current, rootResultModel.items.length - 1)));
229
- } else {
230
- setRootSelectedIdx(Math.max(0, Math.min(rootResultModel.initialIdx, rootResultModel.items.length - 1)));
231
- }
232
- previousRootSelectionContextRef.current = { query: rootQuery, mode: rootModeKind };
233
- }, [
234
- activeMatch?.command.id,
235
- currentRoute,
236
- rootModeKind,
237
- rootQuery,
238
- rootResultModel.initialIdx,
239
- rootResultModel.items.length,
240
- setRootHoveredIdx,
241
- setRootSelectedIdx,
242
- ]);
243
-
244
229
  const {
245
230
  activeRootProviderResultsKey,
246
231
  orderedRootResults,
@@ -261,6 +246,51 @@ export function useCommandBarRootRuntime({
261
246
  writeTickerSearchCache,
262
247
  });
263
248
 
249
+ useEffect(() => {
250
+ if (currentRoute) return;
251
+
252
+ const resultIds = orderedRootResults.map((item) => item.id);
253
+ const previousResultIds = previousRootResultIdsRef.current;
254
+ previousRootResultIdsRef.current = resultIds;
255
+
256
+ setRootHoveredIdx((current) => (current != null && current < resultIds.length ? current : null));
257
+
258
+ const selectionContextChanged =
259
+ previousRootSelectionContextRef.current?.query !== rootQuery
260
+ || previousRootSelectionContextRef.current?.mode !== rootModeKind;
261
+ previousRootSelectionContextRef.current = { query: rootQuery, mode: rootModeKind };
262
+ // Filtering the plugin list is not meant to move the row being toggled.
263
+ const keepSelectionAcrossQueries = activeMatch?.command.id === "plugins";
264
+ if (selectionContextChanged && !keepSelectionAcrossQueries) {
265
+ rootSelectionNavigatedRef.current = false;
266
+ }
267
+
268
+ setRootSelectedIdx((current) => {
269
+ if (rootSelectionNavigatedRef.current || keepSelectionAcrossQueries) {
270
+ // The user picked this row: rows arriving above it — an AI answer, a
271
+ // provider result — may renumber it, never take the highlight from it.
272
+ const selectedId = previousResultIds[current];
273
+ const shiftedIdx = selectedId ? resultIds.indexOf(selectedId) : -1;
274
+ if (shiftedIdx >= 0) return shiftedIdx;
275
+ return clampSelectedIdx(current, resultIds.length);
276
+ }
277
+ // Untouched, the selection follows the best row on offer, which is what
278
+ // an AI answer becomes the moment it lands at the top of the list.
279
+ const defaultIdx = orderedRootResults.findIndex(isDefaultSelectable);
280
+ return clampSelectedIdx(Math.max(rootResultModel.initialIdx, defaultIdx), resultIds.length);
281
+ });
282
+ }, [
283
+ activeMatch?.command.id,
284
+ currentRoute,
285
+ orderedRootResults,
286
+ rootModeKind,
287
+ rootQuery,
288
+ rootResultModel.initialIdx,
289
+ rootSelectionNavigatedRef,
290
+ setRootHoveredIdx,
291
+ setRootSelectedIdx,
292
+ ]);
293
+
264
294
  useEffect(() => {
265
295
  if (!activeRootProviderResultsKey) return;
266
296
  setRootSelectedIdx(0);
@@ -2,7 +2,7 @@ import type { CommandDef, PaneTemplateDef } from "../../../../types/plugin";
2
2
  import { getCommandPrefixes, type Command } from "../../commands/registry";
3
3
  import { getPaneTemplateDisplayLabel } from "../../pane-templates/items";
4
4
 
5
- type RootShortcutArgKind = "text" | "ticker" | "ticker-list";
5
+ export type RootShortcutArgKind = "text" | "ticker" | "ticker-list";
6
6
  type ShortcutIntentKind = "none" | "complete" | "inferred-complete" | "partial" | "ambiguous";
7
7
 
8
8
  interface ShortcutIntentBase {
@@ -60,14 +60,18 @@ function mapPlaceholderToArgKind(value: string | undefined): RootShortcutArgKind
60
60
  return null;
61
61
  }
62
62
 
63
- function getPaneShortcutArgKind(template: PaneTemplateDef): RootShortcutArgKind | null {
63
+ export function getPaneShortcutArgKind(template: PaneTemplateDef): RootShortcutArgKind | null {
64
64
  return template.shortcut?.argKind ?? mapPlaceholderToArgKind(template.shortcut?.argPlaceholder);
65
65
  }
66
66
 
67
- function getCommandShortcutArgKind(command: Command): RootShortcutArgKind | null {
67
+ export function getCommandShortcutArgKind(command: Command): RootShortcutArgKind | null {
68
68
  return mapPlaceholderToArgKind(command.argPlaceholder) ?? (command.hasArg ? "text" : null);
69
69
  }
70
70
 
71
+ export function getPluginCommandShortcutArgKind(command: CommandDef): RootShortcutArgKind | null {
72
+ return command.shortcutArg?.kind ?? (command.shortcutArg ? "text" : null);
73
+ }
74
+
71
75
  function inferShortcutArg(argKind: RootShortcutArgKind | null, activeTicker: string | null): string | null {
72
76
  if (!activeTicker) return null;
73
77
  if (argKind === "ticker" || argKind === "ticker-list") {
@@ -98,7 +102,7 @@ function buildShortcutCandidates(
98
102
  prefix: normalizeShortcutPrefix(command.shortcut!),
99
103
  label: command.label,
100
104
  description: command.description ?? "",
101
- argKind: command.shortcutArg?.kind ?? (command.shortcutArg ? "text" : null),
105
+ argKind: getPluginCommandShortcutArgKind(command),
102
106
  argPlaceholder: command.shortcutArg?.placeholder,
103
107
  source: "plugin-command" as const,
104
108
  pluginCommand: command,
@@ -24,6 +24,8 @@ interface UseCommandBarNavigationStateResult {
24
24
  currentRouteRef: RefObject<CommandBarRoute | null>;
25
25
  dismissCommandBar: () => void;
26
26
  lastMainBrowseRef: RefObject<CommandBarMainSnapshot>;
27
+ /** Records that the user moved the root selection themselves. */
28
+ markRootSelectionNavigated: () => void;
27
29
  popRoute: () => void;
28
30
  pushRoute: (route: CommandBarRoute) => void;
29
31
  rootHoveredIdx: number | null;
@@ -31,6 +33,8 @@ interface UseCommandBarNavigationStateResult {
31
33
  rootModeKindRef: RefObject<ReturnType<typeof resolveCommandBarMode>["kind"]>;
32
34
  rootQuery: string;
33
35
  rootQueryRef: RefObject<string>;
36
+ /** True once the user picked a row; cleared when the query context changes. */
37
+ rootSelectionNavigatedRef: RefObject<boolean>;
34
38
  rootSelectedIdx: number;
35
39
  routeStack: CommandBarRoute[];
36
40
  setRootHoveredIdx: Dispatch<SetStateAction<number | null>>;
@@ -56,6 +60,15 @@ export function useCommandBarNavigationState({
56
60
 
57
61
  const [rootSelectedIdx, setRootSelectedIdx] = useState(0);
58
62
  const [rootHoveredIdx, setRootHoveredIdx] = useState<number | null>(null);
63
+ /**
64
+ * Whether the highlighted row is the user's own choice. Rows that arrive
65
+ * later — an AI answer, provider results — may only shift a chosen row, never
66
+ * replace it, while an untouched selection keeps following the top match.
67
+ */
68
+ const rootSelectionNavigatedRef = useRef(false);
69
+ const markRootSelectionNavigated = useCallback(() => {
70
+ rootSelectionNavigatedRef.current = true;
71
+ }, []);
59
72
  const [routeStack, setRouteStack] = useState<CommandBarRoute[]>([]);
60
73
  const lastMainBrowseRef = useRef<CommandBarMainSnapshot>({ query: "", selectedIdx: 0 });
61
74
 
@@ -70,6 +83,7 @@ export function useCommandBarNavigationState({
70
83
  setRootQueryValue(initialQuery);
71
84
  setRootSelectedIdx(0);
72
85
  setRootHoveredIdx(null);
86
+ rootSelectionNavigatedRef.current = false;
73
87
  }, [initialQuery]);
74
88
 
75
89
  const closeAll = useCallback((options?: CloseCommandBarOptions) => {
@@ -80,6 +94,7 @@ export function useCommandBarNavigationState({
80
94
  setRouteStack([]);
81
95
  setRootSelectedIdx(0);
82
96
  setRootHoveredIdx(null);
97
+ rootSelectionNavigatedRef.current = false;
83
98
  }, [dispatch, restoreThemePreview]);
84
99
 
85
100
  const setRootQuery = useCallback((query: string) => {
@@ -138,6 +153,7 @@ export function useCommandBarNavigationState({
138
153
  currentRouteRef,
139
154
  dismissCommandBar,
140
155
  lastMainBrowseRef,
156
+ markRootSelectionNavigated,
141
157
  popRoute,
142
158
  pushRoute,
143
159
  rootHoveredIdx,
@@ -145,6 +161,7 @@ export function useCommandBarNavigationState({
145
161
  rootModeKindRef,
146
162
  rootQuery,
147
163
  rootQueryRef,
164
+ rootSelectionNavigatedRef,
148
165
  rootSelectedIdx,
149
166
  routeStack,
150
167
  setRootHoveredIdx,
@@ -195,6 +195,31 @@ export function useCommandBarSelectionRuntime({
195
195
  startThemePicker,
196
196
  ]);
197
197
 
198
+ /**
199
+ * Runs `query` exactly as if it had been typed into the root bar and
200
+ * submitted, so AI-suggested inputs take the same prefix/arg path as anything
201
+ * the user types. Argless prefixes reject a trailing argument outright
202
+ * ("ERN NVDA" resolves to nothing), so the bare prefix gets a second try
203
+ * before the text is merely dropped into the input.
204
+ */
205
+ const runRootQuery = useCallback((query: string, options?: { fallbackPrefix?: string }) => {
206
+ const item = resolveImmediateRootSelection(query);
207
+ if (item && !item.disabled) {
208
+ void item.action();
209
+ return;
210
+ }
211
+ const trimmed = query.trim();
212
+ const fallbackPrefix = (options?.fallbackPrefix ?? trimmed.split(/\s+/)[0] ?? "").trim();
213
+ if (fallbackPrefix && fallbackPrefix.toUpperCase() !== trimmed.toUpperCase()) {
214
+ const fallbackItem = resolveImmediateRootSelection(fallbackPrefix);
215
+ if (fallbackItem && !fallbackItem.disabled) {
216
+ void fallbackItem.action();
217
+ return;
218
+ }
219
+ }
220
+ setRootQuery(query);
221
+ }, [resolveImmediateRootSelection, setRootQuery]);
222
+
198
223
  const setActiveListQuery = useCallback((nextQuery: string) => {
199
224
  const route = currentRouteRef.current;
200
225
  if (!route) {
@@ -286,6 +311,7 @@ export function useCommandBarSelectionRuntime({
286
311
  acceptRootShortcutTab,
287
312
  acceptSelectedShortcutTab,
288
313
  activateListSelection,
314
+ runRootQuery,
289
315
  setActiveListQuery,
290
316
  };
291
317
  }
@@ -0,0 +1,276 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { apiClient, setCloudApiFetchTransport } from "../../../api-client";
3
+ import { testRender } from "../../../renderers/opentui/test-utils";
4
+ import type { PluginRegistry } from "../../../plugins/registry";
5
+ import type { PaneTemplateCreateOptions } from "../../../types/plugin";
6
+ import {
7
+ CommandBarHarness,
8
+ createCommandBarTestControls,
9
+ emitKeypress,
10
+ } from "./test-harness";
11
+
12
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
13
+
14
+ afterEach(() => {
15
+ setCloudApiFetchTransport(null);
16
+ apiClient.setSessionToken(null);
17
+ if (testSetup) {
18
+ testSetup.renderer.destroy();
19
+ testSetup = undefined;
20
+ }
21
+ });
22
+
23
+ const { waitForFrameToContain } = createCommandBarTestControls(() => testSetup!);
24
+
25
+ function signInVerified(): void {
26
+ apiClient.setSessionToken("assist-test-token");
27
+ apiClient.restoreCachedUser({
28
+ id: "user-1",
29
+ name: "Tester",
30
+ email: "tester@example.com",
31
+ username: "tester",
32
+ emailVerified: true,
33
+ plan: "free",
34
+ } as never);
35
+ }
36
+
37
+ /**
38
+ * Answers `/assist/command` locally and returns the asks that were made. Other
39
+ * cloud calls are refused: ambient client traffic (a session refresh) must
40
+ * never be mistaken for the bar asking a question.
41
+ */
42
+ function mockAssistTransport(respond: (body: unknown) => Response | Promise<Response>): string[] {
43
+ const requests: string[] = [];
44
+ setCloudApiFetchTransport(async (url, init) => {
45
+ if (!url.endsWith("/assist/command")) {
46
+ throw new Error(`unexpected cloud request: ${url}`);
47
+ }
48
+ requests.push(url);
49
+ return await respond(JSON.parse(String(init?.body ?? "{}")));
50
+ });
51
+ return requests;
52
+ }
53
+
54
+ function jsonResponse(body: unknown, status = 200): Response {
55
+ return new Response(JSON.stringify(body), {
56
+ status,
57
+ headers: { "content-type": "application/json" },
58
+ });
59
+ }
60
+
61
+ /** Records pane creations and registers "ERN", an argless pane shortcut. */
62
+ function configureEarningsRegistry(
63
+ created: Array<{ templateId: string; options?: PaneTemplateCreateOptions }>,
64
+ ) {
65
+ return (pluginRegistry: PluginRegistry) => {
66
+ pluginRegistry.paneTemplates.set("earnings-calendar-pane", {
67
+ id: "earnings-calendar-pane",
68
+ paneId: "portfolio-list",
69
+ label: "Earnings Calendar",
70
+ description: "Upcoming earnings dates and estimates",
71
+ shortcut: { prefix: "ERN" },
72
+ } as never);
73
+ (pluginRegistry as unknown as {
74
+ createPaneFromTemplateAsyncFn: (templateId: string, options?: PaneTemplateCreateOptions) => Promise<void>;
75
+ }).createPaneFromTemplateAsyncFn = async (templateId, options) => {
76
+ created.push({ templateId, options });
77
+ };
78
+ };
79
+ }
80
+
81
+ /** Wide enough to cover the ask debounce plus the round trip. */
82
+ const ASSIST_WAIT_ATTEMPTS = 40;
83
+
84
+ async function waitForRequest(requests: string[]): Promise<void> {
85
+ for (let attempt = 0; attempt < ASSIST_WAIT_ATTEMPTS; attempt++) {
86
+ if (requests.length > 0) return;
87
+ await Bun.sleep(50);
88
+ await testSetup!.renderOnce();
89
+ }
90
+ throw new Error("Timed out waiting for the assist request.");
91
+ }
92
+
93
+ async function waitForFrameWithout(text: string): Promise<string> {
94
+ for (let attempt = 0; attempt < ASSIST_WAIT_ATTEMPTS; attempt++) {
95
+ const frame = testSetup!.captureCharFrame();
96
+ if (!frame.includes(text)) return frame;
97
+ await Bun.sleep(50);
98
+ await testSetup!.renderOnce();
99
+ }
100
+ throw new Error(`Timed out waiting for "${text}" to disappear.`);
101
+ }
102
+
103
+ describe("CommandBar AI assist", () => {
104
+ test("asks on its own, with no Enter, and leads the list with the answer", async () => {
105
+ signInVerified();
106
+ let sentCommandCount = 0;
107
+ let releaseResponse = () => {};
108
+ const held = new Promise<void>((resolve) => { releaseResponse = resolve; });
109
+ const requests = mockAssistTransport(async (body) => {
110
+ sentCommandCount = (body as { commands: unknown[] }).commands.length;
111
+ await held;
112
+ return jsonResponse({
113
+ candidates: [{ input: "CHAT #general", title: "Open the general channel", prefix: "CHAT", confidence: 0.9 }],
114
+ });
115
+ });
116
+ const created: Array<{ templateId: string; options?: PaneTemplateCreateOptions }> = [];
117
+
118
+ testSetup = await testRender(
119
+ <CommandBarHarness
120
+ query="new chat pane"
121
+ configurePluginRegistry={configureEarningsRegistry(created)}
122
+ />,
123
+ { width: 100, height: 20 },
124
+ );
125
+
126
+ await testSetup.renderOnce();
127
+ expect(testSetup.captureCharFrame()).toContain("Thinking…");
128
+ // Nobody pressed Enter; the debounce fires the one request by itself.
129
+ await waitForRequest(requests);
130
+ expect(requests).toHaveLength(1);
131
+ expect(sentCommandCount).toBeGreaterThan(0);
132
+
133
+ releaseResponse();
134
+ const answered = await waitForFrameToContain("CHAT #general — Open the general channel", ASSIST_WAIT_ATTEMPTS);
135
+ expect(answered).not.toContain("Thinking…");
136
+ expect(requests).toHaveLength(1);
137
+ // Above the local matches, and holding the selection an untouched query
138
+ // never moved: plain Enter runs the AI's best guess.
139
+ expect(answered.indexOf("Ask AI")).toBeLessThan(answered.indexOf("Panes"));
140
+
141
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
142
+ expect(created).toEqual([{ templateId: "new-chat-pane", options: { arg: "#general" } }]);
143
+ });
144
+
145
+ test("keeps the row the user picked when an answer lands above it", async () => {
146
+ signInVerified();
147
+ mockAssistTransport(() => jsonResponse({
148
+ candidates: [
149
+ { input: "CHAT #general", title: "Open the general channel", prefix: "CHAT", confidence: 0.9 },
150
+ { input: "CHAT #random", title: "Open the random channel", prefix: "CHAT", confidence: 0.4 },
151
+ ],
152
+ }));
153
+ const created: Array<{ templateId: string; options?: PaneTemplateCreateOptions }> = [];
154
+
155
+ testSetup = await testRender(
156
+ <CommandBarHarness
157
+ query="new chat pane"
158
+ configurePluginRegistry={configureEarningsRegistry(created)}
159
+ />,
160
+ { width: 100, height: 20 },
161
+ );
162
+
163
+ await testSetup.renderOnce();
164
+ // Down lands on the local match while a single "Thinking…" row sits above.
165
+ await emitKeypress(testSetup, { name: "down" });
166
+ await waitForFrameToContain("CHAT #random — Open the random channel", ASSIST_WAIT_ATTEMPTS);
167
+
168
+ // Two answers replaced that one row, so the chosen row moved down by one —
169
+ // Enter still runs it rather than whatever now sits at its old index.
170
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
171
+ expect(created).toEqual([{ templateId: "new-chat-pane", options: undefined }]);
172
+ });
173
+
174
+ test("runs an argless prefix candidate that still carries an argument", async () => {
175
+ signInVerified();
176
+ // Unclamped on purpose: "ERN" takes no argument, so the client has to fall
177
+ // back to the bare prefix instead of dropping the text into the input.
178
+ mockAssistTransport(() => jsonResponse({
179
+ candidates: [{ input: "ERN NVDA", title: "Earnings Calendar for NVDA", prefix: "ERN", confidence: 0.8 }],
180
+ }));
181
+ const created: Array<{ templateId: string; options?: PaneTemplateCreateOptions }> = [];
182
+
183
+ testSetup = await testRender(
184
+ <CommandBarHarness
185
+ query="nvda earnings"
186
+ configurePluginRegistry={configureEarningsRegistry(created)}
187
+ />,
188
+ { width: 100, height: 20 },
189
+ );
190
+
191
+ await testSetup.renderOnce();
192
+ await waitForFrameToContain("ERN NVDA — Earnings Calendar for NVDA", ASSIST_WAIT_ATTEMPTS);
193
+
194
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
195
+ expect(created).toEqual([{ templateId: "earnings-calendar-pane", options: undefined }]);
196
+ });
197
+
198
+ test("claims the answer still in flight when Enter lands on the thinking row", async () => {
199
+ signInVerified();
200
+ let releaseResponse = () => {};
201
+ const held = new Promise<void>((resolve) => { releaseResponse = resolve; });
202
+ const requests = mockAssistTransport(async () => {
203
+ await held;
204
+ return jsonResponse({
205
+ candidates: [{ input: "CHAT #general", title: "Open the general channel", prefix: "CHAT", confidence: 0.9 }],
206
+ });
207
+ });
208
+ const created: Array<{ templateId: string; options?: PaneTemplateCreateOptions }> = [];
209
+
210
+ testSetup = await testRender(
211
+ <CommandBarHarness
212
+ query="new chat pane"
213
+ configurePluginRegistry={configureEarningsRegistry(created)}
214
+ />,
215
+ { width: 100, height: 20 },
216
+ );
217
+
218
+ await testSetup.renderOnce();
219
+ expect(testSetup.captureCharFrame()).toContain("Thinking…");
220
+
221
+ // Enter on "Thinking…" is a promise, not a dead key: there is nothing to
222
+ // run yet, so the ask is claimed and its answer runs when it arrives.
223
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
224
+ expect(created).toEqual([]);
225
+
226
+ releaseResponse();
227
+ for (let attempt = 0; attempt < ASSIST_WAIT_ATTEMPTS && created.length === 0; attempt++) {
228
+ await Bun.sleep(50);
229
+ await testSetup.renderOnce();
230
+ }
231
+ expect(created).toEqual([{ templateId: "new-chat-pane", options: { arg: "#general" } }]);
232
+ expect(requests).toHaveLength(1);
233
+ });
234
+
235
+ test("drops the section when a background ask fails", async () => {
236
+ signInVerified();
237
+ const requests = mockAssistTransport(() => jsonResponse({ error: "assist-unavailable" }, 503));
238
+
239
+ testSetup = await testRender(
240
+ <CommandBarHarness query="show me the newest filings" />,
241
+ { width: 100, height: 20 },
242
+ );
243
+
244
+ await testSetup.renderOnce();
245
+ expect(testSetup.captureCharFrame()).toContain("Ask AI");
246
+
247
+ const frame = await waitForFrameWithout("Ask AI");
248
+ expect(requests).toHaveLength(1);
249
+ expect(frame).not.toContain("unavailable");
250
+ });
251
+
252
+ test("sends signed-out users to sign up instead of the endpoint", async () => {
253
+ const requests = mockAssistTransport(() => jsonResponse({ candidates: [] }));
254
+ const created: Array<{ templateId: string; options?: PaneTemplateCreateOptions }> = [];
255
+
256
+ testSetup = await testRender(
257
+ <CommandBarHarness
258
+ query="new chat pane"
259
+ configurePluginRegistry={configureEarningsRegistry(created)}
260
+ />,
261
+ { width: 100, height: 20 },
262
+ );
263
+
264
+ await testSetup.renderOnce();
265
+ expect(testSetup.captureCharFrame()).toContain("Ask AI — sign up to enable");
266
+
267
+ await Bun.sleep(700);
268
+ await testSetup.renderOnce();
269
+ expect(requests).toEqual([]);
270
+
271
+ // The offer leads the list but never takes the Enter that belongs to the
272
+ // local match the user was already looking at.
273
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
274
+ expect(created).toEqual([{ templateId: "new-chat-pane", options: undefined }]);
275
+ });
276
+ });