Skip to content

Commit 635dd1a

Browse files
committed
feat(autocomplete): add wrapping and window limits
Adds two things to the autocomplete prompt: 1. Wrapping (in that options can now wrap across multiple lines) 2. Automatically shrinking the max items displayed if they don't fit on the screen The limit options helper has become quite a bit more complicated to handle the latter 😬 but that's _probably_ ok.
1 parent 0b852e1 commit 635dd1a

File tree

2 files changed

+145
-41
lines changed

2 files changed

+145
-41
lines changed

packages/prompts/src/autocomplete.ts

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
8989
validate: opts.validate,
9090
render() {
9191
// Title and message display
92-
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
92+
const headings = [`${color.gray(S_BAR)}`, `${symbol(this.state)} ${opts.message}`];
9393
const userInput = this.userInput;
9494
const valueAsString = String(this.value ?? '');
9595
const options = this.options;
@@ -103,12 +103,12 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
103103
const selected = getSelectedOptions(this.selectedValues, options);
104104
const label =
105105
selected.length > 0 ? ` ${color.dim(selected.map(getLabel).join(', '))}` : '';
106-
return `${title}${color.gray(S_BAR)}${label}`;
106+
return `${headings.join('\n')}\n${color.gray(S_BAR)}${label}`;
107107
}
108108

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

114114
default: {
@@ -129,13 +129,43 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
129129
)
130130
: '';
131131

132+
// No matches message
133+
const noResults =
134+
this.filteredOptions.length === 0 && userInput
135+
? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`]
136+
: [];
137+
138+
const validationError =
139+
this.state === 'error' ? [`${color.yellow(S_BAR)} ${color.yellow(this.error)}`] : [];
140+
141+
headings.push(
142+
`${color.cyan(S_BAR)}`,
143+
`${color.cyan(S_BAR)} ${color.dim('Search:')}${searchText}${matches}`,
144+
...noResults,
145+
...validationError
146+
);
147+
148+
// Show instructions
149+
const instructions = [
150+
`${color.dim('↑/↓')} to select`,
151+
`${color.dim('Enter:')} confirm`,
152+
`${color.dim('Type:')} to search`,
153+
];
154+
155+
const footers = [
156+
`${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
157+
`${color.cyan(S_BAR_END)}`,
158+
];
159+
132160
// Render options with selection
133161
const displayOptions =
134162
this.filteredOptions.length === 0
135163
? []
136164
: limitOptions({
137165
cursor: this.cursor,
138166
options: this.filteredOptions,
167+
columnPadding: 3, // for `| `
168+
rowPadding: headings.length + footers.length,
139169
style: (option, active) => {
140170
const label = getLabel(option);
141171
const hint =
@@ -151,31 +181,11 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
151181
output: opts.output,
152182
});
153183

154-
// Show instructions
155-
const instructions = [
156-
`${color.dim('↑/↓')} to select`,
157-
`${color.dim('Enter:')} confirm`,
158-
`${color.dim('Type:')} to search`,
159-
];
160-
161-
// No matches message
162-
const noResults =
163-
this.filteredOptions.length === 0 && userInput
164-
? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`]
165-
: [];
166-
167-
const validationError =
168-
this.state === 'error' ? [`${color.yellow(S_BAR)} ${color.yellow(this.error)}`] : [];
169-
170184
// Return the formatted prompt
171185
return [
172-
`${title}${color.cyan(S_BAR)}`,
173-
`${color.cyan(S_BAR)} ${color.dim('Search:')}${searchText}${matches}`,
174-
...noResults,
175-
...validationError,
186+
...headings,
176187
...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`),
177-
`${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
178-
`${color.cyan(S_BAR_END)}`,
188+
...footers,
179189
].join('\n');
180190
}
181191
}
Lines changed: 110 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { Writable } from 'node:stream';
22
import { WriteStream } from 'node:tty';
3+
import { getColumns } from '@clack/core';
4+
import { wrapAnsi } from 'fast-wrap-ansi';
35
import color from 'picocolors';
46
import type { CommonOptions } from './common.js';
57

@@ -8,37 +10,129 @@ export interface LimitOptionsParams<TOption> extends CommonOptions {
810
maxItems: number | undefined;
911
cursor: number;
1012
style: (option: TOption, active: boolean) => string;
13+
columnPadding?: number;
14+
rowPadding?: number;
1115
}
1216

17+
const trimLines = (
18+
groups: Array<string[]>,
19+
initialLineCount: number,
20+
startIndex: number,
21+
endIndex: number,
22+
maxLines: number
23+
) => {
24+
let lineCount = initialLineCount;
25+
let removals = 0;
26+
for (let i = startIndex; i < endIndex; i++) {
27+
const group = groups[i];
28+
lineCount = lineCount - group.length;
29+
removals++;
30+
if (lineCount <= maxLines) {
31+
break;
32+
}
33+
}
34+
return { lineCount, removals };
35+
};
36+
1337
export const limitOptions = <TOption>(params: LimitOptionsParams<TOption>): string[] => {
1438
const { cursor, options, style } = params;
1539
const output: Writable = params.output ?? process.stdout;
16-
const rows = output instanceof WriteStream && output.rows !== undefined ? output.rows : 10;
40+
const columns = getColumns(output);
41+
const columnPadding = params.columnPadding ?? 0;
42+
const rowPadding = params.rowPadding ?? 4;
43+
const maxWidth = columns - columnPadding;
44+
const rows = output instanceof WriteStream && output.rows !== undefined ? output.rows : 20;
1745
const overflowFormat = color.dim('...');
1846

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

25-
if (cursor >= slidingWindowLocation + maxItems - 3) {
53+
if (cursor >= maxItems - 3) {
2654
slidingWindowLocation = Math.max(Math.min(cursor - maxItems + 3, options.length - maxItems), 0);
27-
} else if (cursor < slidingWindowLocation + 2) {
28-
slidingWindowLocation = Math.max(cursor - 2, 0);
2955
}
3056

31-
const shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0;
32-
const shouldRenderBottomEllipsis =
57+
let shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0;
58+
let shouldRenderBottomEllipsis =
3359
maxItems < options.length && slidingWindowLocation + maxItems < options.length;
3460

35-
return options
36-
.slice(slidingWindowLocation, slidingWindowLocation + maxItems)
37-
.map((option, i, arr) => {
38-
const isTopLimit = i === 0 && shouldRenderTopEllipsis;
39-
const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis;
40-
return isTopLimit || isBottomLimit
41-
? overflowFormat
42-
: style(option, i + slidingWindowLocation === cursor);
43-
});
61+
const slidingWindowLocationEnd = Math.min(slidingWindowLocation + maxItems, options.length);
62+
const lineGroups: Array<string[]> = [];
63+
let lineCount = 0;
64+
if (shouldRenderTopEllipsis) {
65+
lineCount++;
66+
}
67+
if (shouldRenderBottomEllipsis) {
68+
lineCount++;
69+
}
70+
71+
const slidingWindowLocationWithEllipsis =
72+
slidingWindowLocation + (shouldRenderTopEllipsis ? 1 : 0);
73+
const slidingWindowLocationEndWithEllipsis =
74+
slidingWindowLocationEnd - (shouldRenderBottomEllipsis ? 1 : 0);
75+
76+
for (let i = slidingWindowLocationWithEllipsis; i < slidingWindowLocationEndWithEllipsis; i++) {
77+
const wrappedLines = wrapAnsi(style(options[i], i === cursor), maxWidth).split('\n');
78+
lineGroups.push(wrappedLines);
79+
lineCount += wrappedLines.length;
80+
}
81+
82+
if (lineCount > outputMaxItems) {
83+
let precedingRemovals = 0;
84+
let followingRemovals = 0;
85+
let newLineCount = lineCount;
86+
const cursorGroupIndex = cursor - slidingWindowLocationWithEllipsis;
87+
const trimLinesLocal = (startIndex: number, endIndex: number) =>
88+
trimLines(lineGroups, newLineCount, startIndex, endIndex, outputMaxItems);
89+
90+
if (shouldRenderTopEllipsis) {
91+
({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal(
92+
0,
93+
cursorGroupIndex
94+
));
95+
if (newLineCount > outputMaxItems) {
96+
({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal(
97+
cursorGroupIndex + 1,
98+
lineGroups.length
99+
));
100+
}
101+
} else {
102+
({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal(
103+
cursorGroupIndex + 1,
104+
lineGroups.length
105+
));
106+
if (newLineCount > outputMaxItems) {
107+
({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal(
108+
0,
109+
cursorGroupIndex
110+
));
111+
}
112+
}
113+
114+
if (precedingRemovals > 0) {
115+
shouldRenderTopEllipsis = true;
116+
lineGroups.splice(0, precedingRemovals);
117+
}
118+
if (followingRemovals > 0) {
119+
shouldRenderBottomEllipsis = true;
120+
lineGroups.splice(lineGroups.length - followingRemovals, followingRemovals);
121+
}
122+
}
123+
124+
const result: string[] = [];
125+
if (shouldRenderTopEllipsis) {
126+
result.push(overflowFormat);
127+
}
128+
for (const lineGroup of lineGroups) {
129+
for (const line of lineGroup) {
130+
result.push(line);
131+
}
132+
}
133+
if (shouldRenderBottomEllipsis) {
134+
result.push(overflowFormat);
135+
}
136+
137+
return result;
44138
};

0 commit comments

Comments
 (0)