Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
6 changes: 6 additions & 0 deletions .changeset/huge-items-throw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clack/prompts": minor
"@clack/core": minor
---

Replaces `picocolors` with Node.js built-in `styleText`.
1 change: 0 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@
"test": "vitest run"
},
"dependencies": {
"picocolors": "^1.0.0",
"sisteransi": "^1.0.5"
},
"devDependencies": {
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/prompts/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Key } from 'node:readline';
import color from 'picocolors';
import { styleText } from 'node:util';
import { findCursor } from '../utils/cursor.js';
import Prompt, { type PromptOptions } from './prompt.js';

Expand Down Expand Up @@ -73,14 +73,14 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt<

get userInputWithCursor() {
if (!this.userInput) {
return color.inverse(color.hidden('_'));
return styleText(['inverse', 'hidden'], '_');
}
if (this._cursor >= this.userInput.length) {
return `${this.userInput}█`;
}
const s1 = this.userInput.slice(0, this._cursor);
const [s2, ...s3] = this.userInput.slice(this._cursor);
return `${s1}${color.inverse(s2)}${s3.join('')}`;
return `${s1}${styleText('inverse', s2)}${s3.join('')}`;
}

get options(): T[] {
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/prompts/password.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import color from 'picocolors';
import { styleText } from 'node:util';
import Prompt, { type PromptOptions } from './prompt.js';

export interface PasswordOptions extends PromptOptions<string, PasswordPrompt> {
Expand All @@ -18,12 +18,12 @@ export default class PasswordPrompt extends Prompt<string> {
}
const userInput = this.userInput;
if (this.cursor >= userInput.length) {
return `${this.masked}${color.inverse(color.hidden('_'))}`;
return `${this.masked}${styleText(['inverse', 'hidden'], '_')}`;
}
const masked = this.masked;
const s1 = masked.slice(0, this.cursor);
const s2 = masked.slice(this.cursor);
return `${s1}${color.inverse(s2[0])}${s2.slice(1)}`;
return `${s1}${styleText('inverse', s2[0])}${s2.slice(1)}`;
}
clear() {
this._clearUserInput();
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/prompts/text.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import color from 'picocolors';
import { styleText } from 'node:util';
import Prompt, { type PromptOptions } from './prompt.js';

export interface TextOptions extends PromptOptions<string, TextPrompt> {
Expand All @@ -17,7 +17,7 @@ export default class TextPrompt extends Prompt<string> {
}
const s1 = userInput.slice(0, this.cursor);
const [s2, ...s3] = userInput.slice(this.cursor);
return `${s1}${color.inverse(s2)}${s3.join('')}`;
return `${s1}${styleText('inverse', s2)}${s3.join('')}`;
}
get cursor() {
return this._cursor;
Expand Down
12 changes: 8 additions & 4 deletions packages/core/test/prompts/password.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import color from 'picocolors';
import { styleText } from 'node:util';
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as PasswordPrompt } from '../../src/prompts/password.js';
Expand Down Expand Up @@ -65,7 +65,9 @@ describe('PasswordPrompt', () => {
});
instance.prompt();
input.emit('keypress', 'x', { name: 'x' });
expect(instance.userInputWithCursor).to.equal(`•${color.inverse(color.hidden('_'))}`);
expect(instance.userInputWithCursor).to.equal(
`•${styleText(['inverse', 'hidden'], '_')}`
);
});

test('renders cursor inside value', () => {
Expand All @@ -80,7 +82,7 @@ describe('PasswordPrompt', () => {
input.emit('keypress', 'z', { name: 'z' });
input.emit('keypress', 'left', { name: 'left' });
input.emit('keypress', 'left', { name: 'left' });
expect(instance.userInputWithCursor).to.equal(`•${color.inverse('•')}•`);
expect(instance.userInputWithCursor).to.equal(`•${styleText('inverse', '•')}•`);
});

test('renders custom mask', () => {
Expand All @@ -92,7 +94,9 @@ describe('PasswordPrompt', () => {
});
instance.prompt();
input.emit('keypress', 'x', { name: 'x' });
expect(instance.userInputWithCursor).to.equal(`X${color.inverse(color.hidden('_'))}`);
expect(instance.userInputWithCursor).to.equal(
`X${styleText(['inverse', 'hidden'], '_')}`
);
});
});
});
4 changes: 2 additions & 2 deletions packages/core/test/prompts/text.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import color from 'picocolors';
import { styleText } from 'node:util';
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as TextPrompt } from '../../src/prompts/text.js';
Expand Down Expand Up @@ -93,7 +93,7 @@ describe('TextPrompt', () => {
input.emit('keypress', keys[i], { name: keys[i] });
}
input.emit('keypress', 'left', { name: 'left' });
expect(instance.userInputWithCursor).to.equal(`fo${color.inverse('o')}`);
expect(instance.userInputWithCursor).to.equal(`fo${styleText('inverse', 'o')}`);
});

test('shows cursor at end if beyond value', () => {
Expand Down
1 change: 0 additions & 1 deletion packages/prompts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@
},
"dependencies": {
"@clack/core": "workspace:*",
"picocolors": "^1.0.0",
"sisteransi": "^1.0.5"
},
"devDependencies": {
Expand Down
94 changes: 52 additions & 42 deletions packages/prompts/src/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { styleText } from 'node:util';
import { AutocompletePrompt, settings } from '@clack/core';
import color from 'picocolors';
import {
type CommonOptions,
S_BAR,
Expand Down Expand Up @@ -98,7 +98,7 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
const hasGuide = opts.withGuide ?? settings.withGuide;
// Title and message display
const headings = hasGuide
? [`${color.gray(S_BAR)}`, `${symbol(this.state)} ${opts.message}`]
? [`${styleText('gray', S_BAR)}`, `${symbol(this.state)} ${opts.message}`]
: [`${symbol(this.state)} ${opts.message}`];
const userInput = this.userInput;
const options = this.options;
Expand All @@ -107,14 +107,16 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
const opt = (option: Option<Value>, state: 'inactive' | 'active' | 'disabled') => {
const label = getLabel(option);
const hint =
option.hint && option.value === this.focusedValue ? color.dim(` (${option.hint})`) : '';
option.hint && option.value === this.focusedValue
? styleText('dim', ` (${option.hint})`)
: '';
switch (state) {
case 'active':
return `${color.green(S_RADIO_ACTIVE)} ${label}${hint}`;
return `${styleText('green', S_RADIO_ACTIVE)} ${label}${hint}`;
case 'inactive':
return `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}`;
return `${styleText('dim', S_RADIO_INACTIVE)} ${styleText('dim', label)}`;
case 'disabled':
return `${color.gray(S_RADIO_INACTIVE)} ${color.strikethrough(color.gray(label))}`;
return `${styleText('gray', S_RADIO_INACTIVE)} ${styleText(['strikethrough', 'gray'], label)}`;
}
};

Expand All @@ -124,61 +126,64 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
// Show selected value
const selected = getSelectedOptions(this.selectedValues, options);
const label =
selected.length > 0 ? ` ${color.dim(selected.map(getLabel).join(', '))}` : '';
const submitPrefix = hasGuide ? color.gray(S_BAR) : '';
selected.length > 0 ? ` ${styleText('dim', selected.map(getLabel).join(', '))}` : '';
const submitPrefix = hasGuide ? styleText('gray', S_BAR) : '';
return `${headings.join('\n')}\n${submitPrefix}${label}`;
}

case 'cancel': {
const userInputText = userInput ? ` ${color.strikethrough(color.dim(userInput))}` : '';
const cancelPrefix = hasGuide ? color.gray(S_BAR) : '';
const userInputText = userInput
? ` ${styleText(['strikethrough', 'dim'], userInput)}`
: '';
const cancelPrefix = hasGuide ? styleText('gray', S_BAR) : '';
return `${headings.join('\n')}\n${cancelPrefix}${userInputText}`;
}

default: {
const barColor = this.state === 'error' ? color.yellow : color.cyan;
const guidePrefix = hasGuide ? `${barColor(S_BAR)} ` : '';
const guidePrefixEnd = hasGuide ? barColor(S_BAR_END) : '';
const barStyle = this.state === 'error' ? 'yellow' : 'cyan';
const guidePrefix = hasGuide ? `${styleText(barStyle, S_BAR)} ` : '';
const guidePrefixEnd = hasGuide ? styleText(barStyle, S_BAR_END) : '';
// Display cursor position - show plain text in navigation mode
let searchText = '';
if (this.isNavigating || showPlaceholder) {
const searchTextValue = showPlaceholder ? placeholder : userInput;
searchText = searchTextValue !== '' ? ` ${color.dim(searchTextValue)}` : '';
searchText = searchTextValue !== '' ? ` ${styleText('dim', searchTextValue)}` : '';
} else {
searchText = ` ${this.userInputWithCursor}`;
}

// Show match count if filtered
const matches =
this.filteredOptions.length !== options.length
? color.dim(
? styleText(
'dim',
` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`
)
: '';

// No matches message
const noResults =
this.filteredOptions.length === 0 && userInput
? [`${guidePrefix}${color.yellow('No matches found')}`]
? [`${guidePrefix}${styleText('yellow', 'No matches found')}`]
: [];

const validationError =
this.state === 'error' ? [`${guidePrefix}${color.yellow(this.error)}`] : [];
this.state === 'error' ? [`${guidePrefix}${styleText('yellow', this.error)}`] : [];

if (hasGuide) {
headings.push(`${guidePrefix.trimEnd()}`);
}
headings.push(
`${guidePrefix}${color.dim('Search:')}${searchText}${matches}`,
`${guidePrefix}${styleText('dim', 'Search:')}${searchText}${matches}`,
...noResults,
...validationError
);

// Show instructions
const instructions = [
`${color.dim('↑/↓')} to select`,
`${color.dim('Enter:')} confirm`,
`${color.dim('Type:')} to search`,
`${styleText('dim', '↑/↓')} to select`,
`${styleText('dim', 'Enter:')} confirm`,
`${styleText('dim', 'Type:')} to search`,
];

const footers = [`${guidePrefix}${instructions.join(' • ')}`, guidePrefixEnd];
Expand Down Expand Up @@ -243,17 +248,19 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
const label = option.label ?? String(option.value ?? '');
const hint =
option.hint && focusedValue !== undefined && option.value === focusedValue
? color.dim(` (${option.hint})`)
? styleText('dim', ` (${option.hint})`)
: '';
const checkbox = isSelected ? color.green(S_CHECKBOX_SELECTED) : color.dim(S_CHECKBOX_INACTIVE);
const checkbox = isSelected
? styleText('green', S_CHECKBOX_SELECTED)
: styleText('dim', S_CHECKBOX_INACTIVE);

if (option.disabled) {
return `${color.gray(S_CHECKBOX_INACTIVE)} ${color.strikethrough(color.gray(label))}`;
return `${styleText('gray', S_CHECKBOX_INACTIVE)} ${styleText(['strikethrough', 'gray'], label)}`;
}
if (active) {
return `${checkbox} ${label}${hint}`;
}
return `${checkbox} ${color.dim(label)}`;
return `${checkbox} ${styleText('dim', label)}`;
};

// Create text prompt which we'll use as foundation
Expand All @@ -277,7 +284,7 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
output: opts.output,
render() {
// Title and symbol
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
const title = `${styleText('gray', S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;

// Selection counter
const userInput = this.userInput;
Expand All @@ -287,55 +294,58 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
// Search input display
const searchText =
this.isNavigating || showPlaceholder
? color.dim(showPlaceholder ? placeholder : userInput) // Just show plain text when in navigation mode
? styleText('dim', showPlaceholder ? placeholder : userInput) // Just show plain text when in navigation mode
: this.userInputWithCursor;

const options = this.options;

const matches =
this.filteredOptions.length !== options.length
? color.dim(
? styleText(
'dim',
` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`
)
: '';

// Render prompt state
switch (this.state) {
case 'submit': {
return `${title}${color.gray(S_BAR)} ${color.dim(`${this.selectedValues.length} items selected`)}`;
return `${title}${styleText('gray', S_BAR)} ${styleText('dim', `${this.selectedValues.length} items selected`)}`;
}
case 'cancel': {
return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(userInput))}`;
return `${title}${styleText('gray', S_BAR)} ${styleText(['strikethrough', 'dim'], userInput)}`;
}
default: {
const barColor = this.state === 'error' ? color.yellow : color.cyan;
const barStyle = this.state === 'error' ? 'yellow' : 'cyan';
// Instructions
const instructions = [
`${color.dim('↑/↓')} to navigate`,
`${color.dim(this.isNavigating ? 'Space/Tab:' : 'Tab:')} select`,
`${color.dim('Enter:')} confirm`,
`${color.dim('Type:')} to search`,
`${styleText('dim', '↑/↓')} to navigate`,
`${styleText('dim', this.isNavigating ? 'Space/Tab:' : 'Tab:')} select`,
`${styleText('dim', 'Enter:')} confirm`,
`${styleText('dim', 'Type:')} to search`,
];

// No results message
const noResults =
this.filteredOptions.length === 0 && userInput
? [`${barColor(S_BAR)} ${color.yellow('No matches found')}`]
? [`${styleText(barStyle, S_BAR)} ${styleText('yellow', 'No matches found')}`]
: [];

const errorMessage =
this.state === 'error' ? [`${barColor(S_BAR)} ${color.yellow(this.error)}`] : [];
this.state === 'error'
? [`${styleText(barStyle, S_BAR)} ${styleText('yellow', this.error)}`]
: [];

// Calculate header and footer line counts for rowPadding
const headerLines = [
...`${title}${barColor(S_BAR)}`.split('\n'),
`${barColor(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`,
...`${title}${styleText(barStyle, S_BAR)}`.split('\n'),
`${styleText(barStyle, S_BAR)} ${styleText('dim', 'Search:')} ${searchText}${matches}`,
...noResults,
...errorMessage,
];
const footerLines = [
`${barColor(S_BAR)} ${instructions.join(' • ')}`,
`${barColor(S_BAR_END)}`,
`${styleText(barStyle, S_BAR)} ${instructions.join(' • ')}`,
styleText(barStyle, S_BAR_END),
];

// Get limited options for display
Expand All @@ -352,7 +362,7 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
// Build the prompt display
return [
...headerLines,
...displayOptions.map((option) => `${barColor(S_BAR)} ${option}`),
...displayOptions.map((option) => `${styleText(barStyle, S_BAR)} ${option}`),
...footerLines,
].join('\n');
}
Expand Down
Loading
Loading