Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/autocomplete-wrapped-height.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clack/prompts': patch
---

Count wrapped message, search, and instruction lines when sizing `autocomplete` and `autocompleteMultiselect` option lists, so the prompts stay within the terminal height in narrow terminals instead of leaving stale rows on redraw.
46 changes: 35 additions & 11 deletions packages/prompts/src/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { styleText } from 'node:util';
import type { CANCEL_SYMBOL, Validate } from '@clack/core';
import { AutocompletePrompt, settings } from '@clack/core';
import { AutocompletePrompt, settings, wrapTextWithPrefix } from '@clack/core';
import {
type CommonOptions,
S_BAR,
Expand All @@ -10,6 +10,7 @@ import {
S_RADIO_ACTIVE,
S_RADIO_INACTIVE,
symbol,
symbolBar,
} from './common.js';
import { limitOptions } from './limit-options.js';
import type { Option } from './select.js';
Expand Down Expand Up @@ -145,9 +146,13 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
render() {
const hasGuide = opts.withGuide ?? settings.withGuide;
// Title and message display
const headings = hasGuide
? [`${styleText('gray', S_BAR)}`, `${symbol(this.state)} ${opts.message}`]
: [`${symbol(this.state)} ${opts.message}`];
const messageLines = wrapTextWithPrefix(
opts.output,
opts.message,
hasGuide ? `${symbolBar(this.state)} ` : '',
`${symbol(this.state)} `
).split('\n');
const headings = hasGuide ? [`${styleText('gray', S_BAR)}`, ...messageLines] : messageLines;
const userInput = this.userInput;
const options = this.options;
const placeholder = opts.placeholder;
Expand Down Expand Up @@ -222,7 +227,11 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
headings.push(`${guidePrefix.trimEnd()}`);
}
headings.push(
`${guidePrefix}${styleText('dim', 'Search:')}${searchText}${matches}`,
...wrapTextWithPrefix(
opts.output,
`${styleText('dim', 'Search:')}${searchText}${matches}`,
guidePrefix
).split('\n'),
...noResults,
...validationError
);
Expand All @@ -235,7 +244,10 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
`${styleText('dim', 'Type:')} to search`,
];

const footers = [`${guidePrefix}${instructions.join(' • ')}`, guidePrefixEnd];
const footers = [
...wrapTextWithPrefix(opts.output, instructions.join(' • '), guidePrefix).split('\n'),
guidePrefixEnd,
];

// Render options with selection
const displayOptions =
Expand Down Expand Up @@ -360,9 +372,13 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
render() {
const hasGuide = opts.withGuide ?? settings.withGuide;
// Title and symbol
const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} ${
opts.message
}\n`;
const wrappedMessage = wrapTextWithPrefix(
opts.output,
opts.message,
hasGuide ? `${symbolBar(this.state)} ` : '',
`${symbol(this.state)} `
);
const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${wrappedMessage}\n`;

// Selection counter
const userInput = this.userInput;
Expand Down Expand Up @@ -423,11 +439,18 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
// Calculate header and footer line counts for rowPadding
const headerLines = [
...`${title}${hasGuide ? styleText(barStyle, S_BAR) : ''}`.split('\n'),
`${guidePrefix}${styleText('dim', 'Search:')} ${searchText}${matches}`,
...wrapTextWithPrefix(
opts.output,
`${styleText('dim', 'Search:')} ${searchText}${matches}`,
guidePrefix
).split('\n'),
...noResults,
...errorMessage,
];
const footerLines = [`${guidePrefix}${instructions.join(' • ')}`, guidePrefixEnd];
const footerLines = [
...wrapTextWithPrefix(opts.output, instructions.join(' • '), guidePrefix).split('\n'),
guidePrefixEnd,
];

// Get limited options for display
const displayOptions = limitOptions({
Expand All @@ -437,6 +460,7 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
formatOption(option, active, this.selectedValues, this.focusedValue),
maxItems: opts.maxItems,
output: opts.output,
columnPadding: hasGuide ? 3 : 0, // for `| ` when guide is shown
rowPadding: headerLines.length + footerLines.length,
});

Expand Down
32 changes: 32 additions & 0 deletions packages/prompts/test/autocomplete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,3 +574,35 @@ describe('autocomplete with custom filter', () => {
expect(output.buffer).toMatchSnapshot();
});
});

describe('autocomplete prompts in a narrow terminal', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this doesn't belong in its own describe block

test.each([
['autocomplete', autocomplete],
['autocompleteMultiselect', autocompleteMultiselect],
] as const)('%s keeps the first frame within the terminal height', async (_name, prompt) => {
const input = new MockReadable();
const output = new MockWritable();
output.columns = 40;
output.rows = 20;
const controller = new AbortController();

const result = prompt({
message: 'Which languages would you like to translate your project into?',
options: Array.from({ length: 30 }, (_, i) => ({
value: `opt${i}`,
label: `Option ${i} with a label long enough to wrap`,
})),
input,
output,
signal: controller.signal,
});

// A frame taller than the terminal scrolls its top away, and every
// redraw then leaves stale rows behind.
const firstFrame = output.buffer.join('');
expect(firstFrame.split('\n').length).toBeLessThanOrEqual(output.rows);

controller.abort();
await result;
});
});
Loading