From fdc1f55cbcd9b60a97e6b62c1792f42eea0543cf Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 7 Nov 2025 12:11:17 +0000 Subject: [PATCH] fix: wrapping in multi-selects This fixes text wrapping in the multi-select prompt (e.g. success/cancel text is now wrapped, and multi-line messages). --- .changeset/fine-swans-retire.md | 5 + packages/prompts/src/multi-select.ts | 47 +++-- .../__snapshots__/multi-select.test.ts.snap | 196 ++++++++++++++++++ packages/prompts/test/multi-select.test.ts | 63 ++++++ 4 files changed, 298 insertions(+), 13 deletions(-) create mode 100644 .changeset/fine-swans-retire.md diff --git a/.changeset/fine-swans-retire.md b/.changeset/fine-swans-retire.md new file mode 100644 index 00000000..22753dc6 --- /dev/null +++ b/.changeset/fine-swans-retire.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": patch +--- + +Add support for wrapped messages in multi line prompts diff --git a/packages/prompts/src/multi-select.ts b/packages/prompts/src/multi-select.ts index 75ca9045..491b7218 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -1,4 +1,4 @@ -import { MultiSelectPrompt } from '@clack/core'; +import { MultiSelectPrompt, wrapTextWithPrefix } from '@clack/core'; import color from 'picocolors'; import { type CommonOptions, @@ -8,6 +8,7 @@ import { S_CHECKBOX_INACTIVE, S_CHECKBOX_SELECTED, symbol, + symbolBar, } from './common.js'; import { limitOptions } from './limit-options.js'; import type { Option } from './select.js'; @@ -20,6 +21,13 @@ export interface MultiSelectOptions extends CommonOptions { required?: boolean; cursorAt?: Value; } +const computeLabel = (label: string, format: (text: string) => string) => { + return label + .split('\n') + .map((line) => format(line)) + .join('\n'); +}; + export const multiselect = (opts: MultiSelectOptions) => { const opt = ( option: Option, @@ -34,7 +42,7 @@ export const multiselect = (opts: MultiSelectOptions) => { ) => { const label = option.label ?? String(option.value); if (state === 'disabled') { - return `${color.gray(S_CHECKBOX_INACTIVE)} ${color.gray(label)}${ + return `${color.gray(S_CHECKBOX_INACTIVE)} ${computeLabel(label, color.gray)}${ option.hint ? ` ${color.dim(`(${option.hint ?? 'disabled'})`)}` : '' }`; } @@ -44,12 +52,12 @@ export const multiselect = (opts: MultiSelectOptions) => { }`; } if (state === 'selected') { - return `${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}${ + return `${color.green(S_CHECKBOX_SELECTED)} ${computeLabel(label, color.dim)}${ option.hint ? ` ${color.dim(`(${option.hint})`)}` : '' }`; } if (state === 'cancelled') { - return `${color.strikethrough(color.dim(label))}`; + return `${computeLabel(label, (text) => color.strikethrough(color.dim(text)))}`; } if (state === 'active-selected') { return `${color.green(S_CHECKBOX_SELECTED)} ${label}${ @@ -57,9 +65,9 @@ export const multiselect = (opts: MultiSelectOptions) => { }`; } if (state === 'submitted') { - return `${color.dim(label)}`; + return `${computeLabel(label, color.dim)}`; } - return `${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`; + return `${color.dim(S_CHECKBOX_INACTIVE)} ${computeLabel(label, color.dim)}`; }; const required = opts.required ?? true; @@ -82,7 +90,13 @@ export const multiselect = (opts: MultiSelectOptions) => { )}`; }, render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const wrappedMessage = wrapTextWithPrefix( + opts.output, + opts.message, + `${symbolBar(this.state)} `, + `${symbol(this.state)} ` + ); + const title = `${color.gray(S_BAR)}\n${wrappedMessage}\n`; const value = this.value ?? []; const styleOption = (option: Option, active: boolean) => { @@ -101,21 +115,28 @@ export const multiselect = (opts: MultiSelectOptions) => { switch (this.state) { case 'submit': { - return `${title}${color.gray(S_BAR)} ${ + const submitText = this.options .filter(({ value: optionValue }) => value.includes(optionValue)) .map((option) => opt(option, 'submitted')) - .join(color.dim(', ')) || color.dim('none') - }`; + .join(color.dim(', ')) || color.dim('none'); + const wrappedSubmitText = wrapTextWithPrefix( + opts.output, + submitText, + `${color.gray(S_BAR)} ` + ); + return `${title}${wrappedSubmitText}`; } case 'cancel': { const label = this.options .filter(({ value: optionValue }) => value.includes(optionValue)) .map((option) => opt(option, 'cancelled')) .join(color.dim(', ')); - return `${title}${color.gray(S_BAR)}${ - label.trim() ? ` ${label}\n${color.gray(S_BAR)}` : '' - }`; + if (label.trim() === '') { + return `${title}${color.gray(S_BAR)}`; + } + const wrappedLabel = wrapTextWithPrefix(opts.output, label, `${color.gray(S_BAR)} `); + return `${title}${wrappedLabel}\n${color.gray(S_BAR)}`; } case 'error': { const prefix = `${color.yellow(S_BAR)} `; diff --git a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap index a0028fd4..0a064065 100644 --- a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap @@ -636,6 +636,104 @@ exports[`multiselect (isCI = false) > sliding window loops upwards 1`] = ` ] `; +exports[`multiselect (isCI = false) > wraps cancelled state with long options 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ Option 0 Option 0 Option +│ 0 Option 0 Option 0 Option +│ 0 Option 0 Option 0 Option +│ 0 Option 0 +│ ◻ Option 1 Option 1 Option  +│ 1 Option 1 Option 1 Option  +│ 1 Option 1 Option 1 Option  +│ 1 Option 1 +└ +", + "", + "", + "", + "│ ◼ Option 0 Option 0 Option ", + "", + "", + "", + "", + "■ foo +│ Option 0 Option 0 Option 0  +│ Option 0 Option 0 Option 0  +│ Option 0 Option 0 Option 0  +│ Option 0 +│", + " +", + "", +] +`; + +exports[`multiselect (isCI = false) > wraps long messages 1`] = ` +[ + "", + "│ +◆ foo foo foo foo foo foo foo +│ foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ ◻ opt0 +│ ◻ opt1 +└ +", + "", + "", + "", + "│ ◼ opt0", + "", + "", + "", + "", + "◇ foo foo foo foo foo foo foo +│ foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ opt0", + " +", + "", +] +`; + +exports[`multiselect (isCI = false) > wraps success state with long options 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ Option 0 Option 0 Option +│ 0 Option 0 Option 0 Option +│ 0 Option 0 Option 0 Option +│ 0 Option 0 +│ ◻ Option 1 Option 1 Option  +│ 1 Option 1 Option 1 Option  +│ 1 Option 1 Option 1 Option  +│ 1 Option 1 +└ +", + "", + "", + "", + "│ ◼ Option 0 Option 0 Option ", + "", + "", + "", + "", + "◇ foo +│ Option 0 Option 0 Option 0  +│ Option 0 Option 0 Option 0  +│ Option 0 Option 0 Option 0  +│ Option 0", + " +", + "", +] +`; + exports[`multiselect (isCI = true) > can be aborted by a signal 1`] = ` [ "", @@ -1271,3 +1369,101 @@ exports[`multiselect (isCI = true) > sliding window loops upwards 1`] = ` "", ] `; + +exports[`multiselect (isCI = true) > wraps cancelled state with long options 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ Option 0 Option 0 Option +│ 0 Option 0 Option 0 Option +│ 0 Option 0 Option 0 Option +│ 0 Option 0 +│ ◻ Option 1 Option 1 Option  +│ 1 Option 1 Option 1 Option  +│ 1 Option 1 Option 1 Option  +│ 1 Option 1 +└ +", + "", + "", + "", + "│ ◼ Option 0 Option 0 Option ", + "", + "", + "", + "", + "■ foo +│ Option 0 Option 0 Option 0  +│ Option 0 Option 0 Option 0  +│ Option 0 Option 0 Option 0  +│ Option 0 +│", + " +", + "", +] +`; + +exports[`multiselect (isCI = true) > wraps long messages 1`] = ` +[ + "", + "│ +◆ foo foo foo foo foo foo foo +│ foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ ◻ opt0 +│ ◻ opt1 +└ +", + "", + "", + "", + "│ ◼ opt0", + "", + "", + "", + "", + "◇ foo foo foo foo foo foo foo +│ foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ opt0", + " +", + "", +] +`; + +exports[`multiselect (isCI = true) > wraps success state with long options 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ Option 0 Option 0 Option +│ 0 Option 0 Option 0 Option +│ 0 Option 0 Option 0 Option +│ 0 Option 0 +│ ◻ Option 1 Option 1 Option  +│ 1 Option 1 Option 1 Option  +│ 1 Option 1 Option 1 Option  +│ 1 Option 1 +└ +", + "", + "", + "", + "│ ◼ Option 0 Option 0 Option ", + "", + "", + "", + "", + "◇ foo +│ Option 0 Option 0 Option 0  +│ Option 0 Option 0 Option 0  +│ Option 0 Option 0 Option 0  +│ Option 0", + " +", + "", +] +`; diff --git a/packages/prompts/test/multi-select.test.ts b/packages/prompts/test/multi-select.test.ts index 95412ade..e29dd791 100644 --- a/packages/prompts/test/multi-select.test.ts +++ b/packages/prompts/test/multi-select.test.ts @@ -336,4 +336,67 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { expect(value).toEqual(['opt1']); expect(output.buffer).toMatchSnapshot(); }); + + test('wraps long messages', async () => { + output.columns = 40; + + const result = prompts.multiselect({ + message: 'foo '.repeat(20).trim(), + options: [{ value: 'opt0' }, { value: 'opt1' }], + input, + output, + }); + + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toEqual(['opt0']); + expect(output.buffer).toMatchSnapshot(); + }); + + test('wraps cancelled state with long options', async () => { + output.columns = 40; + + const result = prompts.multiselect({ + message: 'foo', + options: [ + { value: 'opt0', label: 'Option 0 '.repeat(10).trim() }, + { value: 'opt1', label: 'Option 1 '.repeat(10).trim() }, + ], + input, + output, + }); + + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', 'escape', { name: 'escape' }); + + const value = await result; + + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('wraps success state with long options', async () => { + output.columns = 40; + + const result = prompts.multiselect({ + message: 'foo', + options: [ + { value: 'opt0', label: 'Option 0 '.repeat(10).trim() }, + { value: 'opt1', label: 'Option 1 '.repeat(10).trim() }, + ], + input, + output, + }); + + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toEqual(['opt0']); + expect(output.buffer).toMatchSnapshot(); + }); });