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.
- package/README.md +3 -3
- package/README.zh-CN.md +2 -2
- package/package.json +2 -1
- package/src/api-client/auth.ts +8 -0
- package/src/api-client/index.ts +70 -0
- package/src/api-client/types.ts +49 -0
- package/src/app/runtime/desktop-deeplink.test.ts +8 -0
- package/src/app/runtime/desktop-deeplink.ts +14 -1
- package/src/components/chart/composite/composite-chart.test.tsx +340 -17
- package/src/components/chart/composite/composite-chart.tsx +859 -68
- package/src/components/chart/composite/interactions.test.ts +78 -22
- package/src/components/chart/composite/interactions.ts +45 -17
- package/src/components/chart/composite/rasterizer.ts +8 -2
- package/src/components/chart/composite/renderers.test.ts +61 -1
- package/src/components/chart/composite/scene.ts +14 -4
- package/src/components/chart/composite/tools.test.ts +371 -0
- package/src/components/chart/composite/tools.ts +508 -0
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/native/raster/primitives.ts +18 -0
- package/src/components/chart/price-axis-labels.tsx +42 -2
- package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
- package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
- package/src/components/command-bar/action-runtime.ts +3 -0
- package/src/components/command-bar/assist/inventory.test.ts +93 -0
- package/src/components/command-bar/assist/inventory.ts +92 -0
- package/src/components/command-bar/assist/model.test.ts +104 -0
- package/src/components/command-bar/assist/model.ts +169 -0
- package/src/components/command-bar/assist/runtime.ts +185 -0
- package/src/components/command-bar/commands/direct/index.ts +1 -1
- package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
- package/src/components/command-bar/list/model.ts +15 -2
- package/src/components/command-bar/list/navigation.ts +6 -0
- package/src/components/command-bar/list/view.tsx +16 -3
- package/src/components/command-bar/pane-templates/workflow.ts +1 -0
- package/src/components/command-bar/panel/body.tsx +2 -0
- package/src/components/command-bar/panel/index.tsx +3 -0
- package/src/components/command-bar/panel/runtime.ts +6 -0
- package/src/components/command-bar/panel/types.ts +1 -0
- package/src/components/command-bar/routes/root/results.ts +28 -2
- package/src/components/command-bar/routes/root/runtime.ts +66 -36
- package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
- package/src/components/command-bar/routing/navigation-state.ts +17 -0
- package/src/components/command-bar/selection-runtime.ts +26 -0
- package/src/components/command-bar/surface/assist.test.tsx +276 -0
- package/src/components/command-bar/surface/index.tsx +93 -2
- package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
- package/src/components/command-bar/view-model.ts +7 -1
- package/src/components/command-bar/workflow/broker.test.ts +22 -0
- package/src/components/command-bar/workflow/broker.ts +1 -0
- package/src/components/index.ts +1 -1
- package/src/components/layout/detached-pane-shell.tsx +40 -2
- package/src/components/layout/floating-pane.tsx +4 -1
- package/src/components/layout/pane/header.tsx +64 -6
- package/src/components/layout/pane/index.tsx +4 -1
- package/src/components/layout/shell/index.tsx +23 -0
- package/src/components/layout/shell/pane/layers.tsx +5 -0
- package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
- package/src/components/onboarding/account-step/form-panel.tsx +157 -0
- package/src/components/onboarding/account-step/index.tsx +58 -0
- package/src/components/onboarding/account-step/model.test.ts +67 -0
- package/src/components/onboarding/account-step/model.ts +126 -0
- package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
- package/src/components/onboarding/onboarding-steps.tsx +91 -55
- package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
- package/src/components/onboarding/onboarding-wizard.tsx +93 -9
- package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
- package/src/components/onboarding/wizard-account.ts +235 -0
- package/src/components/onboarding/wizard-keyboard.ts +67 -1
- package/src/components/onboarding/wizard-model.ts +2 -2
- package/src/components/ui/data-table/index.tsx +1 -0
- package/src/components/ui/data-table/opentui/index.tsx +44 -2
- package/src/components/ui/data-table/types.ts +10 -0
- package/src/components/ui/data-table/visible-range.test.ts +29 -0
- package/src/components/ui/data-table/visible-range.ts +21 -0
- package/src/components/ui/ui.test.tsx +23 -1
- package/src/i18n/es.ts +1093 -0
- package/src/i18n/index.test.ts +5 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/ja.ts +66 -3
- package/src/i18n/ko.ts +66 -3
- package/src/i18n/languages.test.ts +4 -1
- package/src/i18n/languages.ts +12 -1
- package/src/i18n/zh-cn.ts +66 -3
- package/src/i18n/zh-tw.ts +66 -3
- package/src/market-data/coordinator/index.ts +1 -1
- package/src/market-data/coordinator/quotes.ts +64 -1
- package/src/market-data/coordinator/subscriptions.test.ts +72 -0
- package/src/market-data/quotes/cadence.ts +2 -0
- package/src/plugins/builtin/account-management/footer.ts +9 -3
- package/src/plugins/builtin/account-management/model.ts +53 -1
- package/src/plugins/builtin/account-management/pane.tsx +107 -24
- package/src/plugins/builtin/ai/index.tsx +7 -2
- package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
- package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
- package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
- package/src/plugins/builtin/chart-composer/index.tsx +10 -2
- package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
- package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
- package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
- package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
- package/src/plugins/builtin/cloud/plugin.tsx +9 -1
- package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
- package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
- package/src/plugins/builtin/help/index.tsx +38 -0
- package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
- package/src/plugins/builtin/options/footer.test.ts +20 -112
- package/src/plugins/builtin/options/footer.ts +35 -78
- package/src/plugins/builtin/options/index.tsx +6 -0
- package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
- package/src/plugins/builtin/options/live-quotes.ts +93 -39
- package/src/plugins/builtin/options/view.test.tsx +5 -2
- package/src/plugins/builtin/options/view.tsx +49 -9
- package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
- package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
- package/src/plugins/builtin/shared/live-streaming.ts +60 -0
- package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
- package/src/plugins/builtin/shared/plan-access.ts +109 -0
- package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
- package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
- package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
- package/src/plugins/registry/index.test.ts +51 -0
- package/src/plugins/registry/index.ts +12 -0
- package/src/plugins/registry/pane-settings.ts +29 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
- package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
- package/src/renderers/electrobun/view/host/box.tsx +3 -0
- package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
- package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
- package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
- package/src/renderers/electrobun/view/host/input.tsx +16 -1
- package/src/renderers/electrobun/view/host/mouse.ts +1 -0
- package/src/renderers/electrobun/view/host/style.ts +1 -1
- package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
- package/src/renderers/electrobun/view/main.tsx +2 -0
- package/src/renderers/opentui/chart-surface.tsx +2 -0
- package/src/renderers/opentui/host.tsx +3 -0
- package/src/sources/gloomberb-cloud/index.test.ts +8 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -4
- package/src/sources/provider-router/history.test.ts +3 -3
- package/src/sources/provider-router/history.ts +15 -4
- package/src/sources/yahoo-finance/history.test.ts +47 -0
- package/src/sources/yahoo-finance/history.ts +5 -1
- package/src/state/hooks/quote-streaming.test.tsx +181 -1
- package/src/state/hooks/quote-streaming.ts +93 -7
- package/src/theme/colors.ts +20 -1
- package/src/theme/themes.test.ts +3 -0
- package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
- package/src/time-series/resolution.test.ts +51 -0
- package/src/time-series/resolution.ts +41 -0
- package/src/time-series/resolve.test.ts +207 -1
- package/src/time-series/resolve.ts +196 -29
- package/src/time-series/spec-studies.test.ts +4 -0
- package/src/time-series/spec.ts +15 -1
- package/src/time-series/types.ts +7 -0
- package/src/time-series/use-chart-resolution.test.tsx +37 -0
- package/src/time-series/use-chart-resolution.ts +17 -1
- package/src/types/plugin.ts +9 -0
- 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 {
|
|
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:
|
|
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:
|
|
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
|
+
});
|