diff --git a/.changeset/plenty-snakes-ring.md b/.changeset/plenty-snakes-ring.md new file mode 100644 index 00000000..ca27a8bc --- /dev/null +++ b/.changeset/plenty-snakes-ring.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": patch +--- + +Fixes wrapping of cancelled and success messages of select prompt diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 139954a6..41336b98 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,6 @@ export { default as SelectPrompt } from './prompts/select.js'; export { default as SelectKeyPrompt } from './prompts/select-key.js'; export { default as TextPrompt } from './prompts/text.js'; export type { ClackState as State } from './types.js'; -export { block, getColumns, getRows, isCancel } from './utils/index.js'; +export { block, getColumns, getRows, isCancel, wrapTextWithPrefix } from './utils/index.js'; export type { ClackSettings } from './utils/settings.js'; export { settings, updateSettings } from './utils/settings.js'; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 1c102d2a..3bb9aac6 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -3,6 +3,7 @@ import type { Key } from 'node:readline'; import * as readline from 'node:readline'; import type { Readable, Writable } from 'node:stream'; import { ReadStream } from 'node:tty'; +import { wrapAnsi } from 'fast-wrap-ansi'; import { cursor } from 'sisteransi'; import { isActionKey } from './settings.js'; @@ -96,3 +97,23 @@ export const getRows = (output: Writable): number => { } return 20; }; + +export function wrapTextWithPrefix( + output: Writable | undefined, + text: string, + prefix: string, + startPrefix: string = prefix +): string { + const columns = getColumns(output ?? stdout); + const wrapped = wrapAnsi(text, columns - prefix.length, { + hard: true, + trim: false, + }); + const lines = wrapped + .split('\n') + .map((line, index) => { + return `${index === 0 ? startPrefix : prefix}${line}`; + }) + .join('\n'); + return lines; +} diff --git a/packages/prompts/src/common.ts b/packages/prompts/src/common.ts index 57670ab3..2489a815 100644 --- a/packages/prompts/src/common.ts +++ b/packages/prompts/src/common.ts @@ -53,6 +53,20 @@ export const symbol = (state: State) => { } }; +export const symbolBar = (state: State) => { + switch (state) { + case 'initial': + case 'active': + return color.cyan(S_BAR); + case 'cancel': + return color.red(S_BAR); + case 'error': + return color.yellow(S_BAR); + case 'submit': + return color.green(S_BAR); + } +}; + export interface CommonOptions { input?: Readable; output?: Writable; diff --git a/packages/prompts/src/select.ts b/packages/prompts/src/select.ts index b091161c..466ee0f5 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -1,4 +1,4 @@ -import { SelectPrompt } from '@clack/core'; +import { SelectPrompt, wrapTextWithPrefix } from '@clack/core'; import color from 'picocolors'; import { type CommonOptions, @@ -7,6 +7,7 @@ import { S_RADIO_ACTIVE, S_RADIO_INACTIVE, symbol, + symbolBar, } from './common.js'; import { limitOptions } from './limit-options.js'; @@ -102,16 +103,35 @@ export const select = (opts: SelectOptions) => { output: opts.output, initialValue: opts.initialValue, render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const titlePrefix = `${symbol(this.state)} `; + const titlePrefixBar = `${symbolBar(this.state)} `; + const messageLines = wrapTextWithPrefix( + opts.output, + opts.message, + titlePrefixBar, + titlePrefix + ); + const title = `${color.gray(S_BAR)}\n${messageLines}\n`; switch (this.state) { - case 'submit': - return `${title}${color.gray(S_BAR)} ${opt(this.options[this.cursor], 'selected')}`; - case 'cancel': - return `${title}${color.gray(S_BAR)} ${opt( - this.options[this.cursor], - 'cancelled' - )}\n${color.gray(S_BAR)}`; + case 'submit': { + const submitPrefix = `${color.gray(S_BAR)} `; + const wrappedLines = wrapTextWithPrefix( + opts.output, + opt(this.options[this.cursor], 'selected'), + submitPrefix + ); + return `${title}${wrappedLines}`; + } + case 'cancel': { + const cancelPrefix = `${color.gray(S_BAR)} `; + const wrappedLines = wrapTextWithPrefix( + opts.output, + opt(this.options[this.cursor], 'cancelled'), + cancelPrefix + ); + return `${title}${wrappedLines}\n${color.gray(S_BAR)}`; + } default: { const prefix = `${color.cyan(S_BAR)} `; return `${title}${prefix}${limitOptions({ diff --git a/packages/prompts/test/__snapshots__/select.test.ts.snap b/packages/prompts/test/__snapshots__/select.test.ts.snap index bd8238d0..805c5f63 100644 --- a/packages/prompts/test/__snapshots__/select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/select.test.ts.snap @@ -178,6 +178,63 @@ exports[`select (isCI = false) > up arrow selects previous option 1`] = ` ] `; +exports[`select (isCI = false) > wraps long cancelled message 1`] = ` +[ + "", + "│ +◆ foo +│ ● foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ foo foo foo foo +│ ○ Option 1 +└ +", + "", + "", + "", + "■ foo +│ foo foo foo foo foo foo foo +│  foo foo foo foo foo foo  +│ foo foo foo foo foo foo foo +│  foo foo foo foo foo foo  +│ foo foo foo foo +│", + " +", + "", +] +`; + +exports[`select (isCI = false) > wraps long results 1`] = ` +[ + "", + "│ +◆ foo +│ ● foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ foo foo foo foo +│ ○ Option 1 +└ +", + "", + "", + "", + "◇ foo +│ foo foo foo foo foo foo foo +│  foo foo foo foo foo foo  +│ foo foo foo foo foo foo foo +│  foo foo foo foo foo foo  +│ foo foo foo foo", + " +", + "", +] +`; + exports[`select (isCI = true) > can be aborted by a signal 1`] = ` [ "", @@ -355,3 +412,60 @@ exports[`select (isCI = true) > up arrow selects previous option 1`] = ` "", ] `; + +exports[`select (isCI = true) > wraps long cancelled message 1`] = ` +[ + "", + "│ +◆ foo +│ ● foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ foo foo foo foo +│ ○ Option 1 +└ +", + "", + "", + "", + "■ foo +│ foo foo foo foo foo foo foo +│  foo foo foo foo foo foo  +│ foo foo foo foo foo foo foo +│  foo foo foo foo foo foo  +│ foo foo foo foo +│", + " +", + "", +] +`; + +exports[`select (isCI = true) > wraps long results 1`] = ` +[ + "", + "│ +◆ foo +│ ● foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ foo foo foo foo +│ ○ Option 1 +└ +", + "", + "", + "", + "◇ foo +│ foo foo foo foo foo foo foo +│  foo foo foo foo foo foo  +│ foo foo foo foo foo foo foo +│  foo foo foo foo foo foo  +│ foo foo foo foo", + " +", + "", +] +`; diff --git a/packages/prompts/test/select.test.ts b/packages/prompts/test/select.test.ts index 24cbc726..10b73dc5 100644 --- a/packages/prompts/test/select.test.ts +++ b/packages/prompts/test/select.test.ts @@ -165,4 +165,50 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { expect(value).toBe('opt1'); expect(output.buffer).toMatchSnapshot(); }); + + test('wraps long results', async () => { + output.columns = 40; + + const result = prompts.select({ + message: 'foo', + options: [ + { + value: 'opt0', + label: 'foo '.repeat(30).trim(), + }, + { value: 'opt1', label: 'Option 1' }, + ], + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('wraps long cancelled message', async () => { + output.columns = 40; + + const result = prompts.select({ + message: 'foo', + options: [ + { + value: 'opt0', + label: 'foo '.repeat(30).trim(), + }, + { value: 'opt1', label: 'Option 1' }, + ], + input, + output, + }); + + input.emit('keypress', 'escape', { name: 'escape' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); });