From 63f3147880c33ef31eaa3f9e158107fe3165cc52 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:12:28 +0000 Subject: [PATCH 1/2] fix: wrap messages in select prompts Adds wrapping to select prompts so messages and options wrap correctly. --- .changeset/plenty-snakes-ring.md | 5 + packages/prompts/src/common.ts | 14 +++ packages/prompts/src/select.ts | 58 +++++++-- .../test/__snapshots__/select.test.ts.snap | 114 ++++++++++++++++++ packages/prompts/test/select.test.ts | 46 +++++++ 5 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 .changeset/plenty-snakes-ring.md 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/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..c55740bf 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -1,4 +1,6 @@ -import { SelectPrompt } from '@clack/core'; +import type { Writable } from 'node:stream'; +import { getColumns, SelectPrompt } from '@clack/core'; +import { wrapAnsi } from 'fast-wrap-ansi'; import color from 'picocolors'; import { type CommonOptions, @@ -7,6 +9,7 @@ import { S_RADIO_ACTIVE, S_RADIO_INACTIVE, symbol, + symbolBar, } from './common.js'; import { limitOptions } from './limit-options.js'; @@ -102,16 +105,53 @@ 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 output: Writable = opts?.output ?? process.stdout; + const columns = getColumns(output); + const titlePrefix = `${symbol(this.state)} `; + const titlePrefixBar = `${symbolBar(this.state)} `; + const wrappedMessage = wrapAnsi( + opts.message, + columns - Math.max(titlePrefix.length, titlePrefixBar.length), + { + hard: true, + trim: false, + } + ); + const messageLines = wrappedMessage + .split('\n') + .map((line, index) => { + return `${index === 0 ? titlePrefix : titlePrefixBar}${line}`; + }) + .join('\n'); + 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 wrappedOption = wrapAnsi( + opt(this.options[this.cursor], 'selected'), + columns - submitPrefix.length, + { hard: true, trim: false } + ); + const wrappedLines = wrappedOption + .split('\n') + .map((line) => `${submitPrefix}${line}`) + .join('\n'); + return `${title}${wrappedLines}`; + } + case 'cancel': { + const cancelPrefix = `${color.gray(S_BAR)} `; + const wrappedOption = wrapAnsi( + opt(this.options[this.cursor], 'cancelled'), + columns - cancelPrefix.length, + { hard: true, trim: false } + ); + const wrappedLines = wrappedOption + .split('\n') + .map((line) => `${cancelPrefix}${line}`) + .join('\n'); + 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..55a387d4 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' }); + + const value = 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' }); + + const value = await result; + + expect(output.buffer).toMatchSnapshot(); + }); }); From 586002a2d9d74108c5f40ed1fbdfe71e23a5f571 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 7 Nov 2025 12:10:45 +0000 Subject: [PATCH 2/2] chore: extract wrap function to utils --- packages/core/src/index.ts | 2 +- packages/core/src/utils/index.ts | 21 ++++++++++++++ packages/prompts/src/select.ts | 42 ++++++++-------------------- packages/prompts/test/select.test.ts | 4 +-- 4 files changed, 35 insertions(+), 34 deletions(-) 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/select.ts b/packages/prompts/src/select.ts index c55740bf..466ee0f5 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -1,6 +1,4 @@ -import type { Writable } from 'node:stream'; -import { getColumns, SelectPrompt } from '@clack/core'; -import { wrapAnsi } from 'fast-wrap-ansi'; +import { SelectPrompt, wrapTextWithPrefix } from '@clack/core'; import color from 'picocolors'; import { type CommonOptions, @@ -105,51 +103,33 @@ export const select = (opts: SelectOptions) => { output: opts.output, initialValue: opts.initialValue, render() { - const output: Writable = opts?.output ?? process.stdout; - const columns = getColumns(output); const titlePrefix = `${symbol(this.state)} `; const titlePrefixBar = `${symbolBar(this.state)} `; - const wrappedMessage = wrapAnsi( + const messageLines = wrapTextWithPrefix( + opts.output, opts.message, - columns - Math.max(titlePrefix.length, titlePrefixBar.length), - { - hard: true, - trim: false, - } + titlePrefixBar, + titlePrefix ); - const messageLines = wrappedMessage - .split('\n') - .map((line, index) => { - return `${index === 0 ? titlePrefix : titlePrefixBar}${line}`; - }) - .join('\n'); const title = `${color.gray(S_BAR)}\n${messageLines}\n`; switch (this.state) { case 'submit': { const submitPrefix = `${color.gray(S_BAR)} `; - const wrappedOption = wrapAnsi( + const wrappedLines = wrapTextWithPrefix( + opts.output, opt(this.options[this.cursor], 'selected'), - columns - submitPrefix.length, - { hard: true, trim: false } + submitPrefix ); - const wrappedLines = wrappedOption - .split('\n') - .map((line) => `${submitPrefix}${line}`) - .join('\n'); return `${title}${wrappedLines}`; } case 'cancel': { const cancelPrefix = `${color.gray(S_BAR)} `; - const wrappedOption = wrapAnsi( + const wrappedLines = wrapTextWithPrefix( + opts.output, opt(this.options[this.cursor], 'cancelled'), - columns - cancelPrefix.length, - { hard: true, trim: false } + cancelPrefix ); - const wrappedLines = wrappedOption - .split('\n') - .map((line) => `${cancelPrefix}${line}`) - .join('\n'); return `${title}${wrappedLines}\n${color.gray(S_BAR)}`; } default: { diff --git a/packages/prompts/test/select.test.ts b/packages/prompts/test/select.test.ts index 55a387d4..10b73dc5 100644 --- a/packages/prompts/test/select.test.ts +++ b/packages/prompts/test/select.test.ts @@ -184,7 +184,7 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { input.emit('keypress', '', { name: 'return' }); - const value = await result; + await result; expect(output.buffer).toMatchSnapshot(); }); @@ -207,7 +207,7 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { input.emit('keypress', 'escape', { name: 'escape' }); - const value = await result; + await result; expect(output.buffer).toMatchSnapshot(); });