Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions packages/cli/lib/commands/ai-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,11 @@ const AI_ASSISTANT_CHECKBOX_CHOICES = [
async function promptForAgents(): Promise<AIAgentOption[]> {
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[];
}
Expand All @@ -167,10 +168,11 @@ async function promptForAgents(): Promise<AIAgentOption[]> {
async function promptForAssistant(): Promise<AIAssistantOption[]> {
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[];
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"author": "Infragistics",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^10.3.0",
"@inquirer/prompts": "^7.9.0",
"chalk": "^2.3.2",
Comment thread
ivanvpetrov marked this conversation as resolved.
"glob": "^11.0.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/prompt/BasePromptSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export abstract class BasePromptSession {
name: "framework",
message: "Choose framework:",
choices: this.getFrameworkNames(),
pageSize: 10,
default: "Angular"
});

Expand Down Expand Up @@ -708,6 +709,7 @@ type SelectOptions = Omit<InputOptions, "type"> & {
type: "select";
// TODO: Expand type:
choices: any[];
pageSize?: number;
}

type CheckboxOptions = Omit<SelectOptions, "type"> & {
Expand Down
231 changes: 231 additions & 0 deletions packages/core/prompt/ExclusiveCheckbox.ts
Original file line number Diff line number Diff line change
@@ -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: Value;
name?: string;
checked?: boolean;
disabled?: boolean | string;
};

type NormalizedChoice<Value> = {
value: Value;
name: string;
checked: boolean;
disabled: boolean | string;
};

type ExclusiveCheckboxConfig<Value = string> = {
message: string;
choices: ReadonlyArray<Value | ExclusiveCheckboxChoice<Value> | Separator>;
required?: boolean;
exclusiveValues?: readonly Value[];
pageSize?: number;
loop?: boolean;
theme?: PartialDeep<Theme>;
};

function normalizeChoice<Value>(choice: Value | ExclusiveCheckboxChoice<Value> | Separator): NormalizedChoice<Value> | Separator {
if (Separator.isSeparator(choice)) {
return choice;
}

if (typeof choice === "object" && choice !== null && "value" in choice) {
const objectChoice = choice as ExclusiveCheckboxChoice<Value>;
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<Value>(choice: NormalizedChoice<Value> | Separator): choice is NormalizedChoice<Value> {
return !Separator.isSeparator(choice) && !choice.disabled;
}

function isChecked<Value>(choice: NormalizedChoice<Value> | Separator): choice is NormalizedChoice<Value> {
return !Separator.isSeparator(choice) && choice.checked;
}

function toggleExclusiveChoice<Value>(
items: Array<NormalizedChoice<Value> | Separator>,
index: number,
exclusiveValues: readonly Value[],
): Array<NormalizedChoice<Value> | 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) => {
Comment thread
ivanvpetrov marked this conversation as resolved.
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<Value>(
items: Array<NormalizedChoice<Value> | 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<Value>(
items: Array<NormalizedChoice<Value> | Separator>,
index: number,
exclusiveValues: readonly Value[],
): Array<NormalizedChoice<Value> | Separator> {
return toggleExclusiveChoice(items, index, exclusiveValues);
}

export const exclusiveCheckbox = createPrompt<string[], ExclusiveCheckboxConfig<string>>((config, done) => {
const theme = makeTheme(config.theme);
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string>();
const [items, setItems] = useState<Array<NormalizedChoice<string> | 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<string> | 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");
Comment thread
ivanvpetrov marked this conversation as resolved.
Outdated

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");
});
16 changes: 16 additions & 0 deletions packages/core/prompt/InquirerWrapper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { checkbox, confirm, input, select, Separator } from '@inquirer/prompts';
import { Context } from '@inquirer/type';
import { exclusiveCheckbox, type ExclusiveCheckboxChoice } from "./ExclusiveCheckbox";
Comment thread
ivanvpetrov marked this conversation as resolved.

// ref - node_modules\@inquirer\input\dist\cjs\types\index.d.ts - bc for some reason this is not publicly exported
type InputConfig = {
Expand All @@ -19,6 +20,17 @@ type InputConfig = {

type InputChoicesConfig = Omit<InputConfig, "transformer"> & {
choices: (string | Separator)[] | ({ value: string; name?: string; checked?: boolean } | Separator)[];
pageSize?: number;
};

type ExclusiveCheckboxConfig = {
message: string;
choices: ReadonlyArray<string | ExclusiveCheckboxChoice<string> | Separator>;
required?: boolean;
exclusiveValues?: readonly string[];
pageSize?: number;
loop?: boolean;
theme?: unknown;
};

export class InquirerWrapper {
Expand All @@ -36,6 +48,10 @@ export class InquirerWrapper {
return checkbox(message, context);
}

public static async exclusiveCheckbox(message: ExclusiveCheckboxConfig, context?: Context): Promise<string[]> {
return exclusiveCheckbox(message, context);
}

public static async confirm(message: { message: string; default?: boolean }, context?: Context): Promise<boolean> {
return confirm(message, context);
}
Expand Down
29 changes: 29 additions & 0 deletions spec/unit/ai-config-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";

Expand Down
Loading