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
34 changes: 34 additions & 0 deletions .changeset/dark-roses-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@clack/prompts": minor
---

Add theme support for the text prompt. Users can now customize the colors of symbols, guide lines, and error messages by passing a `theme` option.

Example usage:
```typescript
import { text } from '@clack/prompts';
import color from 'picocolors';

const result = await text({
message: 'Enter your name',
theme: {
formatSymbolActive: (str) => color.magenta(str),
formatGuide: (str) => color.blue(str),
formatErrorMessage: (str) => color.bgRed(color.white(str)),
}
});
```

Available theme options for text prompt:
- `formatSymbolActive` - Format the prompt symbol in active/initial state
- `formatSymbolSubmit` - Format the prompt symbol on submit
- `formatSymbolCancel` - Format the prompt symbol on cancel
- `formatSymbolError` - Format the prompt symbol on error
- `formatErrorMessage` - Format error messages
- `formatGuide` - Format the left guide line in active state
- `formatGuideSubmit` - Format the guide line on submit
- `formatGuideCancel` - Format the guide line on cancel
- `formatGuideError` - Format the guide line on error

This establishes the foundation for theming support that will be extended to other prompts.

72 changes: 72 additions & 0 deletions examples/basic/text-theme-example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as p from '@clack/prompts';
import color from 'picocolors';

async function main() {
console.clear();

p.intro(`${color.bgMagenta(color.black(' Custom Themed CLI '))}`);

// Custom theme with a purple/violet color scheme
// Defaults: active=cyan, submit=green, cancel=red, error=yellow
// Guide defaults: guide=cyan, submit=gray, cancel=gray, error=yellow
const purpleTheme = {
formatSymbolActive: (str: string) => color.magenta(str), // default: cyan
formatSymbolSubmit: (str: string) => color.green(str), // default: green (matching guide)
formatSymbolCancel: (str: string) => color.red(str), // default: red
formatSymbolError: (str: string) => color.yellow(str), // default: yellow
formatGuide: (str: string) => color.magenta(str), // default: cyan
formatGuideSubmit: (str: string) => color.green(str), // default: gray
formatGuideCancel: (str: string) => color.red(str), // default: gray - red for cancel
formatGuideError: (str: string) => color.yellow(str), // default: yellow
formatErrorMessage: (str: string) => color.red(str), // default: yellow
};

const name = await p.text({
message: 'What is your project name?',
placeholder: 'my-awesome-project',
theme: purpleTheme,
validate: (value) => {
if (!value) return 'Project name is required';
if (value.includes(' ')) return 'Project name cannot contain spaces';
},
});

if (p.isCancel(name)) {
p.cancel('Setup cancelled.');
process.exit(0);
}

const description = await p.text({
message: 'Describe your project in a few words:',
placeholder: 'A blazing fast CLI tool',
theme: purpleTheme,
});

if (p.isCancel(description)) {
p.cancel('Setup cancelled.');
process.exit(0);
}

const author = await p.text({
message: 'Who is the author?',
placeholder: 'Your Name <you@example.com>',
theme: purpleTheme,
validate: (value) => {
if (!value) return 'Author is required';
},
});

if (p.isCancel(author)) {
p.cancel('Setup cancelled.');
process.exit(0);
}

p.note(
`Name: ${color.cyan(name as string)}\nDescription: ${color.cyan((description as string) || 'N/A')}\nAuthor: ${color.cyan(author as string)}`,
'Project Summary'
);

p.outro(`${color.green('✓')} Project ${color.magenta(name as string)} configured!`);
}

main().catch(console.error);
35 changes: 35 additions & 0 deletions packages/prompts/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,38 @@ export interface CommonOptions {
signal?: AbortSignal;
withGuide?: boolean;
}

export type ColorFormatter = (str: string) => string;

/**
* Global theme options shared across all prompts.
* These control the common visual elements like the guide line.
*/
export interface GlobalTheme {
/** Format the left guide/border line (default: cyan) */
formatGuide?: ColorFormatter;
/** Format the guide line on submit (default: gray) */
formatGuideSubmit?: ColorFormatter;
/** Format the guide line on cancel (default: gray) */
formatGuideCancel?: ColorFormatter;
/** Format the guide line on error (default: yellow) */
formatGuideError?: ColorFormatter;
}

export interface ThemeOptions<T> {
theme?: T & GlobalTheme;
}

export const defaultGlobalTheme: Required<GlobalTheme> = {
formatGuide: color.cyan,
formatGuideSubmit: color.gray,
formatGuideCancel: color.gray,
formatGuideError: color.yellow,
};

export function resolveTheme<T>(
theme: Partial<T> | undefined,
defaults: T
): T {
return { ...defaults, ...theme };
}
93 changes: 83 additions & 10 deletions packages/prompts/src/text.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,48 @@
import { settings, TextPrompt } from '@clack/core';
import color from 'picocolors';
import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js';
import {
type ColorFormatter,
type CommonOptions,
defaultGlobalTheme,
type GlobalTheme,
resolveTheme,
S_BAR,
S_BAR_END,
S_STEP_ACTIVE,
S_STEP_CANCEL,
S_STEP_ERROR,
S_STEP_SUBMIT,
type ThemeOptions,
} from './common.js';

export interface TextOptions extends CommonOptions {
/**
* Theme options specific to the text prompt.
* All formatters are optional - defaults will be used if not provided.
*/
export interface TextTheme {
/** Format the prompt symbol in active/initial state (default: cyan) */
formatSymbolActive?: ColorFormatter;
/** Format the prompt symbol on submit (default: green) */
formatSymbolSubmit?: ColorFormatter;
/** Format the prompt symbol on cancel (default: red) */
formatSymbolCancel?: ColorFormatter;
/** Format the prompt symbol on error (default: yellow) */
formatSymbolError?: ColorFormatter;
/** Format error messages (default: yellow) */
formatErrorMessage?: ColorFormatter;
}

/** Default theme values for the text prompt */
const defaultTextTheme: Required<TextTheme & GlobalTheme> = {
...defaultGlobalTheme,
formatSymbolActive: color.cyan,
formatSymbolSubmit: color.green,
formatSymbolCancel: color.red,
formatSymbolError: color.yellow,
formatErrorMessage: color.yellow,
};

export interface TextOptions extends CommonOptions, ThemeOptions<TextTheme> {
message: string;
placeholder?: string;
defaultValue?: string;
Expand All @@ -11,6 +51,8 @@ export interface TextOptions extends CommonOptions {
}

export const text = (opts: TextOptions) => {
const theme = resolveTheme<Required<TextTheme & GlobalTheme>>(opts.theme, defaultTextTheme);

return new TextPrompt({
validate: opts.validate,
placeholder: opts.placeholder,
Expand All @@ -21,7 +63,38 @@ export const text = (opts: TextOptions) => {
input: opts.input,
render() {
const hasGuide = (opts?.withGuide ?? settings.withGuide) !== false;
const titlePrefix = `${hasGuide ? `${color.gray(S_BAR)}\n` : ''}${symbol(this.state)} `;

// Resolve symbol based on state
const symbolText = (() => {
switch (this.state) {
case 'initial':
case 'active':
return theme.formatSymbolActive(S_STEP_ACTIVE);
case 'cancel':
return theme.formatSymbolCancel(S_STEP_CANCEL);
case 'error':
return theme.formatSymbolError(S_STEP_ERROR);
case 'submit':
return theme.formatSymbolSubmit(S_STEP_SUBMIT);
}
})();

// Resolve connector bar color based on state
const connectorBar = (() => {
switch (this.state) {
case 'initial':
case 'active':
return theme.formatGuide(S_BAR);
case 'cancel':
return theme.formatGuideCancel(S_BAR);
case 'error':
return theme.formatGuideError(S_BAR);
case 'submit':
return theme.formatGuideSubmit(S_BAR);
}
})();

const titlePrefix = `${hasGuide ? `${connectorBar}\n` : ''}${symbolText} `;
const title = `${titlePrefix}${opts.message}\n`;
const placeholder = opts.placeholder
? color.inverse(opts.placeholder[0]) + color.dim(opts.placeholder.slice(1))
Expand All @@ -31,24 +104,24 @@ export const text = (opts: TextOptions) => {

switch (this.state) {
case 'error': {
const errorText = this.error ? ` ${color.yellow(this.error)}` : '';
const errorPrefix = hasGuide ? `${color.yellow(S_BAR)} ` : '';
const errorPrefixEnd = hasGuide ? color.yellow(S_BAR_END) : '';
const errorText = this.error ? ` ${theme.formatErrorMessage(this.error)}` : '';
const errorPrefix = hasGuide ? `${theme.formatGuideError(S_BAR)} ` : '';
const errorPrefixEnd = hasGuide ? theme.formatGuideError(S_BAR_END) : '';
return `${title.trim()}\n${errorPrefix}${userInput}\n${errorPrefixEnd}${errorText}\n`;
}
case 'submit': {
const valueText = value ? ` ${color.dim(value)}` : '';
const submitPrefix = hasGuide ? color.gray(S_BAR) : '';
const submitPrefix = hasGuide ? theme.formatGuideSubmit(S_BAR) : '';
return `${title}${submitPrefix}${valueText}`;
}
case 'cancel': {
const valueText = value ? ` ${color.strikethrough(color.dim(value))}` : '';
const cancelPrefix = hasGuide ? color.gray(S_BAR) : '';
const cancelPrefix = hasGuide ? theme.formatGuideCancel(S_BAR) : '';
return `${title}${cancelPrefix}${valueText}${value.trim() ? `\n${cancelPrefix}` : ''}`;
}
default: {
const defaultPrefix = hasGuide ? `${color.cyan(S_BAR)} ` : '';
const defaultPrefixEnd = hasGuide ? color.cyan(S_BAR_END) : '';
const defaultPrefix = hasGuide ? `${theme.formatGuide(S_BAR)} ` : '';
const defaultPrefixEnd = hasGuide ? theme.formatGuide(S_BAR_END) : '';
return `${title}${defaultPrefix}${userInput}\n${defaultPrefixEnd}\n`;
}
}
Expand Down
Loading
Loading