Skip to content
Open
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
75 changes: 44 additions & 31 deletions packages/prompts/src/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { AutocompletePrompt } from '@clack/core';
import color from 'picocolors';
import {
type CheckboxTheme,
type CommonOptions,
extendStyle,
getThemeColor,
getThemePrefix,
type RadioTheme,
S_BAR,
S_BAR_END,
S_CHECKBOX_INACTIVE,
S_CHECKBOX_SELECTED,
S_RADIO_ACTIVE,
S_RADIO_INACTIVE,
symbol,
} from './common.js';
import { limitOptions } from './limit-options.js';
import type { Option } from './select.js';
Expand Down Expand Up @@ -41,7 +41,7 @@ function getSelectedOptions<T>(values: T[], options: Option<T>[]): Option<T>[] {
return results;
}

interface AutocompleteSharedOptions<Value> extends CommonOptions {
type AutocompleteSharedOptions<Value, TStyle> = CommonOptions<TStyle> & {
Copy link
Collaborator

@43081j 43081j Nov 19, 2025

Choose a reason for hiding this comment

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

Suggested change
type AutocompleteSharedOptions<Value, TStyle> = CommonOptions<TStyle> & {
interface AutocompleteSharedOptions<Value> extends CommonOptions<AutocompleteStyle> {

nobody should be changing the style type of any prompt

each prompt should expose its own style interface, or the base style interface

/**
* The message to display to the user.
*/
Expand All @@ -62,9 +62,9 @@ interface AutocompleteSharedOptions<Value> extends CommonOptions {
* Validates the value
*/
validate?: (value: Value | Value[] | undefined) => string | Error | undefined;
}
};

export interface AutocompleteOptions<Value> extends AutocompleteSharedOptions<Value> {
export interface AutocompleteOptions<Value> extends AutocompleteSharedOptions<Value, RadioTheme> {
/**
* The initial selected value.
*/
Expand All @@ -76,6 +76,7 @@ export interface AutocompleteOptions<Value> extends AutocompleteSharedOptions<Va
}

export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
const style = extendStyle<RadioTheme>(opts.theme);
const prompt = new AutocompletePrompt({
options: opts.options,
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
Expand All @@ -88,13 +89,17 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
output: opts.output,
validate: opts.validate,
render() {
const themeColor = getThemeColor(this.state);
const themePrefix = getThemePrefix(this.state);

// Title and message display
const headings = [`${color.gray(S_BAR)}`, `${symbol(this.state)} ${opts.message}`];
const headings = [`${color.gray(S_BAR)}`, `${style[themePrefix]} ${opts.message}`];
const userInput = this.userInput;
const valueAsString = String(this.value ?? '');
const options = this.options;
const placeholder = opts.placeholder;
const showPlaceholder = valueAsString === '' && placeholder !== undefined;
const bar = style[themeColor](S_BAR);

// Handle different states
switch (this.state) {
Expand All @@ -103,12 +108,12 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
const selected = getSelectedOptions(this.selectedValues, options);
const label =
selected.length > 0 ? ` ${color.dim(selected.map(getLabel).join(', '))}` : '';
return `${headings.join('\n')}\n${color.gray(S_BAR)}${label}`;
return `${headings.join('\n')}\n${bar}${label}`;
}

case 'cancel': {
const userInputText = userInput ? ` ${color.strikethrough(color.dim(userInput))}` : '';
return `${headings.join('\n')}\n${color.gray(S_BAR)}${userInputText}`;
return `${headings.join('\n')}\n${bar}${userInputText}`;
}

default: {
Expand All @@ -132,15 +137,15 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
// No matches message
const noResults =
this.filteredOptions.length === 0 && userInput
? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`]
? [`${bar} ${style.colorError('No matches found')}`]
: [];

const validationError =
this.state === 'error' ? [`${color.yellow(S_BAR)} ${color.yellow(this.error)}`] : [];
this.state === 'error' ? [`${bar} ${style[themeColor](this.error)}`] : [];

headings.push(
`${color.cyan(S_BAR)}`,
`${color.cyan(S_BAR)} ${color.dim('Search:')}${searchText}${matches}`,
`${bar}`,
`${bar} ${color.dim('Search:')}${searchText}${matches}`,
...noResults,
...validationError
);
Expand All @@ -153,8 +158,8 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
];

const footers = [
`${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
`${color.cyan(S_BAR_END)}`,
`${bar} ${color.dim(instructions.join(' • '))}`,
`${style[themeColor](S_BAR_END)}`,
];

// Render options with selection
Expand All @@ -174,8 +179,8 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
: '';

return active
? `${color.green(S_RADIO_ACTIVE)} ${label}${hint}`
: `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}${hint}`;
? `${style.radioActive} ${label}${hint}`
: `${style.radioInactive} ${color.dim(label)}${hint}`;
},
maxItems: opts.maxItems,
output: opts.output,
Expand All @@ -184,7 +189,7 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
// Return the formatted prompt
return [
...headings,
...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`),
...displayOptions.map((option) => `${bar} ${option}`),
...footers,
].join('\n');
}
Expand All @@ -197,7 +202,8 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
};

// Type definition for the autocompleteMultiselect component
export interface AutocompleteMultiSelectOptions<Value> extends AutocompleteSharedOptions<Value> {
export interface AutocompleteMultiSelectOptions<Value>
extends AutocompleteSharedOptions<Value, CheckboxTheme> {
/**
* The initial selected values
*/
Expand All @@ -212,6 +218,7 @@ export interface AutocompleteMultiSelectOptions<Value> extends AutocompleteShare
* Integrated autocomplete multiselect - combines type-ahead filtering with multiselect in one UI
*/
export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOptions<Value>) => {
const style = extendStyle<CheckboxTheme>(opts.theme);
const formatOption = (
option: Option<Value>,
active: boolean,
Expand All @@ -224,11 +231,13 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
option.hint && focusedValue !== undefined && option.value === focusedValue
? color.dim(` (${option.hint})`)
: '';
const checkbox = isSelected ? color.green(S_CHECKBOX_SELECTED) : color.dim(S_CHECKBOX_INACTIVE);

if (active) {
const checkbox = isSelected ? style.checkboxSelectedActive : style.checkboxUnselectedActive;
return `${checkbox} ${label}${hint}`;
}

const checkbox = isSelected ? style.checkboxSelectedInactive : style.checkboxUnselectedInactive;
return `${checkbox} ${color.dim(label)}`;
};

Expand All @@ -250,8 +259,12 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
input: opts.input,
output: opts.output,
render() {
const themeColor = getThemeColor(this.state);
const themePrefix = getThemePrefix(this.state);

// Title and symbol
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
const title = `${color.gray(S_BAR)}\n${style[themePrefix]} ${opts.message}`;
const bar = style[themeColor](S_BAR);

// Selection counter
const userInput = this.userInput;
Expand All @@ -276,10 +289,10 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
// Render prompt state
switch (this.state) {
case 'submit': {
return `${title}${color.gray(S_BAR)} ${color.dim(`${this.selectedValues.length} items selected`)}`;
return `${title}\n${bar} ${color.dim(`${this.selectedValues.length} items selected`)}`;
}
case 'cancel': {
return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(userInput))}`;
return `${title}\n${bar} ${color.strikethrough(color.dim(userInput))}`;
}
default: {
// Instructions
Expand All @@ -293,11 +306,11 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
// No results message
const noResults =
this.filteredOptions.length === 0 && userInput
? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`]
? [`${bar} ${style.colorError('No matches found')}`]
: [];

const errorMessage =
this.state === 'error' ? [`${color.cyan(S_BAR)} ${color.yellow(this.error)}`] : [];
this.state === 'error' ? [`${bar} ${style[themeColor](this.error)}`] : [];

// Get limited options for display
const displayOptions = limitOptions({
Expand All @@ -312,12 +325,12 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
// Build the prompt display
return [
title,
`${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`,
`${bar} ${color.dim('Search:')} ${searchText}${matches}`,
...noResults,
...errorMessage,
...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`),
`${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
`${color.cyan(S_BAR_END)}`,
...displayOptions.map((option) => `${bar} ${option}`),
`${bar} ${color.dim(instructions.join(' • '))}`,
`${style[themeColor](S_BAR_END)}`,
].join('\n');
}
}
Expand Down
89 changes: 61 additions & 28 deletions packages/prompts/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,36 +39,69 @@ export const S_SUCCESS = unicodeOr('◆', '*');
export const S_WARN = unicodeOr('▲', '!');
export const S_ERROR = unicodeOr('■', 'x');

export const symbol = (state: State) => {
switch (state) {
case 'initial':
case 'active':
return color.cyan(S_STEP_ACTIVE);
case 'cancel':
return color.red(S_STEP_CANCEL);
case 'error':
return color.yellow(S_STEP_ERROR);
case 'submit':
return color.green(S_STEP_SUBMIT);
}
};

export const symbolBar = (state: State) => {
switch (state) {
case 'initial':
case 'active':
return color.cyan(S_BAR);
case 'cancel':
return color.red(S_BAR);
case 'error':
return color.yellow(S_BAR);
case 'submit':
return color.green(S_BAR);
}
};
type ColorState = `color${Capitalize<State>}`;
type PrefixState = `prefix${Capitalize<State>}`;

export interface CommonOptions {
export type CommonOptions<TStyle = unknown> = {
input?: Readable;
output?: Writable;
signal?: AbortSignal;
} & (TStyle extends object
? {
theme?: { [K in ColorState]?: (str: string) => string } & {
[K in PrefixState]?: string;
} & TStyle;
}
: {});

export interface RadioTheme {
radioActive?: string;
radioInactive?: string;
radioDisabled?: string;
}

export interface CheckboxTheme {
checkboxSelectedActive?: string;
checkboxSelectedInactive?: string;
checkboxUnselectedActive?: string;
checkboxUnselectedInactive?: string;
checkboxDisabled?: string;
}

const defaultStyle: CommonOptions<RadioTheme & CheckboxTheme>['theme'] = {
colorInitial: color.cyan,
colorActive: color.cyan,
colorCancel: color.gray,
colorError: color.yellow,
colorSubmit: color.gray,

prefixInitial: color.cyan(S_STEP_ACTIVE),
prefixActive: color.cyan(S_STEP_ACTIVE),
prefixCancel: color.red(S_STEP_CANCEL),
prefixError: color.yellow(S_STEP_ERROR),
prefixSubmit: color.green(S_STEP_SUBMIT),

radioActive: color.green(S_RADIO_ACTIVE),
radioInactive: color.dim(S_RADIO_INACTIVE),
radioDisabled: color.gray(S_RADIO_INACTIVE),

checkboxSelectedActive: color.green(S_CHECKBOX_SELECTED),
checkboxSelectedInactive: color.green(S_CHECKBOX_SELECTED),
checkboxUnselectedActive: color.cyan(S_CHECKBOX_ACTIVE),
checkboxUnselectedInactive: color.dim(S_CHECKBOX_INACTIVE),
checkboxDisabled: color.gray(S_CHECKBOX_INACTIVE),
};

type ExtendStyleType<TStyle> = ({ theme: {} } & CommonOptions<TStyle>)['theme'];

export const extendStyle = <TStyle>(style?: ExtendStyleType<TStyle>) => {
return {
...defaultStyle,
...(style ?? {}),
} as Required<ExtendStyleType<TStyle>>;
};

const capitalize = (str: string): string => str[0].toUpperCase() + str.substring(1);

export const getThemeColor = (state: State) => `color${capitalize(state)}` as ColorState;
export const getThemePrefix = (state: State) => `prefix${capitalize(state)}` as PrefixState;
36 changes: 21 additions & 15 deletions packages/prompts/src/confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@ import { ConfirmPrompt } from '@clack/core';
import color from 'picocolors';
import {
type CommonOptions,
extendStyle,
getThemeColor,
getThemePrefix,
type RadioTheme,
S_BAR,
S_BAR_END,
S_RADIO_ACTIVE,
S_RADIO_INACTIVE,
symbol,
} from './common.js';

export interface ConfirmOptions extends CommonOptions {
export interface ConfirmOptions extends CommonOptions<RadioTheme> {
message: string;
active?: string;
inactive?: string;
initialValue?: boolean;
}
export const confirm = (opts: ConfirmOptions) => {
const style = extendStyle<RadioTheme>(opts.theme);
const active = opts.active ?? 'Yes';
const inactive = opts.inactive ?? 'No';
return new ConfirmPrompt({
Expand All @@ -26,26 +28,30 @@ export const confirm = (opts: ConfirmOptions) => {
output: opts.output,
initialValue: opts.initialValue ?? true,
render() {
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
const themeColor = getThemeColor(this.state);
const themePrefix = getThemePrefix(this.state);

const bar = style[themeColor](S_BAR);
const barEnd = style[themeColor](S_BAR_END);

const title = `${color.gray(S_BAR)}\n${style[themePrefix]} ${opts.message}\n`;
const value = this.value ? active : inactive;

switch (this.state) {
case 'submit':
return `${title}${color.gray(S_BAR)} ${color.dim(value)}`;
return `${title}${bar} ${color.dim(value)}`;
case 'cancel':
return `${title}${color.gray(S_BAR)} ${color.strikethrough(
color.dim(value)
)}\n${color.gray(S_BAR)}`;
return `${title}${bar} ${color.strikethrough(color.dim(value))}\n${bar}`;
default: {
return `${title}${color.cyan(S_BAR)} ${
return `${title}${bar} ${
this.value
? `${color.green(S_RADIO_ACTIVE)} ${active}`
: `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}`
? `${style.radioActive} ${active}`
: `${style.radioInactive} ${color.dim(active)}`
} ${color.dim('/')} ${
!this.value
? `${color.green(S_RADIO_ACTIVE)} ${inactive}`
: `${color.dim(S_RADIO_INACTIVE)} ${color.dim(inactive)}`
}\n${color.cyan(S_BAR_END)}\n`;
? `${style.radioActive} ${inactive}`
: `${style.radioInactive} ${color.dim(inactive)}`
}\n${barEnd}\n`;
}
}
},
Expand Down
Loading