Skip to content

Commit 55645c2

Browse files
authored
feat(autocomplete): add wrapping and window limits (#384)
1 parent 9fd94e2 commit 55645c2

File tree

10 files changed

+515
-48
lines changed

10 files changed

+515
-48
lines changed

.changeset/late-squids-obey.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clack/prompts": minor
3+
"@clack/core": minor
4+
---
5+
6+
Support wrapping autocomplete and select prompts.

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ export { default as SelectPrompt } from './prompts/select.js';
88
export { default as SelectKeyPrompt } from './prompts/select-key.js';
99
export { default as TextPrompt } from './prompts/text.js';
1010
export type { ClackState as State } from './types.js';
11-
export { block, getColumns, isCancel } from './utils/index.js';
11+
export { block, getColumns, getRows, isCancel } from './utils/index.js';
1212
export type { ClackSettings } from './utils/settings.js';
1313
export { settings, updateSettings } from './utils/settings.js';

packages/core/src/utils/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,15 @@ export function block({
8484
}
8585

8686
export const getColumns = (output: Writable): number => {
87-
const withColumns = output as Writable & { columns?: number };
88-
if ('columns' in withColumns && typeof withColumns.columns === 'number') {
89-
return withColumns.columns;
87+
if ('columns' in output && typeof output.columns === 'number') {
88+
return output.columns;
9089
}
9190
return 80;
9291
};
92+
93+
export const getRows = (output: Writable): number => {
94+
if ('rows' in output && typeof output.rows === 'number') {
95+
return output.rows;
96+
}
97+
return 20;
98+
};

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: 111 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Writable } from 'node:stream';
2-
import { WriteStream } from 'node:tty';
2+
import { getColumns, getRows } from '@clack/core';
3+
import { wrapAnsi } from 'fast-wrap-ansi';
34
import color from 'picocolors';
45
import type { CommonOptions } from './common.js';
56

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

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

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

25-
if (cursor >= slidingWindowLocation + maxItems - 3) {
52+
if (cursor >= maxItems - 3) {
2653
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);
2954
}
3055

31-
const shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0;
32-
const shouldRenderBottomEllipsis =
56+
let shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0;
57+
let shouldRenderBottomEllipsis =
3358
maxItems < options.length && slidingWindowLocation + maxItems < options.length;
3459

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

packages/prompts/test/__snapshots__/autocomplete.test.ts.snap

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,34 @@ exports[`autocomplete > limits displayed options when maxItems is set 1`] = `
4646
]
4747
`;
4848
49+
exports[`autocomplete > renders bottom ellipsis when items do not fit 1`] = `
50+
[
51+
"<cursor.hide>",
52+
"│
53+
◆ Select an option
54+
│
55+
│ Search: _
56+
│ ● Line 0
57+
│ Line 1
58+
│ Line 2
59+
│ Line 3
60+
│ ...
61+
│ ↑/↓ to select • Enter: confirm • Type: to search
62+
└",
63+
"<cursor.backward count=999><cursor.up count=10>",
64+
"<cursor.down count=1>",
65+
"<erase.down>",
66+
"◇ Select an option
67+
│ Line 0
68+
Line 1
69+
Line 2
70+
Line 3",
71+
"
72+
",
73+
"<cursor.show>",
74+
]
75+
`;
76+
4977
exports[`autocomplete > renders initial UI with message and instructions 1`] = `
5078
[
5179
"<cursor.hide>",
@@ -96,6 +124,28 @@ exports[`autocomplete > renders placeholder if set 1`] = `
96124
]
97125
`;
98126
127+
exports[`autocomplete > renders top ellipsis when scrolled down and its do not fit 1`] = `
128+
[
129+
"<cursor.hide>",
130+
"│
131+
◆ Select an option
132+
│
133+
│ Search: _
134+
│ ...
135+
│ ● Option 2
136+
│ ↑/↓ to select • Enter: confirm • Type: to search
137+
└",
138+
"<cursor.backward count=999><cursor.up count=7>",
139+
"<cursor.down count=1>",
140+
"<erase.down>",
141+
"◇ Select an option
142+
│ Option 2",
143+
"
144+
",
145+
"<cursor.show>",
146+
]
147+
`;
148+
99149
exports[`autocomplete > shows hint when option has hint and is focused 1`] = `
100150
[
101151
"<cursor.hide>",

0 commit comments

Comments
 (0)