Skip to content
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
5 changes: 3 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@
"author": "Infragistics",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^10.3.0",
"@inquirer/prompts": "^7.9.0",
"@inquirer/type": "^3.0.0",
"chalk": "^2.3.2",
Comment thread
ivanvpetrov marked this conversation as resolved.
"glob": "^11.0.0",
"jsonc-parser": "3.3.1",
"through2": "^2.0.3",
"typescript": "~5.5.4"
},
"devDependencies": {
"@angular-devkit/schematics": "^22.0.0",
"@inquirer/type": "^3.0.0"
"@angular-devkit/schematics": "^22.0.0"
}
}
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
238 changes: 238 additions & 0 deletions packages/core/prompt/ExclusiveCheckbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
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) => {
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 exclusiveCheckboxTesting = {
normalizeChoice,
moveActiveIndex,
isSelectable,
isChecked,
};

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");

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");
});
40 changes: 35 additions & 5 deletions packages/core/prompt/InquirerWrapper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
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
type InputConfig = {
Expand All @@ -19,24 +22,51 @@ 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?: PartialDeep<Theme>;
};

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<string> {
return input(message, context);
return promptDelegates.input(message, context);
}

public static async select(message: InputChoicesConfig, context?: Context): Promise<string> {
return select(message, context);
return promptDelegates.select(message, context);
}

public static async checkbox(message: InputChoicesConfig, context?: Context): Promise<string[]> {
return checkbox(message, context);
return promptDelegates.checkbox(message, context);
}

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

public static async confirm(message: { message: string; default?: boolean }, context?: Context): Promise<boolean> {
return confirm(message, context);
return promptDelegates.confirm(message, context);
}
}
2 changes: 1 addition & 1 deletion spec/acceptance/new-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
Loading