Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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
268 changes: 268 additions & 0 deletions packages/core/prompt/ExclusiveCheckbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
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));

const hasConflictingDisabledSelection = toggledOn && items.some(item =>
!Separator.isSeparator(item) &&
!!item.disabled &&
item.checked &&
(isExclusive || exclusiveValues.some(value => Object.is(value, item.value)))
);
if (hasConflictingDisabledSelection) {
return items;
}

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

const DEFAULT_PAGE_SIZE = 7;

function getVisibleItems<Value>(
items: Array<NormalizedChoice<Value> | Separator>,
active: number,
pageSize: number,
): Array<{ item: NormalizedChoice<Value> | Separator; index: number }> {
if (items.length <= pageSize) {
return items.map((item, index) => ({ item, index }));
}

let start = Math.max(0, active - Math.floor(pageSize / 2));
const end = Math.min(items.length, start + pageSize);
start = Math.max(0, end - pageSize);

return items.slice(start, end).map((item, i) => ({ item, index: start + i }));
}

export const exclusiveCheckboxTesting = {
normalizeChoice,
moveActiveIndex,
isSelectable,
isChecked,
getVisibleItems,
};

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 pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE;
const renderedItems = getVisibleItems(items, active, pageSize)
.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);
}
}
Loading