Skip to content

Commit 778aff8

Browse files
committed
merge upstream
2 parents 4412e5d + 55645c2 commit 778aff8

File tree

10 files changed

+516
-48
lines changed

10 files changed

+516
-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: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
9191
validate: opts.validate,
9292
render() {
9393
// Title and message display
94-
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
94+
const headings = [`${color.gray(S_BAR)}`, `${symbol(this.state)} ${opts.message}`];
9595
const userInput = this.userInput;
9696
const valueAsString = String(this.value ?? '');
9797
const options = this.options;
@@ -105,12 +105,13 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
105105
const selected = getSelectedOptions(this.selectedValues, options);
106106
const label =
107107
selected.length > 0 ? ` ${color.dim(selected.map(getLabel).join(', '))}` : '';
108-
return clearPrompt(opts) ? cursor.up() : `${title}${color.gray(S_BAR)}${label}`;
108+
109+
return clearPrompt(opts) ? cursor.up() :`${headings.join('\n')}\n${color.gray(S_BAR)}${label}`;
109110
}
110111

111112
case 'cancel': {
112113
const userInputText = userInput ? ` ${color.strikethrough(color.dim(userInput))}` : '';
113-
return `${title}${color.gray(S_BAR)}${userInputText}`;
114+
return `${headings.join('\n')}\n${color.gray(S_BAR)}${userInputText}`;
114115
}
115116

116117
default: {
@@ -131,13 +132,43 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
131132
)
132133
: '';
133134

135+
// No matches message
136+
const noResults =
137+
this.filteredOptions.length === 0 && userInput
138+
? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`]
139+
: [];
140+
141+
const validationError =
142+
this.state === 'error' ? [`${color.yellow(S_BAR)} ${color.yellow(this.error)}`] : [];
143+
144+
headings.push(
145+
`${color.cyan(S_BAR)}`,
146+
`${color.cyan(S_BAR)} ${color.dim('Search:')}${searchText}${matches}`,
147+
...noResults,
148+
...validationError
149+
);
150+
151+
// Show instructions
152+
const instructions = [
153+
`${color.dim('↑/↓')} to select`,
154+
`${color.dim('Enter:')} confirm`,
155+
`${color.dim('Type:')} to search`,
156+
];
157+
158+
const footers = [
159+
`${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
160+
`${color.cyan(S_BAR_END)}`,
161+
];
162+
134163
// Render options with selection
135164
const displayOptions =
136165
this.filteredOptions.length === 0
137166
? []
138167
: limitOptions({
139168
cursor: this.cursor,
140169
options: this.filteredOptions,
170+
columnPadding: 3, // for `| `
171+
rowPadding: headings.length + footers.length,
141172
style: (option, active) => {
142173
const label = getLabel(option);
143174
const hint =
@@ -153,31 +184,11 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
153184
output: opts.output,
154185
});
155186

156-
// Show instructions
157-
const instructions = [
158-
`${color.dim('↑/↓')} to select`,
159-
`${color.dim('Enter:')} confirm`,
160-
`${color.dim('Type:')} to search`,
161-
];
162-
163-
// No matches message
164-
const noResults =
165-
this.filteredOptions.length === 0 && userInput
166-
? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`]
167-
: [];
168-
169-
const validationError =
170-
this.state === 'error' ? [`${color.yellow(S_BAR)} ${color.yellow(this.error)}`] : [];
171-
172187
// Return the formatted prompt
173188
return [
174-
`${title}${color.cyan(S_BAR)}`,
175-
`${color.cyan(S_BAR)} ${color.dim('Search:')}${searchText}${matches}`,
176-
...noResults,
177-
...validationError,
189+
...headings,
178190
...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`),
179-
`${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
180-
`${color.cyan(S_BAR_END)}`,
191+
...footers,
181192
].join('\n');
182193
}
183194
}
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
@@ -81,6 +81,34 @@ exports[`autocomplete > limits displayed options when maxItems is set 1`] = `
8181
]
8282
`;
8383
84+
exports[`autocomplete > renders bottom ellipsis when items do not fit 1`] = `
85+
[
86+
"<cursor.hide>",
87+
"│
88+
◆ Select an option
89+
│
90+
│ Search: _
91+
│ ● Line 0
92+
│ Line 1
93+
│ Line 2
94+
│ Line 3
95+
│ ...
96+
│ ↑/↓ to select • Enter: confirm • Type: to search
97+
└",
98+
"<cursor.backward count=999><cursor.up count=10>",
99+
"<cursor.down count=1>",
100+
"<erase.down>",
101+
"◇ Select an option
102+
│ Line 0
103+
Line 1
104+
Line 2
105+
Line 3",
106+
"
107+
",
108+
"<cursor.show>",
109+
]
110+
`;
111+
84112
exports[`autocomplete > renders initial UI with message and instructions 1`] = `
85113
[
86114
"<cursor.hide>",
@@ -131,6 +159,28 @@ exports[`autocomplete > renders placeholder if set 1`] = `
131159
]
132160
`;
133161
162+
exports[`autocomplete > renders top ellipsis when scrolled down and its do not fit 1`] = `
163+
[
164+
"<cursor.hide>",
165+
"│
166+
◆ Select an option
167+
│
168+
│ Search: _
169+
│ ...
170+
│ ● Option 2
171+
│ ↑/↓ to select • Enter: confirm • Type: to search
172+
└",
173+
"<cursor.backward count=999><cursor.up count=7>",
174+
"<cursor.down count=1>",
175+
"<erase.down>",
176+
"◇ Select an option
177+
│ Option 2",
178+
"
179+
",
180+
"<cursor.show>",
181+
]
182+
`;
183+
134184
exports[`autocomplete > shows hint when option has hint and is focused 1`] = `
135185
[
136186
"<cursor.hide>",

0 commit comments

Comments
 (0)