From 63c3e037f853a9f09d1e91f66a7e1fc8d4b71909 Mon Sep 17 00:00:00 2001 From: "INFRAGISTICS\\IPetrov" Date: Tue, 1 Sep 2026 11:26:16 +0300 Subject: [PATCH 1/6] fix(menu): platform page size increased to fit WebComponents option --- packages/core/prompt/BasePromptSession.ts | 2 ++ packages/core/prompt/InquirerWrapper.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/core/prompt/BasePromptSession.ts b/packages/core/prompt/BasePromptSession.ts index 68af022be..37a2585bc 100644 --- a/packages/core/prompt/BasePromptSession.ts +++ b/packages/core/prompt/BasePromptSession.ts @@ -50,6 +50,7 @@ export abstract class BasePromptSession { name: "framework", message: "Choose framework:", choices: this.getFrameworkNames(), + pageSize: 10, default: "Angular" }); @@ -708,6 +709,7 @@ type SelectOptions = Omit & { type: "select"; // TODO: Expand type: choices: any[]; + pageSize?: number; } type CheckboxOptions = Omit & { diff --git a/packages/core/prompt/InquirerWrapper.ts b/packages/core/prompt/InquirerWrapper.ts index d96c59bd8..a62d8ef22 100644 --- a/packages/core/prompt/InquirerWrapper.ts +++ b/packages/core/prompt/InquirerWrapper.ts @@ -19,6 +19,7 @@ type InputConfig = { type InputChoicesConfig = Omit & { choices: (string | Separator)[] | ({ value: string; name?: string; checked?: boolean } | Separator)[]; + pageSize?: number; }; export class InquirerWrapper { From 5ad5861cbbf2fe71f150c0823297007391e8fe4b Mon Sep 17 00:00:00 2001 From: "INFRAGISTICS\\IPetrov" Date: Tue, 1 Sep 2026 14:27:17 +0300 Subject: [PATCH 2/6] feat(menu): custom multiselect implemented. Active deselection upon selecting 'none' --- packages/cli/lib/commands/ai-config.ts | 10 +- packages/core/package.json | 1 + packages/core/prompt/ExclusiveCheckbox.ts | 231 ++++++++++++++++++++++ packages/core/prompt/InquirerWrapper.ts | 15 ++ spec/unit/ai-config-spec.ts | 29 +++ 5 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 packages/core/prompt/ExclusiveCheckbox.ts diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index 7e6d7335b..e7e76c129 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -154,10 +154,11 @@ const AI_ASSISTANT_CHECKBOX_CHOICES = [ async function promptForAgents(): Promise { let selected: AIAgentOption[] = AI_AGENT_CHECKBOX_DEFAULTS; if (Util.canPrompt()) { - const result = await InquirerWrapper.checkbox({ + const result = await InquirerWrapper.exclusiveCheckbox({ message: "Which AI agents do you want to generate skills and instructions for?", required: true, - choices: AI_AGENT_CHECKBOX_CHOICES + choices: AI_AGENT_CHECKBOX_CHOICES, + exclusiveValues: ["none"] }); selected = result as AIAgentOption[]; } @@ -167,10 +168,11 @@ async function promptForAgents(): Promise { async function promptForAssistant(): Promise { let selected: AIAssistantOption[] = AI_ASSISTANT_CHECKBOX_DEFAULTS; if (Util.canPrompt()) { - const result = await InquirerWrapper.checkbox({ + const result = await InquirerWrapper.exclusiveCheckbox({ message: "Which coding assistants should MCP servers be configured for?", required: true, - choices: AI_ASSISTANT_CHECKBOX_CHOICES + choices: AI_ASSISTANT_CHECKBOX_CHOICES, + exclusiveValues: ["none"] }); selected = result as AIAssistantOption[]; } diff --git a/packages/core/package.json b/packages/core/package.json index dbe8b2fc7..263310d89 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,6 +12,7 @@ "author": "Infragistics", "license": "MIT", "dependencies": { + "@inquirer/core": "^10.3.0", "@inquirer/prompts": "^7.9.0", "chalk": "^2.3.2", "glob": "^11.0.0", diff --git a/packages/core/prompt/ExclusiveCheckbox.ts b/packages/core/prompt/ExclusiveCheckbox.ts new file mode 100644 index 000000000..8860e767b --- /dev/null +++ b/packages/core/prompt/ExclusiveCheckbox.ts @@ -0,0 +1,231 @@ +import { + createPrompt, + isDownKey, + isEnterKey, + isNumberKey, + isSpaceKey, + isUpKey, + makeTheme, + useKeypress, + usePrefix, + useState, + type Status, +} from "@inquirer/core"; +import { Separator } from "@inquirer/prompts"; +import { styleText } from "node:util"; +import type { PartialDeep } from "@inquirer/type"; +import type { Theme } from "@inquirer/core"; + +export type ExclusiveCheckboxChoice = { + value: Value; + name?: string; + checked?: boolean; + disabled?: boolean | string; +}; + +type NormalizedChoice = { + value: Value; + name: string; + checked: boolean; + disabled: boolean | string; +}; + +type ExclusiveCheckboxConfig = { + message: string; + choices: ReadonlyArray | Separator>; + required?: boolean; + exclusiveValues?: readonly Value[]; + pageSize?: number; + loop?: boolean; + theme?: PartialDeep; +}; + +function normalizeChoice(choice: Value | ExclusiveCheckboxChoice | Separator): NormalizedChoice | Separator { + if (Separator.isSeparator(choice)) { + return choice; + } + + if (typeof choice === "object" && choice !== null && "value" in choice) { + const objectChoice = choice as ExclusiveCheckboxChoice; + const name = objectChoice.name ?? String(objectChoice.value); + return { + value: objectChoice.value, + name, + checked: !!objectChoice.checked, + disabled: objectChoice.disabled ?? false + }; + } + + const name = String(choice); + return { + value: choice as Value, + name, + checked: false, + disabled: false + }; +} + +function isSelectable(choice: NormalizedChoice | Separator): choice is NormalizedChoice { + return !Separator.isSeparator(choice) && !choice.disabled; +} + +function isChecked(choice: NormalizedChoice | Separator): choice is NormalizedChoice { + return !Separator.isSeparator(choice) && choice.checked; +} + +function toggleExclusiveChoice( + items: Array | Separator>, + index: number, + exclusiveValues: readonly Value[], +): Array | Separator> { + const choice = items[index]; + if (!isSelectable(choice)) { + return items; + } + + const toggledOn = !choice.checked; + const isExclusive = exclusiveValues.some(value => Object.is(value, choice.value)); + + return items.map((item, itemIndex) => { + if (Separator.isSeparator(item)) { + return item; + } + + if (itemIndex === index) { + return { ...item, checked: toggledOn }; + } + + if (toggledOn && isExclusive) { + return item.disabled ? item : { ...item, checked: false }; + } + + if (toggledOn && exclusiveValues.some(value => Object.is(value, item.value))) { + return item.disabled ? item : { ...item, checked: false }; + } + + return item; + }); +} + +function moveActiveIndex( + items: Array | Separator>, + active: number, + direction: 1 | -1, + loop: boolean, +): number { + let next = active; + for (let i = 0; i < items.length; i++) { + next += direction; + if (next < 0) { + next = loop ? items.length - 1 : 0; + } + if (next >= items.length) { + next = loop ? 0 : items.length - 1; + } + if (isSelectable(items[next])) { + return next; + } + } + return active; +} + +export function applyExclusiveToggle( + items: Array | Separator>, + index: number, + exclusiveValues: readonly Value[], +): Array | Separator> { + return toggleExclusiveChoice(items, index, exclusiveValues); +} + +export const exclusiveCheckbox = createPrompt>((config, done) => { + const theme = makeTheme(config.theme); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(); + const [items, setItems] = useState | Separator>>( + config.choices.map(normalizeChoice), + ); + const firstSelectable = items.findIndex(isSelectable); + const [active, setActive] = useState(firstSelectable >= 0 ? firstSelectable : 0); + const prefix = usePrefix({ status, theme }); + const exclusiveValues = config.exclusiveValues ?? []; + + useKeypress((key) => { + if (isUpKey(key)) { + setActive(moveActiveIndex(items, active, -1, config.loop ?? true)); + return; + } + + if (isDownKey(key)) { + setActive(moveActiveIndex(items, active, 1, config.loop ?? true)); + return; + } + + if (isSpaceKey(key)) { + setItems(toggleExclusiveChoice(items, active, exclusiveValues)); + setError(undefined); + return; + } + + if (isNumberKey(key)) { + const selectedIndex = Number(key.name) - 1; + let selectableIndex = -1; + const position = items.findIndex((item) => { + if (Separator.isSeparator(item)) { + return false; + } + if (item.disabled) { + return false; + } + selectableIndex++; + return selectableIndex === selectedIndex; + }); + if (position >= 0) { + setActive(position); + setItems(toggleExclusiveChoice(items, position, exclusiveValues)); + setError(undefined); + } + return; + } + + if (isEnterKey(key)) { + const selected = items.filter(isChecked); + if (config.required && selected.length === 0) { + setError("Select at least one option."); + return; + } + setStatus("done"); + done(selected.map(choice => choice.value)); + } + }); + + const renderItem = (item: NormalizedChoice | Separator, index: number, isActive: boolean) => { + if (Separator.isSeparator(item)) { + return ` ${item.separator}`; + } + + const cursor = isActive ? ">" : " "; + const checkbox = item.checked ? "[x]" : "[ ]"; + const label = item.checked ? (item.name) : item.name; + const line = `${cursor} ${checkbox} ${label}`; + return isActive ? theme.style.highlight(line) : line; + }; + + if (status === "done") { + const answer = items.filter(isChecked).map(choice => choice.name).join(", "); + return `${prefix} ${config.message}\n${styleText("cyan", answer)}`; + } + + const renderedItems = items + .map((item, index) => renderItem(item, index, index === active)) + .join("\n"); + + const helpLine = styleText("dim", "Use ↑↓ to navigate, space to toggle, enter to submit"); + const lines = [ + `${prefix} ${config.message}`, + renderedItems, + error ? styleText("red", error) : undefined, + helpLine + ].filter(Boolean); + + return lines.join("\n"); +}); \ No newline at end of file diff --git a/packages/core/prompt/InquirerWrapper.ts b/packages/core/prompt/InquirerWrapper.ts index a62d8ef22..fd6993976 100644 --- a/packages/core/prompt/InquirerWrapper.ts +++ b/packages/core/prompt/InquirerWrapper.ts @@ -1,5 +1,6 @@ import { checkbox, confirm, input, select, Separator } from '@inquirer/prompts'; import { Context } from '@inquirer/type'; +import { exclusiveCheckbox, type ExclusiveCheckboxChoice } from "./ExclusiveCheckbox"; // ref - node_modules\@inquirer\input\dist\cjs\types\index.d.ts - bc for some reason this is not publicly exported type InputConfig = { @@ -22,6 +23,16 @@ type InputChoicesConfig = Omit & { pageSize?: number; }; +type ExclusiveCheckboxConfig = { + message: string; + choices: ReadonlyArray | Separator>; + required?: boolean; + exclusiveValues?: readonly string[]; + pageSize?: number; + loop?: boolean; + theme?: unknown; +}; + export class InquirerWrapper { private constructor() { } @@ -37,6 +48,10 @@ export class InquirerWrapper { return checkbox(message, context); } + public static async exclusiveCheckbox(message: ExclusiveCheckboxConfig, context?: Context): Promise { + return exclusiveCheckbox(message, context); + } + public static async confirm(message: { message: string; default?: boolean }, context?: Context): Promise { return confirm(message, context); } diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index 857dcc2cd..4530f5289 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -4,6 +4,7 @@ import * as coreDetect from "../../packages/core/util/detect-framework"; import { configureMCP, configureSkills, configureInstructions } from "../../packages/cli/lib/commands/ai-config"; import * as aiConfig from "../../packages/cli/lib/commands/ai-config"; import { addMcpServers } from "../../packages/core/util/mcp-config"; +import { applyExclusiveToggle } from "../../packages/core/prompt/ExclusiveCheckbox"; const IGNITEUI_SERVER_KEY = "igniteui-cli"; const IGNITEUI_THEMING_SERVER_KEY = "igniteui-theming"; @@ -155,6 +156,34 @@ describe("Unit - ai-config command", () => { }); }); + describe("exclusive checkbox behavior", () => { + it("clears other selections when None is selected", () => { + const items = [ + { value: "none", name: "None", checked: false, disabled: false }, + { value: "generic", name: "Generic", checked: true, disabled: false }, + { value: "claude", name: "Claude", checked: true, disabled: false } + ]; + + const result = applyExclusiveToggle(items, 0, ["none"]); + + expect(result[0]).toEqual(jasmine.objectContaining({ checked: true })); + expect(result[1]).toEqual(jasmine.objectContaining({ checked: false })); + expect(result[2]).toEqual(jasmine.objectContaining({ checked: false })); + }); + + it("clears None when another selection is made", () => { + const items = [ + { value: "none", name: "None", checked: true, disabled: false }, + { value: "generic", name: "Generic", checked: false, disabled: false } + ]; + + const result = applyExclusiveToggle(items, 1, ["none"]); + + expect(result[0]).toEqual(jasmine.objectContaining({ checked: false })); + expect(result[1]).toEqual(jasmine.objectContaining({ checked: true })); + }); + }); + describe("configureSkills", () => { const angularSkillsDir = "node_modules/igniteui-angular/skills"; From 05c605d5bd838a222c4e766276fe4a0dbf33218d Mon Sep 17 00:00:00 2001 From: "INFRAGISTICS\\IPetrov" Date: Tue, 1 Sep 2026 15:09:35 +0300 Subject: [PATCH 3/6] fix(tests): exclusive checkbox --- spec/acceptance/new-spec.ts | 2 +- spec/unit/ai-config-spec.ts | 38 ++++++++++++++++++------------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/spec/acceptance/new-spec.ts b/spec/acceptance/new-spec.ts index 1fae2c71b..0529e2f78 100644 --- a/spec/acceptance/new-spec.ts +++ b/spec/acceptance/new-spec.ts @@ -11,7 +11,7 @@ describe("New command", () => { spyOn(console, "error"); spyOn(GoogleAnalytics, "post"); spyOn(PackageManager, "installPackages"); - spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["none"])); + spyOn(InquirerWrapper, "exclusiveCheckbox").and.returnValue(Promise.resolve(["none"])); process.chdir("./output"); }); diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index 4530f5289..ab7000bb2 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -633,14 +633,14 @@ describe("Unit - ai-config command", () => { it("prompts for agents when --agent is not provided", async () => { App.container.set(FS_TOKEN, createMockFs()); spyOn(Util, "canPrompt").and.returnValue(true); - spyOn(InquirerWrapper, "checkbox").and.returnValues( + spyOn(InquirerWrapper, "exclusiveCheckbox").and.returnValues( Promise.resolve(["claude"]), Promise.resolve(["vscode"]) ); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "angular" }); - expect(InquirerWrapper.checkbox).toHaveBeenCalledWith(jasmine.objectContaining({ + expect(InquirerWrapper.exclusiveCheckbox).toHaveBeenCalledWith(jasmine.objectContaining({ message: "Which AI agents do you want to generate skills and instructions for?", required: true })); @@ -651,18 +651,18 @@ describe("Unit - ai-config command", () => { it("uses defaults without prompting when canPrompt returns false", async () => { App.container.set(FS_TOKEN, createMockFs()); spyOn(Util, "canPrompt").and.returnValue(false); - spyOn(InquirerWrapper, "checkbox"); + spyOn(InquirerWrapper, "exclusiveCheckbox"); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "angular" }); - expect(InquirerWrapper.checkbox).not.toHaveBeenCalled(); + expect(InquirerWrapper.exclusiveCheckbox).not.toHaveBeenCalled(); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: generic, claude; assistant: generic", cd1: "angular" })); }); it("logs skipping and does not post analytics when none is selected", async () => { App.container.set(FS_TOKEN, createMockFs()); spyOn(Util, "canPrompt").and.returnValue(true); - spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["none"])); + spyOn(InquirerWrapper, "exclusiveCheckbox").and.returnValue(Promise.resolve(["none"])); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "angular" }); @@ -675,7 +675,7 @@ describe("Unit - ai-config command", () => { const mockFs = createMockFs(); App.container.set(FS_TOKEN, mockFs); spyOn(Util, "canPrompt").and.returnValue(true); - spyOn(InquirerWrapper, "checkbox").and.returnValues( + spyOn(InquirerWrapper, "exclusiveCheckbox").and.returnValues( Promise.resolve(["none"]), Promise.resolve(["vscode"]) ); @@ -687,7 +687,7 @@ describe("Unit - ai-config command", () => { expect(config.servers).toBeDefined(); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "screenview", cd: "Ai Config" })); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: none; assistant: vscode", cd1: "angular" })); - expect(InquirerWrapper.checkbox).toHaveBeenCalledTimes(2); + expect(InquirerWrapper.exclusiveCheckbox).toHaveBeenCalledTimes(2); expect( (Util.log as jasmine.Spy).calls.allArgs() .filter(([msg]) => String(msg).includes("Skipping")) @@ -698,14 +698,14 @@ describe("Unit - ai-config command", () => { it("configures multiple agents when selected interactively", async () => { App.container.set(FS_TOKEN, createMockFs()); spyOn(Util, "canPrompt").and.returnValue(true); - spyOn(InquirerWrapper, "checkbox").and.returnValues( + spyOn(InquirerWrapper, "exclusiveCheckbox").and.returnValues( Promise.resolve(["claude", "cursor"]), Promise.resolve(["vscode"]) ); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "angular" }); - expect(InquirerWrapper.checkbox).toHaveBeenCalledWith(jasmine.objectContaining({ + expect(InquirerWrapper.exclusiveCheckbox).toHaveBeenCalledWith(jasmine.objectContaining({ message: "Which AI agents do you want to generate skills and instructions for?" })); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: claude, cursor; assistant: vscode", cd1: "angular" })); @@ -714,11 +714,11 @@ describe("Unit - ai-config command", () => { it("skips prompt when --agent is provided", async () => { App.container.set(FS_TOKEN, createMockFs()); spyOn(Util, "canPrompt").and.returnValue(true); - spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["vscode"])); + spyOn(InquirerWrapper, "exclusiveCheckbox").and.returnValue(Promise.resolve(["vscode"])); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", agents: ["cursor"], framework: "angular" }); - expect(InquirerWrapper.checkbox).not.toHaveBeenCalledWith(jasmine.objectContaining({ + expect(InquirerWrapper.exclusiveCheckbox).not.toHaveBeenCalledWith(jasmine.objectContaining({ message: "Which AI agents do you want to generate skills and instructions for?" })); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ ea: "agent: cursor; assistant: vscode", cd1: "angular" })); @@ -727,24 +727,24 @@ describe("Unit - ai-config command", () => { it("skips assistant prompt when --assistant is provided", async () => { App.container.set(FS_TOKEN, createMockFs()); spyOn(Util, "canPrompt").and.returnValue(true); - spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["claude"])); + spyOn(InquirerWrapper, "exclusiveCheckbox").and.returnValue(Promise.resolve(["claude"])); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", assistants: ["cursor"], framework: "angular" }); - expect(InquirerWrapper.checkbox).toHaveBeenCalledTimes(1); + expect(InquirerWrapper.exclusiveCheckbox).toHaveBeenCalledTimes(1); }); it("prompts for assistant with correct message", async () => { App.container.set(FS_TOKEN, createMockFs()); spyOn(Util, "canPrompt").and.returnValue(true); - spyOn(InquirerWrapper, "checkbox").and.returnValues( + spyOn(InquirerWrapper, "exclusiveCheckbox").and.returnValues( Promise.resolve(["claude"]), Promise.resolve(["vscode"]) ); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "angular" }); - expect(InquirerWrapper.checkbox).toHaveBeenCalledWith(jasmine.objectContaining({ + expect(InquirerWrapper.exclusiveCheckbox).toHaveBeenCalledWith(jasmine.objectContaining({ message: "Which coding assistants should MCP servers be configured for?" })); }); @@ -753,7 +753,7 @@ describe("Unit - ai-config command", () => { const mockFs = createMockFs(); App.container.set(FS_TOKEN, mockFs); spyOn(Util, "canPrompt").and.returnValue(true); - spyOn(InquirerWrapper, "checkbox").and.returnValues( + spyOn(InquirerWrapper, "exclusiveCheckbox").and.returnValues( Promise.resolve(["claude"]), Promise.resolve(["generic"]) ); @@ -768,12 +768,12 @@ describe("Unit - ai-config command", () => { it("logs and returns early when framework is jquery", async () => { App.container.set(FS_TOKEN, createMockFs()); spyOn(Util, "canPrompt").and.returnValue(true); - spyOn(InquirerWrapper, "checkbox"); + spyOn(InquirerWrapper, "exclusiveCheckbox"); await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "jquery" }); expect(Util.log).toHaveBeenCalledWith("AI Config currently not available for jQuery projects."); - expect(InquirerWrapper.checkbox).not.toHaveBeenCalled(); + expect(InquirerWrapper.exclusiveCheckbox).not.toHaveBeenCalled(); }); describe("framework resolution", () => { @@ -781,7 +781,7 @@ describe("Unit - ai-config command", () => { beforeEach(() => { App.container.set(FS_TOKEN, createMockFs()); spyOn(Util, "canPrompt").and.returnValue(true); - spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["none"])); + spyOn(InquirerWrapper, "exclusiveCheckbox").and.returnValue(Promise.resolve(["none"])); }); it("uses detected framework when --framework is not provided", async () => { From 697f4147961d95307a4723b7a1c4d85ad053a0f2 Mon Sep 17 00:00:00 2001 From: "INFRAGISTICS\\IPetrov" Date: Tue, 1 Sep 2026 15:12:33 +0300 Subject: [PATCH 4/6] fix(menu): addressed copilot comments --- packages/core/package.json | 4 ++-- packages/core/prompt/InquirerWrapper.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 263310d89..f331b5204 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -14,6 +14,7 @@ "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/prompts": "^7.9.0", + "@inquirer/type": "^3.0.0", "chalk": "^2.3.2", "glob": "^11.0.0", "jsonc-parser": "3.3.1", @@ -21,7 +22,6 @@ "typescript": "~5.5.4" }, "devDependencies": { - "@angular-devkit/schematics": "^22.0.0", - "@inquirer/type": "^3.0.0" + "@angular-devkit/schematics": "^22.0.0" } } diff --git a/packages/core/prompt/InquirerWrapper.ts b/packages/core/prompt/InquirerWrapper.ts index fd6993976..bfc41185c 100644 --- a/packages/core/prompt/InquirerWrapper.ts +++ b/packages/core/prompt/InquirerWrapper.ts @@ -1,5 +1,7 @@ import { checkbox, confirm, input, select, Separator } from '@inquirer/prompts'; -import { Context } from '@inquirer/type'; +import type { Context } from '@inquirer/type'; +import type { PartialDeep } from '@inquirer/type'; +import type { Theme } from '@inquirer/core'; import { exclusiveCheckbox, type ExclusiveCheckboxChoice } from "./ExclusiveCheckbox"; // ref - node_modules\@inquirer\input\dist\cjs\types\index.d.ts - bc for some reason this is not publicly exported @@ -30,7 +32,7 @@ type ExclusiveCheckboxConfig = { exclusiveValues?: readonly string[]; pageSize?: number; loop?: boolean; - theme?: unknown; + theme?: PartialDeep; }; export class InquirerWrapper { From 6ef91e1da9f0cc5ad536a794c4c797c6fa75d940 Mon Sep 17 00:00:00 2001 From: "INFRAGISTICS\\IPetrov" Date: Tue, 1 Sep 2026 15:26:24 +0300 Subject: [PATCH 5/6] feat(menu): custom exclusive checkbox code coverage --- packages/core/prompt/ExclusiveCheckbox.ts | 7 ++ spec/unit/ai-config-spec.ts | 112 +++++++++++++++++++++- 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/core/prompt/ExclusiveCheckbox.ts b/packages/core/prompt/ExclusiveCheckbox.ts index 8860e767b..30ff21f85 100644 --- a/packages/core/prompt/ExclusiveCheckbox.ts +++ b/packages/core/prompt/ExclusiveCheckbox.ts @@ -137,6 +137,13 @@ export function applyExclusiveToggle( return toggleExclusiveChoice(items, index, exclusiveValues); } +export const exclusiveCheckboxTesting = { + normalizeChoice, + moveActiveIndex, + isSelectable, + isChecked, +}; + export const exclusiveCheckbox = createPrompt>((config, done) => { const theme = makeTheme(config.theme); const [status, setStatus] = useState("idle"); diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index ab7000bb2..f1c4cd861 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -4,7 +4,8 @@ import * as coreDetect from "../../packages/core/util/detect-framework"; import { configureMCP, configureSkills, configureInstructions } from "../../packages/cli/lib/commands/ai-config"; import * as aiConfig from "../../packages/cli/lib/commands/ai-config"; import { addMcpServers } from "../../packages/core/util/mcp-config"; -import { applyExclusiveToggle } from "../../packages/core/prompt/ExclusiveCheckbox"; +import { applyExclusiveToggle, exclusiveCheckboxTesting } from "../../packages/core/prompt/ExclusiveCheckbox"; +import { Separator } from "@inquirer/prompts"; const IGNITEUI_SERVER_KEY = "igniteui-cli"; const IGNITEUI_THEMING_SERVER_KEY = "igniteui-theming"; @@ -182,6 +183,115 @@ describe("Unit - ai-config command", () => { expect(result[0]).toEqual(jasmine.objectContaining({ checked: false })); expect(result[1]).toEqual(jasmine.objectContaining({ checked: true })); }); + + it("returns the same array when toggling a disabled option", () => { + const items = [ + { value: "none", name: "None", checked: false, disabled: true }, + { value: "generic", name: "Generic", checked: true, disabled: false } + ]; + + const result = applyExclusiveToggle(items, 0, ["none"]); + + expect(result).toBe(items); + expect(result[1]).toEqual(jasmine.objectContaining({ checked: true })); + }); + + it("preserves separators while clearing others for exclusive selection", () => { + const separator = new Separator("---"); + const items = [ + { value: "none", name: "None", checked: false, disabled: false }, + separator, + { value: "generic", name: "Generic", checked: true, disabled: false } + ]; + + const result = applyExclusiveToggle(items, 0, ["none"]); + + expect(result[0]).toEqual(jasmine.objectContaining({ checked: true })); + expect(result[1]).toBe(separator); + expect(result[2]).toEqual(jasmine.objectContaining({ checked: false })); + }); + + it("does not clear others when turning an exclusive option off", () => { + const items = [ + { value: "none", name: "None", checked: true, disabled: false }, + { value: "generic", name: "Generic", checked: true, disabled: false } + ]; + + const result = applyExclusiveToggle(items, 0, ["none"]); + + expect(result[0]).toEqual(jasmine.objectContaining({ checked: false })); + expect(result[1]).toEqual(jasmine.objectContaining({ checked: true })); + }); + + it("keeps disabled exclusive options unchanged when selecting another option", () => { + const items = [ + { value: "none", name: "None", checked: true, disabled: true }, + { value: "generic", name: "Generic", checked: false, disabled: false } + ]; + + const result = applyExclusiveToggle(items, 1, ["none"]); + + expect(result[0]).toEqual(jasmine.objectContaining({ checked: true })); + expect(result[1]).toEqual(jasmine.objectContaining({ checked: true })); + }); + + it("normalizes primitive choices to selectable items", () => { + const result = exclusiveCheckboxTesting.normalizeChoice("vscode") as any; + + expect(result).toEqual({ + value: "vscode", + name: "vscode", + checked: false, + disabled: false + }); + }); + + it("normalizes object choices and applies defaults", () => { + const result = exclusiveCheckboxTesting.normalizeChoice({ + value: "claude", + checked: true + }) as any; + + expect(result).toEqual({ + value: "claude", + name: "claude", + checked: true, + disabled: false + }); + }); + + it("detects separators as non-selectable and not checked", () => { + const separator = new Separator("---"); + + expect(exclusiveCheckboxTesting.isSelectable(separator as any)).toBe(false); + expect(exclusiveCheckboxTesting.isChecked(separator as any)).toBe(false); + }); + + it("moves to next selectable item when loop is disabled", () => { + const separator = new Separator("---"); + const items = [ + { value: "none", name: "None", checked: false, disabled: false }, + separator, + { value: "generic", name: "Generic", checked: false, disabled: true }, + { value: "claude", name: "Claude", checked: false, disabled: false } + ] as any; + + const result = exclusiveCheckboxTesting.moveActiveIndex(items, 0, 1, false); + + expect(result).toBe(3); + }); + + it("wraps to last selectable item when moving up with loop enabled", () => { + const items = [ + { value: "none", name: "None", checked: false, disabled: false }, + { value: "generic", name: "Generic", checked: false, disabled: true }, + { value: "claude", name: "Claude", checked: false, disabled: false } + ] as any; + + const result = exclusiveCheckboxTesting.moveActiveIndex(items, 0, -1, true); + + expect(result).toBe(2); + }); }); describe("configureSkills", () => { From cebd5cd9d8197ee06b27cb7d7aeb90c1d7c29675 Mon Sep 17 00:00:00 2001 From: "INFRAGISTICS\\IPetrov" Date: Tue, 1 Sep 2026 15:36:43 +0300 Subject: [PATCH 6/6] feat(menu): inquirer wrapper code coverage --- packages/core/prompt/InquirerWrapper.ts | 22 ++++- spec/unit/ai-config-spec.ts | 118 +++++++++++++++++++++++- 2 files changed, 134 insertions(+), 6 deletions(-) diff --git a/packages/core/prompt/InquirerWrapper.ts b/packages/core/prompt/InquirerWrapper.ts index bfc41185c..9ec8f341e 100644 --- a/packages/core/prompt/InquirerWrapper.ts +++ b/packages/core/prompt/InquirerWrapper.ts @@ -35,26 +35,38 @@ type ExclusiveCheckboxConfig = { theme?: PartialDeep; }; +const promptDelegates = { + input, + select, + checkbox, + exclusiveCheckbox, + confirm, +}; + +export const inquirerWrapperTesting = { + promptDelegates, +}; + export class InquirerWrapper { private constructor() { } public static async input(message: InputConfig, context?: Context): Promise { - return input(message, context); + return promptDelegates.input(message, context); } public static async select(message: InputChoicesConfig, context?: Context): Promise { - return select(message, context); + return promptDelegates.select(message, context); } public static async checkbox(message: InputChoicesConfig, context?: Context): Promise { - return checkbox(message, context); + return promptDelegates.checkbox(message, context); } public static async exclusiveCheckbox(message: ExclusiveCheckboxConfig, context?: Context): Promise { - return exclusiveCheckbox(message, context); + return promptDelegates.exclusiveCheckbox(message, context); } public static async confirm(message: { message: string; default?: boolean }, context?: Context): Promise { - return confirm(message, context); + return promptDelegates.confirm(message, context); } } diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index f1c4cd861..244468762 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -4,8 +4,10 @@ import * as coreDetect from "../../packages/core/util/detect-framework"; import { configureMCP, configureSkills, configureInstructions } from "../../packages/cli/lib/commands/ai-config"; import * as aiConfig from "../../packages/cli/lib/commands/ai-config"; import { addMcpServers } from "../../packages/core/util/mcp-config"; -import { applyExclusiveToggle, exclusiveCheckboxTesting } from "../../packages/core/prompt/ExclusiveCheckbox"; +import { applyExclusiveToggle, exclusiveCheckbox, exclusiveCheckboxTesting } from "../../packages/core/prompt/ExclusiveCheckbox"; +import { InquirerWrapper as LocalInquirerWrapper, inquirerWrapperTesting } from "../../packages/core/prompt/InquirerWrapper"; import { Separator } from "@inquirer/prompts"; +import { PassThrough, Writable } from "stream"; const IGNITEUI_SERVER_KEY = "igniteui-cli"; const IGNITEUI_THEMING_SERVER_KEY = "igniteui-theming"; @@ -158,6 +160,25 @@ describe("Unit - ai-config command", () => { }); describe("exclusive checkbox behavior", () => { + async function runExclusivePrompt(keys: string[], config: any) { + const input = new PassThrough(); + let rendered = ""; + const output = new Writable({ + write(chunk, _encoding, callback) { + rendered += chunk.toString(); + callback(); + } + }); + + const promise = exclusiveCheckbox(config, { input, output, clearPromptOnDone: true } as any); + for (const key of keys) { + input.write(key); + } + + const result = await promise; + return { result, rendered }; + } + it("clears other selections when None is selected", () => { const items = [ { value: "none", name: "None", checked: false, disabled: false }, @@ -292,6 +313,101 @@ describe("Unit - ai-config command", () => { expect(result).toBe(2); }); + + it("accepts numeric selection and submits selected value", async () => { + const { result, rendered } = await runExclusivePrompt(["2", "\r"], { + message: "Select agent", + choices: ["none", "claude"], + exclusiveValues: ["none"], + required: true, + loop: false + }); + + expect(result).toEqual(["claude"]); + expect(rendered).toContain("Select agent"); + }); + + it("shows required error then allows completion after selection", async () => { + const { result, rendered } = await runExclusivePrompt(["\r", " ", "\r"], { + message: "Select at least one", + choices: ["none", "claude"], + exclusiveValues: ["none"], + required: true, + loop: true + }); + + expect(result).toEqual(["none"]); + expect(rendered).toContain("Select at least one option."); + }); + + it("supports arrow navigation and space toggle", async () => { + const { result } = await runExclusivePrompt(["\u001b[B", " ", "\r"], { + message: "Select with arrows", + choices: ["none", "claude"], + exclusiveValues: ["none"], + required: true, + loop: false + }); + + expect(result).toEqual(["claude"]); + }); + }); + + describe("InquirerWrapper delegates", () => { + it("delegates input calls", async () => { + const context = {} as any; + const config = { message: "Input" } as any; + spyOn(inquirerWrapperTesting.promptDelegates, "input").and.returnValue(Promise.resolve("value") as any); + + const result = await LocalInquirerWrapper.input(config, context); + + expect(result).toBe("value"); + expect(inquirerWrapperTesting.promptDelegates.input).toHaveBeenCalledWith(config, context); + }); + + it("delegates select calls", async () => { + const context = {} as any; + const config = { message: "Select", choices: ["A"] } as any; + spyOn(inquirerWrapperTesting.promptDelegates, "select").and.returnValue(Promise.resolve("A") as any); + + const result = await LocalInquirerWrapper.select(config, context); + + expect(result).toBe("A"); + expect(inquirerWrapperTesting.promptDelegates.select).toHaveBeenCalledWith(config, context); + }); + + it("delegates checkbox calls", async () => { + const context = {} as any; + const config = { message: "Check", choices: ["A"] } as any; + spyOn(inquirerWrapperTesting.promptDelegates, "checkbox").and.returnValue(Promise.resolve(["A"]) as any); + + const result = await LocalInquirerWrapper.checkbox(config, context); + + expect(result).toEqual(["A"]); + expect(inquirerWrapperTesting.promptDelegates.checkbox).toHaveBeenCalledWith(config, context); + }); + + it("delegates exclusive checkbox calls", async () => { + const context = {} as any; + const config = { message: "Exclusive", choices: [{ value: "none" }], exclusiveValues: ["none"] } as any; + spyOn(inquirerWrapperTesting.promptDelegates, "exclusiveCheckbox").and.returnValue(Promise.resolve(["none"]) as any); + + const result = await LocalInquirerWrapper.exclusiveCheckbox(config, context); + + expect(result).toEqual(["none"]); + expect(inquirerWrapperTesting.promptDelegates.exclusiveCheckbox).toHaveBeenCalledWith(config, context); + }); + + it("delegates confirm calls", async () => { + const context = {} as any; + const config = { message: "Confirm", default: true }; + spyOn(inquirerWrapperTesting.promptDelegates, "confirm").and.returnValue(Promise.resolve(true) as any); + + const result = await LocalInquirerWrapper.confirm(config, context); + + expect(result).toBe(true); + expect(inquirerWrapperTesting.promptDelegates.confirm).toHaveBeenCalledWith(config, context); + }); }); describe("configureSkills", () => {