Repository navigation
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new TypeScript-based terminal user interface (TUI) library, @ollin/tui, which aims for behavioral parity with laravel/prompts. The changes include the core monorepo scaffold, prompt helpers, environment management, and testing utilities. Feedback was provided regarding critical error handling in promptUntilValid, type safety improvements for multisearch, and stricter validation for choice selection.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| while (true) { | ||
| const value = await read(); | ||
| const required = ensureRequired(value, options.required); | ||
| const validation = required ?? (await validationMessage(value, options.validate)); | ||
|
|
||
| if (!validation) { | ||
| return value; | ||
| } | ||
|
|
||
| if (!environment.interactive) { | ||
| throw new PromptValidationError(validation); | ||
| } | ||
|
|
||
| environment.error.write(renderError(validation)); | ||
| } |
There was a problem hiding this comment.
The promptUntilValid function does not catch PromptValidationError thrown by the read callback. Prompts like number, select, and search throw PromptValidationError when validation fails. Without a try/catch block in the loop, any invalid input will crash the prompt process instead of displaying the error and re-prompting the user.
while (true) {
try {
const value = await read();
const required = ensureRequired(value, options.required);
const validation = required ?? (await validationMessage(value, options.validate));
if (!validation) {
return value;
}
if (!environment.interactive) {
throw new PromptValidationError(validation);
}
environment.error.write(renderError(validation));
} catch (error) {
if (error instanceof PromptValidationError) {
if (!environment.interactive) {
throw error;
}
environment.error.write(renderError(error.message));
} else {
throw error;
}
}
}| export type SearchPromptOptions<T> = BasePromptOptions<T> & { | ||
| options: Array<ChoiceInput<T>> | ((query: string) => MaybePromise<Array<ChoiceInput<T>>>); | ||
| }; |
There was a problem hiding this comment.
The current type definition for multisearch uses SearchPromptOptions<T[]>, which incorrectly forces the individual choices to have a value of type T[] instead of T. We should introduce a dedicated MultiSearchPromptOptions<T> type where options contains ChoiceInput<T> but the prompt itself returns T[].
export type SearchPromptOptions<T> = BasePromptOptions<T> & {
options: Array<ChoiceInput<T>> | ((query: string) => MaybePromise<Array<ChoiceInput<T>>>);
};
export type MultiSearchPromptOptions<T> = BasePromptOptions<T[]> & {
options: Array<ChoiceInput<T>> | ((query: string) => MaybePromise<Array<ChoiceInput<T>>>);
};| import type { | ||
| Choice, | ||
| ChoiceInput, | ||
| ConfirmPromptOptions, | ||
| MultiSelectPromptOptions, | ||
| SearchPromptOptions, | ||
| SelectPromptOptions, | ||
| TextPromptOptions | ||
| } from '#tui/types'; |
There was a problem hiding this comment.
Import the newly created MultiSearchPromptOptions type from #tui/types.
| import type { | |
| Choice, | |
| ChoiceInput, | |
| ConfirmPromptOptions, | |
| MultiSelectPromptOptions, | |
| SearchPromptOptions, | |
| SelectPromptOptions, | |
| TextPromptOptions | |
| } from '#tui/types'; | |
| import type { | |
| Choice, | |
| ChoiceInput, | |
| ConfirmPromptOptions, | |
| MultiSearchPromptOptions, | |
| MultiSelectPromptOptions, | |
| SearchPromptOptions, | |
| SelectPromptOptions, | |
| TextPromptOptions | |
| } from '#tui/types'; |
| export const multisearch = async <T>(options: SearchPromptOptions<T[]>): Promise<T[]> => { | ||
| return promptUntilValid(options, async () => { | ||
| const query = await ask(options.message, options.hint); | ||
| const source = typeof options.options === 'function' ? await options.options(query) : options.options; | ||
| const choices = normalizeChoices(source); | ||
|
|
||
| if (query.trim() === '' && options.default !== undefined) { | ||
| return options.default; | ||
| } | ||
|
|
||
| return query | ||
| .split(',') | ||
| .map((part) => findChoice(choices, part.trim())) | ||
| .filter((choice): choice is Choice<T[]> => choice !== undefined && !choice.disabled) | ||
| .flatMap((choice) => choice.value); | ||
| }); | ||
| }; |
There was a problem hiding this comment.
Update multisearch to use MultiSearchPromptOptions<T> so that choices are correctly typed as Choice<T> and mapped to their individual values instead of using flatMap on T[].
| export const multisearch = async <T>(options: SearchPromptOptions<T[]>): Promise<T[]> => { | |
| return promptUntilValid(options, async () => { | |
| const query = await ask(options.message, options.hint); | |
| const source = typeof options.options === 'function' ? await options.options(query) : options.options; | |
| const choices = normalizeChoices(source); | |
| if (query.trim() === '' && options.default !== undefined) { | |
| return options.default; | |
| } | |
| return query | |
| .split(',') | |
| .map((part) => findChoice(choices, part.trim())) | |
| .filter((choice): choice is Choice<T[]> => choice !== undefined && !choice.disabled) | |
| .flatMap((choice) => choice.value); | |
| }); | |
| }; | |
| export const multisearch = async <T>(options: MultiSearchPromptOptions<T>): Promise<T[]> => { | |
| return promptUntilValid(options, async () => { | |
| const query = await ask(options.message, options.hint); | |
| const source = typeof options.options === 'function' ? await options.options(query) : options.options; | |
| const choices = normalizeChoices(source); | |
| if (query.trim() === '' && options.default !== undefined) { | |
| return options.default; | |
| } | |
| return query | |
| .split(',') | |
| .map((part) => findChoice(choices, part.trim())) | |
| .filter((choice): choice is Choice<T> => choice !== undefined && !choice.disabled) | |
| .map((choice) => choice.value); | |
| }); | |
| }; |
| PromptInput, | ||
| PromptOutput, | ||
| PromptValue, | ||
| SearchPromptOptions, |
| const findChoice = <T>(choices: Array<Choice<T>>, answer: string): Choice<T> | undefined => { | ||
| const index = Number.parseInt(answer, 10); | ||
|
|
||
| if (!Number.isNaN(index)) { | ||
| return choices[index - 1]; | ||
| } | ||
|
|
||
| return choices.find((choice) => choice.label === answer || String(choice.value) === answer); | ||
| }; |
There was a problem hiding this comment.
Using Number.parseInt directly can lead to false positives (e.g., '1abc' parses to 1 and matches the first choice). We should strictly check if the answer is a numeric string before parsing it as an index.
const findChoice = <T>(choices: Array<Choice<T>>, answer: string): Choice<T> | undefined => {
if (/^\d+$/.test(answer)) {
const index = Number.parseInt(answer, 10);
return choices[index - 1];
}
return choices.find((choice) => choice.label === answer || String(choice.value) === answer);
};| return answer | ||
| .split(',') | ||
| .map((part) => findChoice(choices, part.trim())) | ||
| .filter((choice): choice is Choice<T> => choice !== undefined && !choice.disabled) | ||
| .map((choice) => choice.value); | ||
| }); |
There was a problem hiding this comment.
Summary
ollin-tuipnpm/Turbo monorepo@ollin/tuipublic helper surface for the upstream prompt toolkit portValidation
pnpm typecheckpnpm testNotes
This PR must remain draft while implementation continues phase by phase. It should not be marked ready for review yet.