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/late-squids-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clack/prompts": minor
"@clack/core": minor
---

Support wrapping autocomplete and select prompts.
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ export { default as SelectPrompt } from './prompts/select.js';
export { default as SelectKeyPrompt } from './prompts/select-key.js';
export { default as TextPrompt } from './prompts/text.js';
export type { ClackState as State } from './types.js';
export { block, getColumns, isCancel } from './utils/index.js';
export { block, getColumns, getRows, isCancel } from './utils/index.js';
export type { ClackSettings } from './utils/settings.js';
export { settings, updateSettings } from './utils/settings.js';
12 changes: 9 additions & 3 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,15 @@ export function block({
}

export const getColumns = (output: Writable): number => {
const withColumns = output as Writable & { columns?: number };
if ('columns' in withColumns && typeof withColumns.columns === 'number') {
return withColumns.columns;
if ('columns' in output && typeof output.columns === 'number') {
return output.columns;
}
return 80;
};

export const getRows = (output: Writable): number => {
if ('rows' in output && typeof output.rows === 'number') {
return output.rows;
}
return 20;
};
60 changes: 35 additions & 25 deletions packages/prompts/src/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
validate: opts.validate,
render() {
// Title and message display
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
const headings = [`${color.gray(S_BAR)}`, `${symbol(this.state)} ${opts.message}`];
const userInput = this.userInput;
const valueAsString = String(this.value ?? '');
const options = this.options;
Expand All @@ -103,12 +103,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 `${title}${color.gray(S_BAR)}${label}`;
return `${headings.join('\n')}\n${color.gray(S_BAR)}${label}`;
}

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

default: {
Expand All @@ -129,13 +129,43 @@ 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')}`]
: [];

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

headings.push(
`${color.cyan(S_BAR)}`,
`${color.cyan(S_BAR)} ${color.dim('Search:')}${searchText}${matches}`,
...noResults,
...validationError
);

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

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

// Render options with selection
const displayOptions =
this.filteredOptions.length === 0
? []
: limitOptions({
cursor: this.cursor,
options: this.filteredOptions,
columnPadding: 3, // for `| `
rowPadding: headings.length + footers.length,
style: (option, active) => {
const label = getLabel(option);
const hint =
Expand All @@ -151,31 +181,11 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
output: opts.output,
});

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

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

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

// Return the formatted prompt
return [
`${title}${color.cyan(S_BAR)}`,
`${color.cyan(S_BAR)} ${color.dim('Search:')}${searchText}${matches}`,
...noResults,
...validationError,
...headings,
...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`),
`${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
`${color.cyan(S_BAR_END)}`,
...footers,
].join('\n');
}
}
Expand Down
129 changes: 111 additions & 18 deletions packages/prompts/src/limit-options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Writable } from 'node:stream';
import { WriteStream } from 'node:tty';
import { getColumns, getRows } from '@clack/core';
import { wrapAnsi } from 'fast-wrap-ansi';
import color from 'picocolors';
import type { CommonOptions } from './common.js';

Expand All @@ -8,37 +9,129 @@ export interface LimitOptionsParams<TOption> extends CommonOptions {
maxItems: number | undefined;
cursor: number;
style: (option: TOption, active: boolean) => string;
columnPadding?: number;
rowPadding?: number;
}

const trimLines = (
groups: Array<string[]>,
initialLineCount: number,
startIndex: number,
endIndex: number,
maxLines: number
) => {
let lineCount = initialLineCount;
let removals = 0;
for (let i = startIndex; i < endIndex; i++) {
const group = groups[i];
lineCount = lineCount - group.length;
removals++;
if (lineCount <= maxLines) {
break;
}
}
return { lineCount, removals };
};

export const limitOptions = <TOption>(params: LimitOptionsParams<TOption>): string[] => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this logic is a bit bonkers so if you want it explaining, feel free to catch me on discord

const { cursor, options, style } = params;
const output: Writable = params.output ?? process.stdout;
const rows = output instanceof WriteStream && output.rows !== undefined ? output.rows : 10;
const columns = getColumns(output);
const columnPadding = params.columnPadding ?? 0;
const rowPadding = params.rowPadding ?? 4;
const maxWidth = columns - columnPadding;
const rows = getRows(output);
const overflowFormat = color.dim('...');

const paramMaxItems = params.maxItems ?? Number.POSITIVE_INFINITY;
const outputMaxItems = Math.max(rows - 4, 0);
const outputMaxItems = Math.max(rows - rowPadding, 0);
// We clamp to minimum 5 because anything less doesn't make sense UX wise
const maxItems = Math.min(outputMaxItems, Math.max(paramMaxItems, 5));
const maxItems = Math.max(paramMaxItems, 5);
let slidingWindowLocation = 0;

if (cursor >= slidingWindowLocation + maxItems - 3) {
if (cursor >= maxItems - 3) {
slidingWindowLocation = Math.max(Math.min(cursor - maxItems + 3, options.length - maxItems), 0);
} else if (cursor < slidingWindowLocation + 2) {
slidingWindowLocation = Math.max(cursor - 2, 0);
}

const shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0;
const shouldRenderBottomEllipsis =
let shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0;
let shouldRenderBottomEllipsis =
maxItems < options.length && slidingWindowLocation + maxItems < options.length;

return options
.slice(slidingWindowLocation, slidingWindowLocation + maxItems)
.map((option, i, arr) => {
const isTopLimit = i === 0 && shouldRenderTopEllipsis;
const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis;
return isTopLimit || isBottomLimit
? overflowFormat
: style(option, i + slidingWindowLocation === cursor);
});
const slidingWindowLocationEnd = Math.min(slidingWindowLocation + maxItems, options.length);
const lineGroups: Array<string[]> = [];
let lineCount = 0;
if (shouldRenderTopEllipsis) {
lineCount++;
}
if (shouldRenderBottomEllipsis) {
lineCount++;
}

const slidingWindowLocationWithEllipsis =
slidingWindowLocation + (shouldRenderTopEllipsis ? 1 : 0);
const slidingWindowLocationEndWithEllipsis =
slidingWindowLocationEnd - (shouldRenderBottomEllipsis ? 1 : 0);

for (let i = slidingWindowLocationWithEllipsis; i < slidingWindowLocationEndWithEllipsis; i++) {
const wrappedLines = wrapAnsi(style(options[i], i === cursor), maxWidth).split('\n');
lineGroups.push(wrappedLines);
lineCount += wrappedLines.length;
}

if (lineCount > outputMaxItems) {
let precedingRemovals = 0;
let followingRemovals = 0;
let newLineCount = lineCount;
const cursorGroupIndex = cursor - slidingWindowLocationWithEllipsis;
const trimLinesLocal = (startIndex: number, endIndex: number) =>
trimLines(lineGroups, newLineCount, startIndex, endIndex, outputMaxItems);

if (shouldRenderTopEllipsis) {
({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal(
0,
cursorGroupIndex
));
if (newLineCount > outputMaxItems) {
({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal(
cursorGroupIndex + 1,
lineGroups.length
));
}
} else {
({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal(
cursorGroupIndex + 1,
lineGroups.length
));
if (newLineCount > outputMaxItems) {
({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal(
0,
cursorGroupIndex
));
}
}

if (precedingRemovals > 0) {
shouldRenderTopEllipsis = true;
lineGroups.splice(0, precedingRemovals);
}
if (followingRemovals > 0) {
shouldRenderBottomEllipsis = true;
lineGroups.splice(lineGroups.length - followingRemovals, followingRemovals);
}
}

const result: string[] = [];
if (shouldRenderTopEllipsis) {
result.push(overflowFormat);
}
for (const lineGroup of lineGroups) {
for (const line of lineGroup) {
result.push(line);
}
}
if (shouldRenderBottomEllipsis) {
result.push(overflowFormat);
}

return result;
};
50 changes: 50 additions & 0 deletions packages/prompts/test/__snapshots__/autocomplete.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,34 @@ exports[`autocomplete > limits displayed options when maxItems is set 1`] = `
]
`;

exports[`autocomplete > renders bottom ellipsis when items do not fit 1`] = `
[
"<cursor.hide>",
"│
◆ Select an option
│
│ Search: _
│ ● Line 0
│ Line 1
│ Line 2
│ Line 3
│ ...
│ ↑/↓ to select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=10>",
"<cursor.down count=1>",
"<erase.down>",
"◇ Select an option
│ Line 0
Line 1
Line 2
Line 3",
"
",
"<cursor.show>",
]
`;

exports[`autocomplete > renders initial UI with message and instructions 1`] = `
[
"<cursor.hide>",
Expand Down Expand Up @@ -96,6 +124,28 @@ exports[`autocomplete > renders placeholder if set 1`] = `
]
`;

exports[`autocomplete > renders top ellipsis when scrolled down and its do not fit 1`] = `
[
"<cursor.hide>",
"│
◆ Select an option
│
│ Search: _
│ ...
│ ● Option 2
│ ↑/↓ to select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=7>",
"<cursor.down count=1>",
"<erase.down>",
"◇ Select an option
│ Option 2",
"
",
"<cursor.show>",
]
`;

exports[`autocomplete > shows hint when option has hint and is focused 1`] = `
[
"<cursor.hide>",
Expand Down
Loading
Loading