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
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