gloomberb 0.10.3 → 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/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/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 +64 -3
- package/src/i18n/ko.ts +64 -3
- package/src/i18n/languages.test.ts +4 -1
- package/src/i18n/languages.ts +12 -1
- package/src/i18n/zh-cn.ts +64 -3
- package/src/i18n/zh-tw.ts +64 -3
- package/src/market-data/coordinator/quotes.ts +63 -1
- package/src/market-data/coordinator/subscriptions.test.ts +33 -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 -124
- package/src/plugins/builtin/options/footer.ts +32 -90
- package/src/plugins/builtin/options/index.tsx +6 -0
- package/src/plugins/builtin/options/view.tsx +3 -0
- 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/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 +124 -1
- package/src/state/hooks/quote-streaming.ts +90 -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
|
@@ -31,6 +31,12 @@ function alignAxisLabel(label: string, width: number, index: number, count: numb
|
|
|
31
31
|
return `${" ".repeat(left)}${clipped}${" ".repeat(padding - left)}`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export interface StaticXAxisMarker {
|
|
35
|
+
ratio: number;
|
|
36
|
+
label: string;
|
|
37
|
+
color: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
export function StaticXAxisLabels({
|
|
35
41
|
labels,
|
|
36
42
|
positionedLabels,
|
|
@@ -41,6 +47,7 @@ export function StaticXAxisLabels({
|
|
|
41
47
|
cursorLabel,
|
|
42
48
|
cursorColor,
|
|
43
49
|
cursorBackgroundColor,
|
|
50
|
+
extraMarkers,
|
|
44
51
|
}: {
|
|
45
52
|
labels: string[];
|
|
46
53
|
positionedLabels?: readonly StaticChartXAxisLabel[];
|
|
@@ -51,6 +58,8 @@ export function StaticXAxisLabels({
|
|
|
51
58
|
cursorLabel?: string | null;
|
|
52
59
|
cursorColor?: string;
|
|
53
60
|
cursorBackgroundColor?: string;
|
|
61
|
+
/** Anchors that stay put while the cursor moves, such as a measure start. */
|
|
62
|
+
extraMarkers?: readonly StaticXAxisMarker[];
|
|
54
63
|
}) {
|
|
55
64
|
const uiHost = useUiHost();
|
|
56
65
|
const { cellWidthPx = 8, fractionalViewport = false } = useUiCapabilities();
|
|
@@ -95,6 +104,10 @@ export function StaticXAxisLabels({
|
|
|
95
104
|
flexDirection="row"
|
|
96
105
|
position="relative"
|
|
97
106
|
overflow="hidden"
|
|
107
|
+
data-gloom-role="chart-time-axis"
|
|
108
|
+
data-gloom-label={(visiblePositionedLabels.length > 0
|
|
109
|
+
? visiblePositionedLabels.map((entry) => entry.label)
|
|
110
|
+
: visibleLabels).join(" ")}
|
|
98
111
|
>
|
|
99
112
|
{uiHost.kind === "desktop-web" && visiblePositionedLabels.length > 0 ? (
|
|
100
113
|
<Box position="absolute" left={0} top={0} width={width} height={1}>
|
|
@@ -139,6 +152,39 @@ export function StaticXAxisLabels({
|
|
|
139
152
|
})}
|
|
140
153
|
</Box>
|
|
141
154
|
)}
|
|
155
|
+
{extraMarkers?.map((marker) => {
|
|
156
|
+
const label = marker.label.slice(0, width);
|
|
157
|
+
const ratio = clampRatio(marker.ratio);
|
|
158
|
+
if (label.length === 0) return null;
|
|
159
|
+
return (
|
|
160
|
+
<Text
|
|
161
|
+
key={`${marker.label}:${marker.ratio}`}
|
|
162
|
+
fg={marker.color}
|
|
163
|
+
bg={cursorBackgroundColor}
|
|
164
|
+
selectable={false}
|
|
165
|
+
style={fractionalViewport
|
|
166
|
+
? {
|
|
167
|
+
position: "absolute",
|
|
168
|
+
left: `${ratio * 100}%`,
|
|
169
|
+
top: 0,
|
|
170
|
+
transform: "translateX(-50%)",
|
|
171
|
+
whiteSpace: "pre",
|
|
172
|
+
pointerEvents: "none",
|
|
173
|
+
zIndex: 2,
|
|
174
|
+
}
|
|
175
|
+
: {
|
|
176
|
+
position: "absolute",
|
|
177
|
+
left: Math.max(0, Math.min(width - label.length, Math.round(ratio * (width - 1)) - Math.floor(label.length / 2))),
|
|
178
|
+
top: 0,
|
|
179
|
+
whiteSpace: "pre",
|
|
180
|
+
pointerEvents: "none",
|
|
181
|
+
zIndex: 2,
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
{label}
|
|
185
|
+
</Text>
|
|
186
|
+
);
|
|
187
|
+
})}
|
|
142
188
|
{clippedCursorLabel && (cursorLeftPercent !== null || cursorLeft !== null) ? (
|
|
143
189
|
<Text
|
|
144
190
|
fg={cursorColor}
|
|
@@ -238,6 +238,7 @@ export function useCommandBarActionRuntime({
|
|
|
238
238
|
adaptTickerSearchRouteResult,
|
|
239
239
|
createPaneTemplateItem,
|
|
240
240
|
getAvailablePaneShortcutTemplates,
|
|
241
|
+
getAvailablePaneTemplates,
|
|
241
242
|
nonShortcutPaneTemplateItems,
|
|
242
243
|
openPaneTemplateWorkflow,
|
|
243
244
|
paneShortcutItems,
|
|
@@ -295,6 +296,7 @@ export function useCommandBarActionRuntime({
|
|
|
295
296
|
createPluginCommandItem,
|
|
296
297
|
executeCollectionCommand,
|
|
297
298
|
getAvailablePaneShortcutTemplates,
|
|
299
|
+
getAvailablePaneTemplates,
|
|
298
300
|
getAvailablePluginCommands,
|
|
299
301
|
getWorkflowFieldStringValue,
|
|
300
302
|
getWorkflowInputRef,
|
|
@@ -307,6 +309,7 @@ export function useCommandBarActionRuntime({
|
|
|
307
309
|
openInlineConfirm,
|
|
308
310
|
openModeRoute,
|
|
309
311
|
openPaneTemplateWorkflow,
|
|
312
|
+
openPluginCommandWorkflow,
|
|
310
313
|
openWorkflowFieldPicker,
|
|
311
314
|
paneShortcutItems,
|
|
312
315
|
persistLayoutChange,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { CommandDef, PaneTemplateDef } from "../../../types/plugin";
|
|
3
|
+
import type { Command } from "../commands/registry";
|
|
4
|
+
import { buildAssistCommandInventory } from "./inventory";
|
|
5
|
+
|
|
6
|
+
function command(overrides: Partial<Command> & { prefix: string }): Command {
|
|
7
|
+
return {
|
|
8
|
+
id: overrides.prefix.toLowerCase(),
|
|
9
|
+
label: "Command",
|
|
10
|
+
description: "A command",
|
|
11
|
+
category: "Config",
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function paneTemplate(overrides: Partial<PaneTemplateDef> & { id: string }): PaneTemplateDef {
|
|
17
|
+
return {
|
|
18
|
+
paneId: "pane",
|
|
19
|
+
label: "Template",
|
|
20
|
+
description: "A template",
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("buildAssistCommandInventory", () => {
|
|
26
|
+
test("maps arg placeholders onto the kinds the shortcut parser uses", () => {
|
|
27
|
+
const inventory = buildAssistCommandInventory({
|
|
28
|
+
commands: [
|
|
29
|
+
command({ prefix: "DES", aliases: ["T"], label: "Description", hasArg: true, argPlaceholder: "ticker" }),
|
|
30
|
+
command({ prefix: "HELP", label: "Help", description: "Open the help window" }),
|
|
31
|
+
command({ prefix: "LAY", label: "Layout Actions", hasArg: true, argPlaceholder: "action" }),
|
|
32
|
+
],
|
|
33
|
+
pluginCommands: [{
|
|
34
|
+
id: "direct-message",
|
|
35
|
+
label: "DM",
|
|
36
|
+
description: "Open a DM",
|
|
37
|
+
keywords: [],
|
|
38
|
+
category: "navigation",
|
|
39
|
+
shortcut: "dm",
|
|
40
|
+
shortcutArg: { placeholder: "@username" },
|
|
41
|
+
execute: () => {},
|
|
42
|
+
}],
|
|
43
|
+
paneTemplates: [
|
|
44
|
+
paneTemplate({
|
|
45
|
+
id: "research",
|
|
46
|
+
label: "New Research Pane",
|
|
47
|
+
description: "Research view",
|
|
48
|
+
shortcut: { prefix: "RV", argPlaceholder: "tickers", argKind: "ticker-list" },
|
|
49
|
+
}),
|
|
50
|
+
paneTemplate({ id: "econ", label: "Econ", shortcut: { prefix: "ECON" } }),
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(inventory).toEqual([
|
|
55
|
+
{ prefix: "DES", name: "Description", description: "A command", arg: { kind: "ticker", placeholder: "ticker" } },
|
|
56
|
+
{ prefix: "HELP", name: "Help", description: "Open the help window" },
|
|
57
|
+
{ prefix: "LAY", name: "Layout Actions", description: "A command", arg: { kind: "text", placeholder: "action" } },
|
|
58
|
+
{ prefix: "DM", name: "DM", description: "Open a DM", arg: { kind: "text", placeholder: "@username" } },
|
|
59
|
+
{ prefix: "RV", name: "Research", description: "Research view", arg: { kind: "ticker-list", placeholder: "tickers" } },
|
|
60
|
+
{ prefix: "ECON", name: "Econ", description: "A template" },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("drops prefixless commands and keeps the entry the bar would run for a duplicate prefix", () => {
|
|
65
|
+
const inventory = buildAssistCommandInventory({
|
|
66
|
+
commands: [
|
|
67
|
+
command({ prefix: "", label: "New Portfolio" }),
|
|
68
|
+
command({ prefix: " ", label: "Reset All Data" }),
|
|
69
|
+
command({ prefix: "PL", label: "Manage Plugins" }),
|
|
70
|
+
],
|
|
71
|
+
pluginCommands: [],
|
|
72
|
+
paneTemplates: [
|
|
73
|
+
paneTemplate({ id: "plugins-pane", label: "Plugin List", shortcut: { prefix: "pl" } }),
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(inventory).toEqual([
|
|
78
|
+
{ prefix: "PL", name: "Manage Plugins", description: "A command" },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("caps the inventory at the server limit", () => {
|
|
83
|
+
const inventory = buildAssistCommandInventory({
|
|
84
|
+
commands: Array.from({ length: 40 }, (_, index) => command({ prefix: `C${index}` })),
|
|
85
|
+
pluginCommands: [],
|
|
86
|
+
paneTemplates: [],
|
|
87
|
+
limit: 25,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(inventory).toHaveLength(25);
|
|
91
|
+
expect(inventory.at(-1)?.prefix).toBe("C24");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { AssistCommandDescriptor } from "../../../api-client";
|
|
2
|
+
import type { CommandDef, PaneTemplateDef } from "../../../types/plugin";
|
|
3
|
+
import type { Command } from "../commands/registry";
|
|
4
|
+
import { getPaneTemplateDisplayLabel } from "../pane-templates/items";
|
|
5
|
+
import {
|
|
6
|
+
getCommandShortcutArgKind,
|
|
7
|
+
getPaneShortcutArgKind,
|
|
8
|
+
getPluginCommandShortcutArgKind,
|
|
9
|
+
type RootShortcutArgKind,
|
|
10
|
+
} from "../routes/root/shortcuts";
|
|
11
|
+
|
|
12
|
+
/** Server cap on `/assist/command` inventories. */
|
|
13
|
+
const ASSIST_INVENTORY_LIMIT = 150;
|
|
14
|
+
|
|
15
|
+
interface AssistInventorySource {
|
|
16
|
+
commands: readonly Command[];
|
|
17
|
+
pluginCommands: readonly CommandDef[];
|
|
18
|
+
paneTemplates: readonly PaneTemplateDef[];
|
|
19
|
+
limit?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function describeArg(
|
|
23
|
+
kind: RootShortcutArgKind | null,
|
|
24
|
+
placeholder: string | undefined,
|
|
25
|
+
): AssistCommandDescriptor["arg"] {
|
|
26
|
+
if (!kind) return undefined;
|
|
27
|
+
const trimmed = placeholder?.trim();
|
|
28
|
+
return trimmed ? { kind, placeholder: trimmed } : { kind };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function describe(
|
|
32
|
+
prefix: string,
|
|
33
|
+
name: string,
|
|
34
|
+
description: string | undefined,
|
|
35
|
+
arg: AssistCommandDescriptor["arg"],
|
|
36
|
+
): AssistCommandDescriptor | null {
|
|
37
|
+
const normalizedPrefix = prefix.trim().toUpperCase();
|
|
38
|
+
const normalizedName = name.trim();
|
|
39
|
+
if (!normalizedPrefix || !normalizedName) return null;
|
|
40
|
+
const normalizedDescription = description?.trim();
|
|
41
|
+
return {
|
|
42
|
+
prefix: normalizedPrefix,
|
|
43
|
+
name: normalizedName,
|
|
44
|
+
...(normalizedDescription ? { description: normalizedDescription } : {}),
|
|
45
|
+
...(arg ? { arg } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Flattens the command bar's prefix language into the shape `/assist/command`
|
|
51
|
+
* expects. Sources are visited in the same order the shortcut parser resolves
|
|
52
|
+
* them (built-in commands, plugin commands, pane templates), so the entry kept
|
|
53
|
+
* for a duplicated prefix is the one the bar would actually run. Aliases are
|
|
54
|
+
* omitted: the assistant should teach one canonical prefix per command.
|
|
55
|
+
*/
|
|
56
|
+
export function buildAssistCommandInventory({
|
|
57
|
+
commands,
|
|
58
|
+
pluginCommands,
|
|
59
|
+
paneTemplates,
|
|
60
|
+
limit = ASSIST_INVENTORY_LIMIT,
|
|
61
|
+
}: AssistInventorySource): AssistCommandDescriptor[] {
|
|
62
|
+
const descriptors: Array<AssistCommandDescriptor | null> = [
|
|
63
|
+
...commands.map((command) => describe(
|
|
64
|
+
command.prefix,
|
|
65
|
+
command.label,
|
|
66
|
+
command.description,
|
|
67
|
+
describeArg(getCommandShortcutArgKind(command), command.argPlaceholder),
|
|
68
|
+
)),
|
|
69
|
+
...pluginCommands.map((command) => describe(
|
|
70
|
+
command.shortcut ?? "",
|
|
71
|
+
command.label,
|
|
72
|
+
command.description,
|
|
73
|
+
describeArg(getPluginCommandShortcutArgKind(command), command.shortcutArg?.placeholder),
|
|
74
|
+
)),
|
|
75
|
+
...paneTemplates.map((template) => describe(
|
|
76
|
+
template.shortcut?.prefix ?? "",
|
|
77
|
+
getPaneTemplateDisplayLabel(template),
|
|
78
|
+
template.description,
|
|
79
|
+
describeArg(getPaneShortcutArgKind(template), template.shortcut?.argPlaceholder),
|
|
80
|
+
)),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const seenPrefixes = new Set<string>();
|
|
84
|
+
const inventory: AssistCommandDescriptor[] = [];
|
|
85
|
+
for (const descriptor of descriptors) {
|
|
86
|
+
if (!descriptor || seenPrefixes.has(descriptor.prefix)) continue;
|
|
87
|
+
seenPrefixes.add(descriptor.prefix);
|
|
88
|
+
inventory.push(descriptor);
|
|
89
|
+
if (inventory.length >= limit) break;
|
|
90
|
+
}
|
|
91
|
+
return inventory;
|
|
92
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildAssistResultItems,
|
|
4
|
+
shouldAutoAskAssist,
|
|
5
|
+
shouldShowAssistRow,
|
|
6
|
+
type AssistRequestState,
|
|
7
|
+
} from "./model";
|
|
8
|
+
|
|
9
|
+
const handlers = {
|
|
10
|
+
onAsk: () => {},
|
|
11
|
+
onSignUp: () => {},
|
|
12
|
+
onRunCandidate: () => {},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function labels(
|
|
16
|
+
state: AssistRequestState,
|
|
17
|
+
options?: { query?: string; enabled?: boolean; auto?: boolean },
|
|
18
|
+
): string[] {
|
|
19
|
+
return buildAssistResultItems({
|
|
20
|
+
...handlers,
|
|
21
|
+
query: options?.query ?? "chart nvidia vs amd",
|
|
22
|
+
enabled: options?.enabled ?? true,
|
|
23
|
+
auto: options?.auto ?? true,
|
|
24
|
+
state,
|
|
25
|
+
}).map((item) => item.label);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("shouldAutoAskAssist", () => {
|
|
29
|
+
test("stays out of the way while the user speaks the command language", () => {
|
|
30
|
+
expect(shouldAutoAskAssist({ query: "chart nvidia vs amd", hasShortcutIntent: false })).toBe(true);
|
|
31
|
+
expect(shouldAutoAskAssist({ query: "nvda", hasShortcutIntent: false })).toBe(true);
|
|
32
|
+
// A recognized prefix — with or without an argument — is not a question.
|
|
33
|
+
expect(shouldAutoAskAssist({ query: "T NVDA", hasShortcutIntent: true })).toBe(false);
|
|
34
|
+
expect(shouldAutoAskAssist({ query: "gg", hasShortcutIntent: false })).toBe(false);
|
|
35
|
+
expect(shouldAutoAskAssist({ query: " ", hasShortcutIntent: false })).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("shouldShowAssistRow", () => {
|
|
40
|
+
test("offers the AI on natural language, or on any dead end", () => {
|
|
41
|
+
expect(shouldShowAssistRow({ query: "chart nvidia vs amd", resultCount: 4 })).toBe(true);
|
|
42
|
+
expect(shouldShowAssistRow({ query: "zzzz", resultCount: 0 })).toBe(true);
|
|
43
|
+
expect(shouldShowAssistRow({ query: "chart", resultCount: 3 })).toBe(false);
|
|
44
|
+
expect(shouldShowAssistRow({ query: " ", resultCount: 0 })).toBe(false);
|
|
45
|
+
// Trailing whitespace alone is not a second word.
|
|
46
|
+
expect(shouldShowAssistRow({ query: "chart ", resultCount: 3 })).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("buildAssistResultItems", () => {
|
|
51
|
+
test("routes signed-out users to sign up instead of the request", () => {
|
|
52
|
+
expect(labels({ status: "idle" }, { enabled: false })).toEqual(["Ask AI — sign up to enable"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("renders each state of the request", () => {
|
|
56
|
+
expect(labels({ status: "idle" })).toEqual(["Thinking…"]);
|
|
57
|
+
expect(labels({ status: "idle" }, { auto: false })).toEqual([]);
|
|
58
|
+
expect(labels({ status: "loading", query: "chart nvidia vs amd", source: "auto" }))
|
|
59
|
+
.toEqual(["Thinking…"]);
|
|
60
|
+
expect(labels({ status: "answered", query: "chart nvidia vs amd", source: "auto", candidates: [] }))
|
|
61
|
+
.toEqual(["No command found — try HELP"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("keeps failures silent unless the user asked for the answer", () => {
|
|
65
|
+
const failure = { status: "error", query: "chart nvidia vs amd", kind: "rate-limited" } as const;
|
|
66
|
+
expect(labels({ ...failure, source: "auto" })).toEqual([]);
|
|
67
|
+
expect(labels({ ...failure, source: "explicit" }))
|
|
68
|
+
.toEqual(["Rate limited — try again in a minute"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("shows candidates input-first and runs the exact command-bar text", () => {
|
|
72
|
+
const runs: Array<[string, string | undefined]> = [];
|
|
73
|
+
const items = buildAssistResultItems({
|
|
74
|
+
...handlers,
|
|
75
|
+
onRunCandidate: (input, prefix) => runs.push([input, prefix]),
|
|
76
|
+
query: "chart nvidia vs amd",
|
|
77
|
+
enabled: true,
|
|
78
|
+
auto: true,
|
|
79
|
+
state: {
|
|
80
|
+
status: "answered",
|
|
81
|
+
query: "chart nvidia vs amd",
|
|
82
|
+
source: "auto",
|
|
83
|
+
candidates: [{ input: "G NVDA AMD", title: "Chart NVDA vs AMD", prefix: "G", confidence: 0.9 }],
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(items[0]?.label).toBe("G NVDA AMD — Chart NVDA vs AMD");
|
|
88
|
+
// The marker rides the trailing column instead of shifting the label.
|
|
89
|
+
expect(items[0]?.right).toBe("✦");
|
|
90
|
+
expect(items[0]?.accent).toBe(true);
|
|
91
|
+
items[0]?.action();
|
|
92
|
+
expect(runs).toEqual([["G NVDA AMD", "G"]]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("ignores an answer that belongs to an earlier query", () => {
|
|
96
|
+
const state: AssistRequestState = {
|
|
97
|
+
status: "answered",
|
|
98
|
+
query: "chart nvidia",
|
|
99
|
+
source: "auto",
|
|
100
|
+
candidates: [{ input: "G NVDA", title: "Chart NVDA", prefix: "G", confidence: 0.9 }],
|
|
101
|
+
};
|
|
102
|
+
expect(labels(state, { query: "chart nvidia vs amd" })).toEqual(["Thinking…"]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { AssistCommandCandidate } from "../../../api-client";
|
|
2
|
+
import { t } from "../../../i18n";
|
|
3
|
+
import type { ResultItem } from "../list/model";
|
|
4
|
+
|
|
5
|
+
/** Marker shown on every AI-resolved row, matching the AI plugin's iconography. */
|
|
6
|
+
const ASSIST_GLYPH = "✦";
|
|
7
|
+
|
|
8
|
+
/** Category heading the assist rows group under; sorted first by the view model. */
|
|
9
|
+
const ASSIST_CATEGORY = "Ask AI";
|
|
10
|
+
|
|
11
|
+
/** Shorter queries are almost always a half-typed prefix, not a question. */
|
|
12
|
+
const ASSIST_AUTO_MIN_QUERY_LENGTH = 3;
|
|
13
|
+
|
|
14
|
+
export type AssistErrorKind = "unavailable" | "rate-limited" | "failed";
|
|
15
|
+
|
|
16
|
+
/** Whether the request was started by the debounce or by the user hitting Enter. */
|
|
17
|
+
export type AssistRequestSource = "auto" | "explicit";
|
|
18
|
+
|
|
19
|
+
export type AssistRequestState =
|
|
20
|
+
| { status: "idle" }
|
|
21
|
+
| { status: "loading"; query: string; source: AssistRequestSource }
|
|
22
|
+
| { status: "answered"; query: string; source: AssistRequestSource; candidates: AssistCommandCandidate[] }
|
|
23
|
+
| { status: "error"; query: string; source: AssistRequestSource; kind: AssistErrorKind };
|
|
24
|
+
|
|
25
|
+
export interface AssistRowHandlers {
|
|
26
|
+
/** Signed in with a verified email, i.e. `/assist/command` will answer. */
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
/** The query qualifies for a background ask, so the section is expected. */
|
|
29
|
+
auto: boolean;
|
|
30
|
+
state: AssistRequestState;
|
|
31
|
+
onAsk: () => void;
|
|
32
|
+
onSignUp: () => void;
|
|
33
|
+
onRunCandidate: (input: string, prefix?: string) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether the query should be answered by the AI without being asked for it.
|
|
38
|
+
* Anything the prefix parser recognizes is the user speaking the command
|
|
39
|
+
* language, and very short input is a prefix mid-typing rather than a question.
|
|
40
|
+
*/
|
|
41
|
+
export function shouldAutoAskAssist({
|
|
42
|
+
query,
|
|
43
|
+
hasShortcutIntent,
|
|
44
|
+
}: {
|
|
45
|
+
query: string;
|
|
46
|
+
hasShortcutIntent: boolean;
|
|
47
|
+
}): boolean {
|
|
48
|
+
if (hasShortcutIntent) return false;
|
|
49
|
+
return query.trim().length >= ASSIST_AUTO_MIN_QUERY_LENGTH;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a signed-out user is offered the sign-up row. Multi-word input reads
|
|
54
|
+
* as natural language rather than a prefix, and a query with no local matches
|
|
55
|
+
* is a dead end either way.
|
|
56
|
+
*/
|
|
57
|
+
export function shouldShowAssistRow({
|
|
58
|
+
query,
|
|
59
|
+
resultCount,
|
|
60
|
+
}: {
|
|
61
|
+
query: string;
|
|
62
|
+
resultCount: number;
|
|
63
|
+
}): boolean {
|
|
64
|
+
const trimmed = query.trim();
|
|
65
|
+
if (!trimmed) return false;
|
|
66
|
+
if (/\s/.test(trimmed)) return true;
|
|
67
|
+
return resultCount === 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assistErrorLabel(kind: AssistErrorKind): string {
|
|
71
|
+
if (kind === "unavailable") return t("AI assist unavailable");
|
|
72
|
+
if (kind === "rate-limited") return t("Rate limited — try again in a minute");
|
|
73
|
+
return t("AI assist failed — try again");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assistRow(options: {
|
|
77
|
+
id: string;
|
|
78
|
+
label: string;
|
|
79
|
+
kind: ResultItem["kind"];
|
|
80
|
+
action?: () => void;
|
|
81
|
+
defaultSelectable?: boolean;
|
|
82
|
+
}): ResultItem {
|
|
83
|
+
return {
|
|
84
|
+
id: options.id,
|
|
85
|
+
label: options.label,
|
|
86
|
+
detail: "",
|
|
87
|
+
category: ASSIST_CATEGORY,
|
|
88
|
+
kind: options.kind,
|
|
89
|
+
// Right-aligned like a shortcut, so the glyph never crowds the answer.
|
|
90
|
+
right: ASSIST_GLYPH,
|
|
91
|
+
accent: true,
|
|
92
|
+
disabled: !options.action,
|
|
93
|
+
defaultSelectable: options.defaultSelectable,
|
|
94
|
+
action: options.action ?? (() => {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Rows for the assist section. They always land in their own category, which
|
|
100
|
+
* sorts to the top of the list, so the answer to what the user typed leads the
|
|
101
|
+
* results and plain Enter runs the AI's best guess.
|
|
102
|
+
*/
|
|
103
|
+
export function buildAssistResultItems({
|
|
104
|
+
query,
|
|
105
|
+
enabled,
|
|
106
|
+
auto,
|
|
107
|
+
state,
|
|
108
|
+
onAsk,
|
|
109
|
+
onSignUp,
|
|
110
|
+
onRunCandidate,
|
|
111
|
+
}: AssistRowHandlers & { query: string }): ResultItem[] {
|
|
112
|
+
const trimmed = query.trim();
|
|
113
|
+
if (!trimmed) return [];
|
|
114
|
+
|
|
115
|
+
if (!enabled) {
|
|
116
|
+
// An offer, not an answer: it leads the list without ever taking the Enter
|
|
117
|
+
// that belongs to the local match the user is looking at.
|
|
118
|
+
return [assistRow({
|
|
119
|
+
id: "assist:sign-up",
|
|
120
|
+
label: t("Ask AI — sign up to enable"),
|
|
121
|
+
kind: "action",
|
|
122
|
+
action: onSignUp,
|
|
123
|
+
defaultSelectable: false,
|
|
124
|
+
})];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A response only describes the query it was asked about.
|
|
128
|
+
const active = state.status !== "idle" && state.query === trimmed ? state : null;
|
|
129
|
+
|
|
130
|
+
if (!active) {
|
|
131
|
+
if (!auto) return [];
|
|
132
|
+
// Still inside the debounce window; activating the row skips the wait.
|
|
133
|
+
return [assistRow({ id: "assist:pending", label: t("Thinking…"), kind: "info", action: onAsk })];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (active.status === "loading") {
|
|
137
|
+
// Selected by default while it leads the list, so Enter has to mean
|
|
138
|
+
// something: it claims the answer that is already on the wire.
|
|
139
|
+
return [assistRow({ id: "assist:loading", label: t("Thinking…"), kind: "info", action: onAsk })];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (active.status === "error") {
|
|
143
|
+
// A background failure is not worth a row the user never asked for.
|
|
144
|
+
if (active.source === "auto") return [];
|
|
145
|
+
return [assistRow({
|
|
146
|
+
id: "assist:error",
|
|
147
|
+
label: assistErrorLabel(active.kind),
|
|
148
|
+
kind: "info",
|
|
149
|
+
action: onAsk,
|
|
150
|
+
defaultSelectable: false,
|
|
151
|
+
})];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (active.candidates.length === 0) {
|
|
155
|
+
return [assistRow({
|
|
156
|
+
id: "assist:no-command",
|
|
157
|
+
label: t("No command found — try HELP"),
|
|
158
|
+
kind: "info",
|
|
159
|
+
})];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Input first: the row doubles as a lesson in the prefix language.
|
|
163
|
+
return active.candidates.map((candidate, index) => assistRow({
|
|
164
|
+
id: `assist:candidate:${index}:${candidate.input}`,
|
|
165
|
+
label: `${candidate.input} — ${candidate.title}`,
|
|
166
|
+
kind: "action",
|
|
167
|
+
action: () => onRunCandidate(candidate.input, candidate.prefix),
|
|
168
|
+
}));
|
|
169
|
+
}
|