diff --git a/.changeset/early-maps-carry.md b/.changeset/early-maps-carry.md new file mode 100644 index 00000000..6a0053bf --- /dev/null +++ b/.changeset/early-maps-carry.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": patch +--- + +Fix duplicated logs when scrolling through options with multiline messages by calculating `rowPadding` dynamically based on actual rendered lines instead of using a hardcoded value. diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index e55b285f..6472a435 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -299,6 +299,18 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti const errorMessage = this.state === 'error' ? [`${color.cyan(S_BAR)} ${color.yellow(this.error)}`] : []; + // Calculate header and footer line counts for rowPadding + const headerLines = [ + ...title.split('\n'), + `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`, + ...noResults, + ...errorMessage, + ]; + const footerLines = [ + `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`, + `${color.cyan(S_BAR_END)}`, + ]; + // Get limited options for display const displayOptions = limitOptions({ cursor: this.cursor, @@ -307,17 +319,14 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti formatOption(option, active, this.selectedValues, this.focusedValue), maxItems: opts.maxItems, output: opts.output, + rowPadding: headerLines.length + footerLines.length, }); // Build the prompt display return [ - title, - `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`, - ...noResults, - ...errorMessage, + ...headerLines, ...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`), - `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`, - `${color.cyan(S_BAR_END)}`, + ...footerLines, ].join('\n'); } } diff --git a/packages/prompts/src/multi-select.ts b/packages/prompts/src/multi-select.ts index 62f30ae3..0ec1d93d 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -146,23 +146,31 @@ export const multiselect = (opts: MultiSelectOptions) => { i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}` ) .join('\n'); + // Calculate rowPadding: title lines + footer lines (error message + trailing newline) + const titleLineCount = title.split('\n').length; + const footerLineCount = footer.split('\n').length + 1; // footer + trailing newline return `${title}${prefix}${limitOptions({ output: opts.output, options: this.options, cursor: this.cursor, maxItems: opts.maxItems, columnPadding: prefix.length, + rowPadding: titleLineCount + footerLineCount, style: styleOption, }).join(`\n${prefix}`)}\n${footer}\n`; } default: { const prefix = `${color.cyan(S_BAR)} `; + // Calculate rowPadding: title lines + footer lines (S_BAR_END + trailing newline) + const titleLineCount = title.split('\n').length; + const footerLineCount = 2; // S_BAR_END + trailing newline return `${title}${prefix}${limitOptions({ output: opts.output, options: this.options, cursor: this.cursor, maxItems: opts.maxItems, columnPadding: prefix.length, + rowPadding: titleLineCount + footerLineCount, style: styleOption, }).join(`\n${prefix}`)}\n${color.cyan(S_BAR_END)}\n`; } diff --git a/packages/prompts/src/select.ts b/packages/prompts/src/select.ts index b6bb6721..4571a83c 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -144,12 +144,16 @@ export const select = (opts: SelectOptions) => { } default: { const prefix = `${color.cyan(S_BAR)} `; + // Calculate rowPadding: title lines + footer lines (S_BAR_END + trailing newline) + const titleLineCount = title.split('\n').length; + const footerLineCount = 2; // S_BAR_END + trailing newline return `${title}${prefix}${limitOptions({ output: opts.output, cursor: this.cursor, options: this.options, maxItems: opts.maxItems, columnPadding: prefix.length, + rowPadding: titleLineCount + footerLineCount, style: (item, active) => opt(item, item.disabled ? 'disabled' : active ? 'active' : 'inactive'), }).join(`\n${prefix}`)}\n${color.cyan(S_BAR_END)}\n`; diff --git a/packages/prompts/test/__snapshots__/select.test.ts.snap b/packages/prompts/test/__snapshots__/select.test.ts.snap index 1332d878..270d4d79 100644 --- a/packages/prompts/test/__snapshots__/select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/select.test.ts.snap @@ -36,6 +36,124 @@ exports[`select (isCI = false) > can cancel 1`] = ` ] `; +exports[`select (isCI = false) > correctly limits options when message wraps to multiple lines 1`] = ` +[ + "", + "│ +◆ This is a very +│ long message that +│ will wrap to +│ multiple lines +│ ● Option 0 +│ ○ Option 1 +│ ○ Option 2 +│ ... +└ +", + "", + "", + "", + "│ ○ Option 0 +│ ● Option 1 +│ ○ Option 2 +│ ... +└ +", + "", + "", + "", + "│ ○ Option 1 +│ ● Option 2 +│ ... +└ +", + "", + "", + "", + "│ ... +│ ● Option 3 +│ ○ Option 4 +│ ... +└ +", + "", + "", + "", + "│ ● Option 4 +│ ○ Option 5 +│ ... +└ +", + "", + "", + "", + "◇ This is a very +│ long message that +│ will wrap to +│ multiple lines +│ Option 4", + " +", + "", +] +`; + +exports[`select (isCI = false) > correctly limits options with explicit multiline message 1`] = ` +[ + "", + "│ +◆ Choose an option: +│ Line 2 of the message +│ Line 3 of the message +│ ● Option 0 +│ ○ Option 1 +│ ○ Option 2 +│ ○ Option 3 +│ ... +└ +", + "", + "", + "", + "│ ○ Option 0 +│ ● Option 1 +│ ○ Option 2 +│ ○ Option 3 +│ ... +└ +", + "", + "", + "", + "│ ○ Option 1 +│ ● Option 2 +│ ○ Option 3 +│ ... +└ +", + "", + "", + "", + "│ ... +│ ○ Option 2 +│ ● Option 3 +│ ○ Option 4 +│ ... +└ +", + "", + "", + "", + "◇ Choose an option: +│ Line 2 of the message +│ Line 3 of the message +│ Option 3", + " +", + "", +] +`; + exports[`select (isCI = false) > down arrow selects next option 1`] = ` [ "", @@ -362,6 +480,124 @@ exports[`select (isCI = true) > can cancel 1`] = ` ] `; +exports[`select (isCI = true) > correctly limits options when message wraps to multiple lines 1`] = ` +[ + "", + "│ +◆ This is a very +│ long message that +│ will wrap to +│ multiple lines +│ ● Option 0 +│ ○ Option 1 +│ ○ Option 2 +│ ... +└ +", + "", + "", + "", + "│ ○ Option 0 +│ ● Option 1 +│ ○ Option 2 +│ ... +└ +", + "", + "", + "", + "│ ○ Option 1 +│ ● Option 2 +│ ... +└ +", + "", + "", + "", + "│ ... +│ ● Option 3 +│ ○ Option 4 +│ ... +└ +", + "", + "", + "", + "│ ● Option 4 +│ ○ Option 5 +│ ... +└ +", + "", + "", + "", + "◇ This is a very +│ long message that +│ will wrap to +│ multiple lines +│ Option 4", + " +", + "", +] +`; + +exports[`select (isCI = true) > correctly limits options with explicit multiline message 1`] = ` +[ + "", + "│ +◆ Choose an option: +│ Line 2 of the message +│ Line 3 of the message +│ ● Option 0 +│ ○ Option 1 +│ ○ Option 2 +│ ○ Option 3 +│ ... +└ +", + "", + "", + "", + "│ ○ Option 0 +│ ● Option 1 +│ ○ Option 2 +│ ○ Option 3 +│ ... +└ +", + "", + "", + "", + "│ ○ Option 1 +│ ● Option 2 +│ ○ Option 3 +│ ... +└ +", + "", + "", + "", + "│ ... +│ ○ Option 2 +│ ● Option 3 +│ ○ Option 4 +│ ... +└ +", + "", + "", + "", + "◇ Choose an option: +│ Line 2 of the message +│ Line 3 of the message +│ Option 3", + " +", + "", +] +`; + exports[`select (isCI = true) > down arrow selects next option 1`] = ` [ "", diff --git a/packages/prompts/test/limit-options.test.ts b/packages/prompts/test/limit-options.test.ts index f48f4a7a..c9a5faa3 100644 --- a/packages/prompts/test/limit-options.test.ts +++ b/packages/prompts/test/limit-options.test.ts @@ -241,4 +241,54 @@ describe('limitOptions', () => { const result = limitOptions(options); expect(result).toEqual(['Item 1', '-- Item 2 --', 'Item 3']); }); + + test('respects custom rowPadding', async () => { + options.options = [ + { value: 'Item 1' }, + { value: 'Item 2' }, + { value: 'Item 3' }, + { value: 'Item 4' }, + { value: 'Item 5' }, + { value: 'Item 6' }, + { value: 'Item 7' }, + { value: 'Item 8' }, + { value: 'Item 9' }, + { value: 'Item 10' }, + ]; + output.rows = 12; + options.rowPadding = 6; + // Available rows for options = 12 - 6 = 6 + const result = limitOptions(options); + expect(result).toEqual(['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5', color.dim('...')]); + }); + + test('respects custom rowPadding when scrolling', async () => { + options.options = [ + { value: 'Item 1' }, + { value: 'Item 2' }, + { value: 'Item 3' }, + { value: 'Item 4' }, + { value: 'Item 5' }, + { value: 'Item 6' }, + { value: 'Item 7' }, + { value: 'Item 8' }, + { value: 'Item 9' }, + { value: 'Item 10' }, + ]; + output.rows = 12; + // Simulate a multiline message that takes 6 lines + options.rowPadding = 6; + // Move cursor to middle of list + options.cursor = 5; + // Available rows for options = 12 - 6 = 6 + const result = limitOptions(options); + expect(result).toEqual([ + color.dim('...'), + 'Item 4', + 'Item 5', + 'Item 6', + 'Item 7', + color.dim('...'), + ]); + }); }); diff --git a/packages/prompts/test/select.test.ts b/packages/prompts/test/select.test.ts index ac1130af..ad783601 100644 --- a/packages/prompts/test/select.test.ts +++ b/packages/prompts/test/select.test.ts @@ -275,4 +275,59 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + + test('correctly limits options when message wraps to multiple lines', async () => { + // Simulate a narrow terminal that forces the message to wrap + output.columns = 30; + output.rows = 12; + + const result = prompts.select({ + // Long message that will wrap to multiple lines in a 30-column terminal + message: 'This is a very long message that will wrap to multiple lines', + options: Array.from({ length: 10 }, (_, i) => ({ + value: `opt${i}`, + label: `Option ${i}`, + })), + input, + output, + }); + + // Scroll down through options to trigger the bug scenario + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('opt4'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('correctly limits options with explicit multiline message', async () => { + output.rows = 12; + + const result = prompts.select({ + // Explicit multiline message + message: 'Choose an option:\nLine 2 of the message\nLine 3 of the message', + options: Array.from({ length: 10 }, (_, i) => ({ + value: `opt${i}`, + label: `Option ${i}`, + })), + input, + output, + }); + + // Scroll down to test that options don't overflow + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('opt3'); + expect(output.buffer).toMatchSnapshot(); + }); });