Skip to content
Merged
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/dirty-rats-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clack/prompts": patch
"@clack/core": patch
---

Handle empty arrays in various prompts and utilities.
9 changes: 5 additions & 4 deletions packages/core/src/prompts/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
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}${styleText('inverse', s2)}${s3.join('')}`;
const preCursor = this.userInput.slice(0, this.cursor);
const cursorChar = this.userInput.slice(this.cursor, this.cursor + 1);
const rest = this.userInput.slice(this.cursor + 1);
return `${preCursor}${styleText('inverse', cursorChar)}${rest}`;
}

get options(): T[] {
Expand Down Expand Up @@ -117,7 +118,7 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
}
} else {
if (!this.multiple && this.options.length > 0) {
initialValues = [this.options[0].value];
initialValues = [this.options[0]?.value];
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/prompts/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ const SEGMENTS: Record<string, SegmentConfig> = {
} as const;

function segmentsFor(fmt: DateFormat): SegmentConfig[] {
return [...fmt].map((c) => SEGMENTS[c as keyof typeof SEGMENTS]);
// biome-ignore lint/style/noNonNullAssertion: DateFormat only contains Y/M/D keys present in SEGMENTS
return [...fmt].map((c) => SEGMENTS[c as keyof typeof SEGMENTS]!);
}

function detectLocaleFormat(locale?: string): { segments: SegmentConfig[]; separator: string } {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/prompts/group-multiselect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export default class GroupMultiSelectPrompt<T extends { value: any }> extends Pr

private toggleValue() {
const item = this.options[this.cursor];
if (item === undefined) {
return;
}
if (this.value === undefined) {
this.value = [];
}
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/prompts/multi-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ export default class MultiLinePrompt extends Prompt<string> {
if (this.cursor >= userInput.length) {
return `${userInput}█`;
}
const s1 = userInput.slice(0, this.cursor);
const s2 = userInput[this.cursor];
const s3 = userInput.slice(this.cursor + 1);
if (s2 === '\n') return `${s1}█\n${s3}`;
return `${s1}${styleText('inverse', s2)}${s3}`;
const preCursor = userInput.slice(0, this.cursor);
const cursorChar = userInput.slice(this.cursor, this.cursor + 1);
const rest = userInput.slice(this.cursor + 1);
if (cursorChar === '\n') return `${preCursor}█\n${rest}`;
return `${preCursor}${styleText('inverse', cursorChar)}${rest}`;
}
get cursor() {
return this._cursor;
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/prompts/multi-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default class MultiSelectPrompt<T extends OptionLike> extends Prompt<T['v
cursor = 0;

private get _value(): T['value'] {
return this.options[this.cursor].value;
return this.options[this.cursor]?.value;
}

private get _enabledOptions(): T[] {
Expand Down Expand Up @@ -59,7 +59,7 @@ export default class MultiSelectPrompt<T extends OptionLike> extends Prompt<T['v
this.options.findIndex(({ value }) => value === opts.cursorAt),
0
);
this.cursor = this.options[cursor].disabled ? findCursor<T>(cursor, 1, this.options) : cursor;
this.cursor = this.options[cursor]?.disabled ? findCursor<T>(cursor, 1, this.options) : cursor;
this.on('key', (_char, key) => {
if (key.name === 'a') {
this.toggleAll();
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/prompts/password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ export default class PasswordPrompt extends Prompt<string> {
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}${styleText('inverse', s2[0])}${s2.slice(1)}`;
const preCursor = masked.slice(0, this.cursor);
const cursorChar = masked.slice(this.cursor, this.cursor + 1);
const rest = masked.slice(this.cursor + 1);
return `${preCursor}${styleText('inverse', cursorChar)}${rest}`;
}
clear() {
this._clearUserInput();
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/prompts/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export default class SelectPrompt<T extends { value: any; disabled?: boolean }>
}

private changeValue() {
this.value = this._selectedValue.value;
const selectedValue = this._selectedValue;
this.value = selectedValue === undefined ? undefined : selectedValue.value;
}

constructor(opts: SelectOptions<T>) {
Expand All @@ -27,7 +28,7 @@ export default class SelectPrompt<T extends { value: any; disabled?: boolean }>

const initialCursor = this.options.findIndex(({ value }) => value === opts.initialValue);
const cursor = initialCursor === -1 ? 0 : initialCursor;
this.cursor = this.options[cursor].disabled ? findCursor<T>(cursor, 1, this.options) : cursor;
this.cursor = this.options[cursor]?.disabled ? findCursor<T>(cursor, 1, this.options) : cursor;
this.changeValue();

this.on('cursor', (key) => {
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/prompts/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ export default class TextPrompt extends Prompt<string> {
if (this.cursor >= userInput.length) {
return `${this.userInput}█`;
}
const s1 = userInput.slice(0, this.cursor);
const [s2, ...s3] = userInput.slice(this.cursor);
return `${s1}${styleText('inverse', s2)}${s3.join('')}`;
const preCursor = userInput.slice(0, this.cursor);
const cursorChar = userInput.slice(this.cursor, this.cursor + 1);
const rest = userInput.slice(this.cursor + 1);
return `${preCursor}${styleText('inverse', cursorChar)}${rest}`;
}
get cursor() {
return this._cursor;
Expand Down
16 changes: 9 additions & 7 deletions packages/core/src/utils/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function findCursor<T extends { disabled?: boolean }>(
const maxCursor = Math.max(options.length - 1, 0);
const clampedCursor = newCursor < 0 ? maxCursor : newCursor > maxCursor ? 0 : newCursor;
const newOption = options[clampedCursor];
if (newOption.disabled) {
if (newOption?.disabled) {
return findCursor(clampedCursor, delta < 0 ? -1 : 1, options);
}
return clampedCursor;
Expand All @@ -37,20 +37,22 @@ export function findTextCursor(

cursorY = Math.max(0, Math.min(lines.length - 1, cursorY + deltaY));

cursorX = Math.min(cursorX, lines[cursorY].length) + deltaX;
// biome-ignore-start lint/style/noNonNullAssertion: cursorY/i are always kept within lines bounds
cursorX = Math.min(cursorX, lines[cursorY]!.length) + deltaX;
while (cursorX < 0 && cursorY > 0) {
cursorY--;
cursorX += lines[cursorY].length + 1;
cursorX += lines[cursorY]!.length + 1;
}
while (cursorX > lines[cursorY].length && cursorY < lines.length - 1) {
cursorX -= lines[cursorY].length + 1;
while (cursorX > lines[cursorY]!.length && cursorY < lines.length - 1) {
cursorX -= lines[cursorY]!.length + 1;
cursorY++;
}
cursorX = Math.max(0, Math.min(lines[cursorY].length, cursorX));
cursorX = Math.max(0, Math.min(lines[cursorY]!.length, cursorX));

let newCursor = 0;
for (let i = 0; i < cursorY; i++) {
newCursor += lines[i].length + 1;
newCursor += lines[i]!.length + 1;
}
// biome-ignore-end lint/style/noNonNullAssertion: end of clamped cursor block
return newCursor + cursorX;
}
2 changes: 1 addition & 1 deletion packages/core/src/utils/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function updateSettings(updates: ClackSettings) {
if (!Object.hasOwn(aliases, alias)) continue;

const action = aliases[alias];
if (!settings.actions.has(action)) continue;
if (action === undefined || !settings.actions.has(action)) continue;

if (!settings.aliases.has(alias)) {
settings.aliases.set(alias, action);
Expand Down
10 changes: 5 additions & 5 deletions packages/core/test/prompts/date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { MockReadable } from '../mock-readable.js';
import { MockWritable } from '../mock-writable.js';

const d = (iso: string) => {
const [y, m, day] = iso.slice(0, 10).split('-').map(Number);
const [y, m, day] = iso.slice(0, 10).split('-').map(Number) as [number, number, number];
return new Date(Date.UTC(y, m - 1, day));
};

Expand Down Expand Up @@ -407,9 +407,9 @@ describe('DatePrompt', () => {
});
instance.prompt();
// en-US is MDY
expect(instance.segments[0].type).to.equal('month');
expect(instance.segments[1].type).to.equal('day');
expect(instance.segments[2].type).to.equal('year');
expect(instance.segments[0]?.type).to.equal('month');
expect(instance.segments[1]?.type).to.equal('day');
expect(instance.segments[2]?.type).to.equal('year');
});

test('explicit format overrides locale', () => {
Expand All @@ -422,7 +422,7 @@ describe('DatePrompt', () => {
initialValue: d('2025-03-15'),
});
instance.prompt();
expect(instance.segments[0].type).to.equal('year');
expect(instance.segments[0]?.type).to.equal('year');
});
});
});
43 changes: 43 additions & 0 deletions packages/core/test/prompts/group-multiselect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { cursor } from 'sisteransi';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { default as GroupMultiSelectPrompt } from '../../src/prompts/group-multiselect.js';
import { MockReadable } from '../mock-readable.js';
import { MockWritable } from '../mock-writable.js';

describe('GroupMultiSelectPrompt', () => {
let input: MockReadable;
let output: MockWritable;

beforeEach(() => {
input = new MockReadable();
output = new MockWritable();
});

afterEach(() => {
vi.restoreAllMocks();
});

test('renders render() result', () => {
const instance = new GroupMultiSelectPrompt({
input,
output,
render: () => 'foo',
options: {
group: [{ value: 'foo' }, { value: 'bar' }],
},
});
instance.prompt();
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
});

test('does not throw if empty options are provided', () => {
const instance = new GroupMultiSelectPrompt({
input,
output,
render: () => 'foo',
options: {},
});
instance.prompt();
expect(() => input.emit('keypress', ' ', { name: 'space' })).not.toThrow();
});
});
12 changes: 12 additions & 0 deletions packages/core/test/prompts/multi-select.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ describe('MultiSelectPrompt', () => {
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
});

test('does not throw if empty options are provided', () => {
expect(
() =>
new MultiSelectPrompt({
input,
output,
render: () => 'foo',
options: [],
})
).not.toThrow();
});

describe('cursor', () => {
test('cursor is index of selected item', () => {
const instance = new MultiSelectPrompt({
Expand Down
10 changes: 10 additions & 0 deletions packages/core/test/prompts/select.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ describe('SelectPrompt', () => {
expect(output.buffer).to.deep.equal([cursor.hide, 'foo']);
});

test('does not throw if empty options are provided', () => {
const instance = new SelectPrompt({
input,
output,
render: () => 'foo',
options: [],
});
expect(() => instance.prompt()).not.toThrow();
});

describe('cursor', () => {
test('cursor is index of selected item', () => {
const instance = new SelectPrompt({
Expand Down
2 changes: 1 addition & 1 deletion packages/prompts/src/group-multi-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export const groupMultiselect = <Value>(opts: GroupMultiSelectOptions<Value>) =>
const groupActive =
!active &&
typeof option.group === 'string' &&
this.options[this.cursor].value === option.group;
this.options[this.cursor]?.value === option.group;
if (groupActive) {
return opt(option, selected ? 'group-active-selected' : 'group-active', options);
}
Expand Down
14 changes: 11 additions & 3 deletions packages/prompts/src/limit-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,19 @@ const trimLines = (
let removals = 0;
if (fromEnd) {
for (let i = endIndex - 1; i >= startIndex; i--) {
lineCount -= groups[i].length;
const group = groups[i];
if (group) {
lineCount -= group.length;
}
removals++;
if (lineCount <= maxLines) break;
}
} else {
for (let i = startIndex; i < endIndex; i++) {
lineCount -= groups[i].length;
const group = groups[i];
if (group) {
lineCount -= group.length;
}
removals++;
if (lineCount <= maxLines) break;
}
Expand Down Expand Up @@ -142,7 +148,9 @@ export const limitOptions = <TOption>({
slidingWindowLocationEnd - (shouldRenderBottomEllipsis ? 1 : 0);

for (let i = slidingWindowLocationWithEllipsis; i < slidingWindowLocationEndWithEllipsis; i++) {
const wrappedLines = wrapAnsi(style(options[i], i === cursor), maxWidth, {
const option = options[i];
const styledOption = option ? style(option, i === cursor) : '';
const wrappedLines = wrapAnsi(styledOption, maxWidth, {
hard: true,
trim: false,
}).split('\n');
Expand Down
2 changes: 1 addition & 1 deletion packages/prompts/src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const log = {

const messageParts = Array.isArray(message) ? message : message.split('\n');
if (messageParts.length > 0) {
const [firstLine, ...lines] = messageParts;
const [firstLine, ...lines] = messageParts as [string, ...string[]];
if (firstLine.length > 0) {
parts.push(`${prefix}${firstLine}`);
} else {
Expand Down
8 changes: 5 additions & 3 deletions packages/prompts/src/multi-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@ export const multiline = (opts: MultiLineOptions) => {
const hasGuide = opts?.withGuide ?? settings.withGuide;
const titlePrefix = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} `;
const title = `${titlePrefix}${opts.message}\n`;
const placeholder = opts.placeholder
? styleText('inverse', opts.placeholder[0]) + styleText('dim', opts.placeholder.slice(1))
: styleText(['inverse', 'hidden'], '_');
const placeholder =
opts.placeholder && opts.placeholder.length > 0
? // biome-ignore lint/style/noNonNullAssertion: guarded by placeholder.length > 0
styleText('inverse', opts.placeholder[0]!) + styleText('dim', opts.placeholder.slice(1))
: styleText(['inverse', 'hidden'], '_');
const userInput = !this.userInput ? placeholder : this.userInputWithCursor;
const value = this.value ?? '';
const submitButton = opts.showSubmit
Expand Down
5 changes: 4 additions & 1 deletion packages/prompts/src/select-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ export interface SelectKeyOptions<Value extends string> extends CommonOptions {

export const selectKey = <Value extends string>(opts: SelectKeyOptions<Value>) => {
const opt = (
option: Option<Value>,
option: Option<Value> | undefined,
state: 'inactive' | 'active' | 'selected' | 'cancelled' = 'inactive'
) => {
if (option === undefined) {
return '';
}
const label = option.label ?? String(option.value);
if (state === 'selected') {
return `${styleText('dim', label)}`;
Expand Down
5 changes: 4 additions & 1 deletion packages/prompts/src/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,12 @@ const computeLabel = (label: string, format: (text: string) => string) => {

export const select = <Value>(opts: SelectOptions<Value>) => {
const opt = (
option: Option<Value>,
option: Option<Value> | undefined,
state: 'inactive' | 'active' | 'selected' | 'cancelled' | 'disabled'
) => {
if (option === undefined) {
return '';
}
const label = option.label ?? String(option.value);
switch (state) {
case 'disabled':
Expand Down
3 changes: 2 additions & 1 deletion packages/prompts/src/spinner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ export const spinner = ({
}
clearPrevMessage();
_prevMessage = _message;
const frame = styleFn(frames[frameIndex]);
// biome-ignore lint/style/noNonNullAssertion: frameIndex is always kept within frames bounds
const frame = styleFn(frames[frameIndex]!);
let outputMessage: string;

if (isCI) {
Expand Down
Loading
Loading