From 06315324431081513bed2d80becdf5553599650c Mon Sep 17 00:00:00 2001 From: Chenxin Yan Date: Mon, 9 Nov 2026 18:25:41 -0800 Subject: [PATCH] fix(prompts): keep autocomplete prompts within the terminal height Count wrapped message, search, and instruction lines when sizing the option list, and pass columnPadding for the guide prefix in autocompleteMultiselect. --- .changeset/autocomplete-wrapped-height.md | 5 +++ packages/prompts/src/autocomplete.ts | 46 ++++++++++++++++------ packages/prompts/test/autocomplete.test.ts | 32 +++++++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 .changeset/autocomplete-wrapped-height.md diff --git a/.changeset/autocomplete-wrapped-height.md b/.changeset/autocomplete-wrapped-height.md new file mode 100644 index 00000000..9471d791 --- /dev/null +++ b/.changeset/autocomplete-wrapped-height.md @@ -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. diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 0cfc5165..30fcc7dc 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -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, @@ -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'; @@ -145,9 +146,13 @@ export const autocomplete = (opts: AutocompleteOptions) => { 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; @@ -222,7 +227,11 @@ export const autocomplete = (opts: AutocompleteOptions) => { 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 ); @@ -235,7 +244,10 @@ export const autocomplete = (opts: AutocompleteOptions) => { `${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 = @@ -360,9 +372,13 @@ export const autocompleteMultiselect = (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; @@ -423,11 +439,18 @@ export const autocompleteMultiselect = (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({ @@ -437,6 +460,7 @@ export const autocompleteMultiselect = (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, }); diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index 7351bab2..812b6d47 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -574,3 +574,35 @@ describe('autocomplete with custom filter', () => { expect(output.buffer).toMatchSnapshot(); }); }); + +describe('autocomplete prompts in a narrow terminal', () => { + 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; + }); +});