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.
Files changed (152) 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/layout/detached-pane-shell.tsx +40 -2
  52. package/src/components/layout/floating-pane.tsx +4 -1
  53. package/src/components/layout/pane/header.tsx +64 -6
  54. package/src/components/layout/pane/index.tsx +4 -1
  55. package/src/components/layout/shell/index.tsx +23 -0
  56. package/src/components/layout/shell/pane/layers.tsx +5 -0
  57. package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
  58. package/src/components/onboarding/account-step/form-panel.tsx +157 -0
  59. package/src/components/onboarding/account-step/index.tsx +58 -0
  60. package/src/components/onboarding/account-step/model.test.ts +67 -0
  61. package/src/components/onboarding/account-step/model.ts +126 -0
  62. package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
  63. package/src/components/onboarding/onboarding-steps.tsx +91 -55
  64. package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
  65. package/src/components/onboarding/onboarding-wizard.tsx +93 -9
  66. package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
  67. package/src/components/onboarding/wizard-account.ts +235 -0
  68. package/src/components/onboarding/wizard-keyboard.ts +67 -1
  69. package/src/components/onboarding/wizard-model.ts +2 -2
  70. package/src/i18n/es.ts +1093 -0
  71. package/src/i18n/index.test.ts +5 -2
  72. package/src/i18n/index.ts +3 -0
  73. package/src/i18n/ja.ts +64 -3
  74. package/src/i18n/ko.ts +64 -3
  75. package/src/i18n/languages.test.ts +4 -1
  76. package/src/i18n/languages.ts +12 -1
  77. package/src/i18n/zh-cn.ts +64 -3
  78. package/src/i18n/zh-tw.ts +64 -3
  79. package/src/market-data/coordinator/quotes.ts +63 -1
  80. package/src/market-data/coordinator/subscriptions.test.ts +33 -0
  81. package/src/market-data/quotes/cadence.ts +2 -0
  82. package/src/plugins/builtin/account-management/footer.ts +9 -3
  83. package/src/plugins/builtin/account-management/model.ts +53 -1
  84. package/src/plugins/builtin/account-management/pane.tsx +107 -24
  85. package/src/plugins/builtin/ai/index.tsx +7 -2
  86. package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
  87. package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
  88. package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
  89. package/src/plugins/builtin/chart-composer/index.tsx +10 -2
  90. package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
  91. package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
  92. package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
  93. package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
  94. package/src/plugins/builtin/cloud/plugin.tsx +9 -1
  95. package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
  96. package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
  97. package/src/plugins/builtin/help/index.tsx +38 -0
  98. package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
  99. package/src/plugins/builtin/options/footer.test.ts +20 -124
  100. package/src/plugins/builtin/options/footer.ts +32 -90
  101. package/src/plugins/builtin/options/index.tsx +6 -0
  102. package/src/plugins/builtin/options/view.tsx +3 -0
  103. package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
  104. package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
  105. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
  106. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
  107. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
  108. package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
  109. package/src/plugins/builtin/shared/live-streaming.ts +60 -0
  110. package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
  111. package/src/plugins/builtin/shared/plan-access.ts +109 -0
  112. package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
  113. package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
  114. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
  115. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
  116. package/src/plugins/registry/index.test.ts +51 -0
  117. package/src/plugins/registry/index.ts +12 -0
  118. package/src/plugins/registry/pane-settings.ts +29 -0
  119. package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
  120. package/src/renderers/electrobun/view/host/box.tsx +3 -0
  121. package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
  122. package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
  123. package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
  124. package/src/renderers/electrobun/view/host/input.tsx +16 -1
  125. package/src/renderers/electrobun/view/host/mouse.ts +1 -0
  126. package/src/renderers/electrobun/view/host/style.ts +1 -1
  127. package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
  128. package/src/renderers/electrobun/view/main.tsx +2 -0
  129. package/src/renderers/opentui/chart-surface.tsx +2 -0
  130. package/src/renderers/opentui/host.tsx +3 -0
  131. package/src/sources/gloomberb-cloud/index.test.ts +8 -0
  132. package/src/sources/gloomberb-cloud/index.ts +4 -4
  133. package/src/sources/provider-router/history.test.ts +3 -3
  134. package/src/sources/provider-router/history.ts +15 -4
  135. package/src/sources/yahoo-finance/history.test.ts +47 -0
  136. package/src/sources/yahoo-finance/history.ts +5 -1
  137. package/src/state/hooks/quote-streaming.test.tsx +124 -1
  138. package/src/state/hooks/quote-streaming.ts +90 -7
  139. package/src/theme/colors.ts +20 -1
  140. package/src/theme/themes.test.ts +3 -0
  141. package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
  142. package/src/time-series/resolution.test.ts +51 -0
  143. package/src/time-series/resolution.ts +41 -0
  144. package/src/time-series/resolve.test.ts +207 -1
  145. package/src/time-series/resolve.ts +196 -29
  146. package/src/time-series/spec-studies.test.ts +4 -0
  147. package/src/time-series/spec.ts +15 -1
  148. package/src/time-series/types.ts +7 -0
  149. package/src/time-series/use-chart-resolution.test.tsx +37 -0
  150. package/src/time-series/use-chart-resolution.ts +17 -1
  151. package/src/types/plugin.ts +9 -0
  152. 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
+ }