Skip to content

Conversation

@pankaj-raikar
Copy link

Screen.Recording.2026-01-18.at.1.00.05.AM.mov
  • Introduced a new searchable-checkbox utility to enhance user experience by allowing search functionality within checkbox prompts.
  • Updated the install command to utilize the new searchable checkbox for selecting skills to install.
  • Added dependencies for @inquirer/ansi, @inquirer/figures, and fuzzysort to support the new feature.

- Introduced a new `searchable-checkbox` utility to enhance user experience by allowing search functionality within checkbox prompts.
- Updated the install command to utilize the new searchable checkbox for selecting skills to install.
- Added dependencies for `@inquirer/ansi`, `@inquirer/figures`, and `fuzzysort` to support the new feature.
Copilot AI review requested due to automatic review settings January 17, 2026 19:32
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds a new searchable checkbox prompt utility to enhance the user experience when selecting skills to install. The new utility provides fuzzy search functionality within checkbox prompts, making it easier to find and select skills when working with large skill repositories.

Changes:

  • Introduced a new searchable-checkbox utility that extends the standard checkbox prompt with fuzzy search capabilities using the fuzzysort library
  • Updated the install command to replace the standard checkbox prompt with the new searchable checkbox for improved skill selection UX
  • Added required dependencies: @inquirer/ansi, @inquirer/figures, and fuzzysort

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/utils/searchable-checkbox.ts New utility implementing a searchable checkbox prompt with fuzzy search, keyboard navigation, and comprehensive state management
src/commands/install.ts Updated to import and use the new searchableCheckbox utility instead of the standard checkbox prompt
package.json Added three new dependencies to support the searchable checkbox feature
bun.lock Lockfile updated with new dependency resolutions

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 204 to 211
useMemo(() => {
const first = items.findIndex(isSelectable);
if (first === -1) {
throw new ValidationError(
'[searchable checkbox] No selectable choices. All choices are disabled.',
);
}
return first;
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useMemo hook is being used incorrectly here. useMemo should return a value that can be used elsewhere, but this implementation throws an error for side effects and returns a value that is never used. This check should either be moved outside of useMemo or the returned value should be stored and utilized.

Suggested change
useMemo(() => {
const first = items.findIndex(isSelectable);
if (first === -1) {
throw new ValidationError(
'[searchable checkbox] No selectable choices. All choices are disabled.',
);
}
return first;
useEffect(() => {
const first = items.findIndex(isSelectable);
if (first === -1) {
throw new ValidationError(
'[searchable checkbox] No selectable choices. All choices are disabled.',
);
}

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 449
import {
createPrompt,
useState,
useKeypress,
usePrefix,
usePagination,
useMemo,
useEffect,
useRef,
makeTheme,
isUpKey,
isDownKey,
isSpaceKey,
isNumberKey,
isEnterKey,
isBackspaceKey,
ValidationError,
Separator,
type Theme,
type Status,
type Keybinding,
type KeypressEvent,
} from '@inquirer/core';
import { cursorHide } from '@inquirer/ansi';
import type { PartialDeep } from '@inquirer/type';
import { styleText } from 'node:util';
import figures from '@inquirer/figures';
import fuzzysort from 'fuzzysort';

type SearchableCheckboxTheme = {
icon: {
checked: string;
unchecked: string;
cursor: string;
};
style: {
disabledChoice: (text: string) => string;
renderSelectedChoices: <T>(
selectedChoices: ReadonlyArray<NormalizedChoice<T>>,
allChoices: ReadonlyArray<NormalizedChoice<T> | Separator>,
) => string;
description: (text: string) => string;
keysHelpTip: (keys: [key: string, action: string][]) => string | undefined;
};
keybindings: ReadonlyArray<Keybinding>;
};

type CheckboxShortcuts = {
all?: string | null;
invert?: string | null;
};

type Choice<Value> = {
value: Value;
name?: string;
checkedName?: string;
description?: string;
short?: string;
disabled?: boolean | string;
checked?: boolean;
type?: never;
};

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

type SearchableCheckboxConfig<
Value,
ChoicesObject =
| ReadonlyArray<string | Separator>
| ReadonlyArray<Choice<Value> | Separator>,
> = {
message: string;
prefix?: string;
pageSize?: number;
choices: ChoicesObject extends ReadonlyArray<string | Separator>
? ChoicesObject
: ReadonlyArray<Choice<Value> | Separator>;
loop?: boolean;
required?: boolean;
validate?: (
choices: readonly NormalizedChoice<Value>[],
) => boolean | string | Promise<string | boolean>;
theme?: PartialDeep<Theme<SearchableCheckboxTheme>>;
shortcuts?: CheckboxShortcuts;
searchKey?: string;
clearSearchKey?: string;
};

type Item<Value> = NormalizedChoice<Value> | Separator;


const checkboxTheme: SearchableCheckboxTheme = {
icon: {
checked: styleText('green', figures.circleFilled),
unchecked: figures.circle,
cursor: figures.pointer,
},
style: {
disabledChoice: (text: string) => styleText('dim', `- ${text}`),
renderSelectedChoices: (selectedChoices) =>
selectedChoices.map((choice) => choice.short).join(', '),
description: (text: string) => styleText('cyan', text),
keysHelpTip: (keys: [string, string][]) =>
keys
.map(([key, action]) => `${styleText('bold', key)} ${styleText('dim', action)}`)
.join(styleText('dim', ' • ')),
},
keybindings: [],
};

function isSelectable<Value>(item: Item<Value>): item is NormalizedChoice<Value> {
return !Separator.isSeparator(item) && !item.disabled;
}

function isChecked<Value>(item: Item<Value>): item is NormalizedChoice<Value> {
return isSelectable(item) && item.checked;
}

function toggle<Value>(item: Item<Value>): Item<Value> {
return isSelectable(item) ? { ...item, checked: !item.checked } : item;
}

function check(checked: boolean) {
return function <Value>(item: Item<Value>): Item<Value> {
return isSelectable(item) ? { ...item, checked } : item;
};
}

function normalizeChoices<Value>(
choices: ReadonlyArray<string | Separator> | ReadonlyArray<Choice<Value> | Separator>,
): Item<Value>[] {
return choices.map((choice) => {
if (Separator.isSeparator(choice)) return choice;

if (typeof choice === 'string') {
return {
value: choice as Value,
name: choice,
short: choice,
checkedName: choice,
disabled: false,
checked: false,
};
}

const name = choice.name ?? String(choice.value);
const normalizedChoice: NormalizedChoice<Value> = {
value: choice.value,
name,
short: choice.short ?? name,
checkedName: choice.checkedName ?? name,
disabled: choice.disabled ?? false,
checked: choice.checked ?? false,
};

if (choice.description) {
normalizedChoice.description = choice.description;
}

return normalizedChoice;
});
}

function isPrintableKey(key: KeypressEvent): boolean {
const enriched = key as KeypressEvent & { sequence?: string; meta?: boolean };
if (enriched.ctrl || enriched.meta) return false;
if (!enriched.sequence) return false;
if (enriched.sequence.length !== 1) return false;
const charCode = enriched.sequence.charCodeAt(0);
if (charCode === 32) return false;
return charCode >= 32 && charCode <= 126;
}

export default createPrompt(
<Value>(
config: SearchableCheckboxConfig<Value>,
done: (value: Array<Value>) => void,
) => {
const { pageSize = 7, loop = true, required, validate = () => true } = config;
const shortcuts = { all: 'a', invert: 'i', ...config.shortcuts };
const searchKey = config.searchKey ?? 'f';
const clearSearchKey = config.clearSearchKey ?? 'escape';
const theme = makeTheme<SearchableCheckboxTheme>(checkboxTheme, config.theme);
const { keybindings } = theme;
const [status, setStatus] = useState<Status>('idle');
const prefix = usePrefix({ status, theme });
const [items, setItems] = useState<ReadonlyArray<Item<Value>>>(
normalizeChoices(config.choices),
);
const [searchQuery, setSearchQuery] = useState('');
const [searchActive, setSearchActive] = useState(false);
const preSearchActiveIndex = useRef<number | null>(null);
const restoreActiveIndex = useRef<number | null>(null);
const activeItemIndex = useRef<number | null>(null);

useMemo(() => {
const first = items.findIndex(isSelectable);
if (first === -1) {
throw new ValidationError(
'[searchable checkbox] No selectable choices. All choices are disabled.',
);
}
return first;
}, [items]);

const filteredIndexes = useMemo(() => {
if (!searchQuery) {
return items.map((_, index) => index);
}

const searchable = items
.map((item, index) => {
if (Separator.isSeparator(item)) return null;
const searchText = `${item.name} ${item.description ?? ''}`.trim();
return { index, searchText };
})
.filter((item): item is { index: number; searchText: string } => item !== null);

const results = fuzzysort.go(searchQuery, searchable, { key: 'searchText' });
return results.map((result) => result.obj.index);
}, [items, searchQuery]);

const filteredItems = useMemo(
() => filteredIndexes.map((index) => items[index]!).filter(Boolean),
[filteredIndexes, items],
);

const [active, setActive] = useState(0);
const [errorMsg, setError] = useState<string>();

useEffect(() => {
const currentIndex = filteredIndexes[active];
if (currentIndex !== undefined) {
activeItemIndex.current = currentIndex;
} else if (!searchActive) {
activeItemIndex.current = active;
}
}, [active, filteredIndexes, searchActive]);

useEffect(() => {
if (!searchActive && restoreActiveIndex.current !== null) {
const restoredIndex = restoreActiveIndex.current;
restoreActiveIndex.current = null;
if (
restoredIndex < filteredItems.length &&
isSelectable(filteredItems[restoredIndex]!)
) {
setActive(restoredIndex);
return;
}
}

if (filteredItems.length === 0) {
if (active !== 0) setActive(0);
return;
}
const firstSelectable = filteredItems.findIndex(isSelectable);
if (firstSelectable === -1) {
if (active !== 0) setActive(0);
return;
}
if (active >= filteredItems.length || !isSelectable(filteredItems[active]!)) {
setActive(firstSelectable);
}
}, [filteredItems.length, searchQuery, searchActive, active, filteredItems]);

const bounds = useMemo(() => {
const first = filteredItems.findIndex(isSelectable);
let last = -1;
for (let index = filteredItems.length - 1; index >= 0; index -= 1) {
if (isSelectable(filteredItems[index]!)) {
last = index;
break;
}
}
return { first, last };
}, [filteredItems]);

useKeypress(async (key: KeypressEvent) => {
if (!searchActive && key.name === searchKey) {
preSearchActiveIndex.current = activeItemIndex.current ?? null;
setSearchActive(true);
setSearchQuery('');
setError(undefined);
return;
}

if (searchActive) {
if (key.name === clearSearchKey) {
restoreActiveIndex.current = preSearchActiveIndex.current;
preSearchActiveIndex.current = null;
setSearchActive(false);
setSearchQuery('');
setError(undefined);
return;
}
if (isBackspaceKey(key)) {
setSearchQuery(searchQuery.slice(0, -1));
setError(undefined);
return;
}
if (isPrintableKey(key)) {
const sequence = (key as KeypressEvent & { sequence?: string }).sequence ?? '';
setSearchQuery(`${searchQuery}${sequence}`);
setError(undefined);
return;
}
}

if (isEnterKey(key)) {
const selection = items.filter(isChecked);
const isValid = await validate([...selection]);
if (required && !items.some(isChecked)) {
setError('At least one choice must be selected');
} else if (isValid === true) {
setStatus('done');
done(selection.map((choice) => choice.value));
} else {
setError(isValid || 'You must select a valid value');
}
} else if (bounds.first !== -1 && (isUpKey(key, keybindings) || isDownKey(key, keybindings))) {
if (
loop ||
(isUpKey(key, keybindings) && active !== bounds.first) ||
(isDownKey(key, keybindings) && active !== bounds.last)
) {
const offset = isUpKey(key, keybindings) ? -1 : 1;
let next = active;
do {
next = (next + offset + filteredItems.length) % filteredItems.length;
} while (!isSelectable(filteredItems[next]!));
setActive(next);
}
} else if (isSpaceKey(key)) {
setError(undefined);
const activeIndex = filteredIndexes[active];
if (activeIndex === undefined) return;
setItems(items.map((choice, i) => (i === activeIndex ? toggle(choice) : choice)));
} else if (!searchActive && key.name === shortcuts.all) {
const selectAll = items.some((choice) => isSelectable(choice) && !choice.checked);
setItems(items.map(check(selectAll)));
} else if (!searchActive && key.name === shortcuts.invert) {
setItems(items.map(toggle));
} else if (bounds.first !== -1 && isNumberKey(key)) {
const selectedIndex = Number(key.name) - 1;

let selectableIndex = -1;
const position = filteredItems.findIndex((item) => {
if (Separator.isSeparator(item)) return false;

selectableIndex++;
return selectableIndex === selectedIndex;
});

const selectedItem = filteredItems[position];
const activeIndex = filteredIndexes[position];
if (selectedItem && isSelectable(selectedItem) && activeIndex !== undefined) {
setActive(position);
setItems(items.map((choice, i) => (i === activeIndex ? toggle(choice) : choice)));
}
}
});

const message = theme.style.message(config.message, status);

if (status === 'done') {
const selection = items.filter(isChecked);
const answer = theme.style.answer(
theme.style.renderSelectedChoices(selection, items),
);

return [prefix, message, answer].filter(Boolean).join(' ');
}

let description: string | undefined;
const page =
filteredItems.length === 0
? styleText('dim', ' No matches')
: usePagination({
items: filteredItems,
active,
renderItem({ item, isActive }) {
if (Separator.isSeparator(item)) {
return ` ${item.separator}`;
}

if (item.disabled) {
const disabledLabel =
typeof item.disabled === 'string' ? item.disabled : '(disabled)';
return theme.style.disabledChoice(`${item.name} ${disabledLabel}`);
}

if (isActive) {
description = item.description;
}

const checkbox = item.checked ? theme.icon.checked : theme.icon.unchecked;
const name = item.checked ? item.checkedName : item.name;
const color = isActive ? theme.style.highlight : (x: string) => x;
const cursor = isActive ? theme.icon.cursor : ' ';
return color(`${cursor}${checkbox} ${name}`);
},
pageSize,
loop,
});

const searchHint = searchActive
? theme.style.highlight(searchQuery || 'type to search')
: theme.style.defaultAnswer(searchQuery || `press ${searchKey} to search`);
const searchLine = `${styleText('bold', 'Search:')} ${searchHint}`;

const keys: [string, string][] = [
['↑↓', 'navigate'],
['space', 'select'],
[searchKey, 'search'],
];
if (searchActive) keys.push(['esc', 'clear']);
if (!searchActive && shortcuts.all) keys.push([shortcuts.all, 'all']);
if (!searchActive && shortcuts.invert) keys.push([shortcuts.invert, 'invert']);
keys.push(['⏎', 'submit']);

const helpLine = theme.style.keysHelpTip(keys);

const lines = [
[prefix, message].filter(Boolean).join(' '),
searchLine,
page,
' ',
description ? theme.style.description(description) : '',
errorMsg ? theme.style.error(errorMsg) : '',
helpLine,
]
.filter(Boolean)
.join('\n')
.trimEnd();

return `${lines}${cursorHide}`;
},
);

export { Separator } from '@inquirer/core';
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new searchableCheckbox utility lacks test coverage. Since the repository contains comprehensive automated testing for other utilities (as seen in tests/utils/), tests should be added for this new utility to verify its functionality, search behavior, keyboard interactions, and edge cases.

Copilot uses AI. Check for mistakes.
Copy link
Author

@pankaj-raikar pankaj-raikar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed useMemo used incorrectly and Added Test Case

@numman-ali
Copy link
Owner

Imma bring this into my large v2 build
Thanks !

@pankaj-raikar
Copy link
Author

I will try to contribute more stuff as I find enhancements.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants