From 38019c786efc28951a5921f26634cf4c4392367f Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Wed, 22 Oct 2025 16:38:04 +0200 Subject: [PATCH 1/5] feat: Refactor the API for stopping spinners & progress bars (#405) --- .changeset/odd-bikes-nail.md | 27 ++++++++ packages/prompts/src/progress-bar.ts | 2 + packages/prompts/src/spinner.ts | 14 +++- .../__snapshots__/progress-bar.test.ts.snap | 68 +++++++++++++++++-- .../test/__snapshots__/spinner.test.ts.snap | 68 +++++++++++++++++-- packages/prompts/test/progress-bar.test.ts | 32 +++++++-- packages/prompts/test/spinner.test.ts | 32 +++++++-- 7 files changed, 224 insertions(+), 19 deletions(-) create mode 100644 .changeset/odd-bikes-nail.md diff --git a/.changeset/odd-bikes-nail.md b/.changeset/odd-bikes-nail.md new file mode 100644 index 0000000..349e6ed --- /dev/null +++ b/.changeset/odd-bikes-nail.md @@ -0,0 +1,27 @@ +--- +"@clack/prompts": minor +--- + +Updates the API for stopping spinners and progress bars to be clearer + +Previously, both the spinner and progress bar components used a single `stop` method that accepted a code to indicate success, cancellation, or error. This update separates these into distinct methods: `stop()`, `cancel()`, and `error()`: + +```diff +const spinner = prompts.spinner(); +spinner.start(); + +// Cancelling a spinner +- spinner.stop(undefined, 1); ++ spinner.cancel(); + +// Stopping with an error +- spinner.stop(undefined, 2); ++ spinner.error(); +``` + +As before, you can pass a message to each method to customize the output displayed: + +```js +spinner.cancel("Operation cancelled by user"); +progressBar.error("An error occurred during processing"); +``` diff --git a/packages/prompts/src/progress-bar.ts b/packages/prompts/src/progress-bar.ts index 8801833..8859514 100644 --- a/packages/prompts/src/progress-bar.ts +++ b/packages/prompts/src/progress-bar.ts @@ -63,6 +63,8 @@ export function progress({ return { start, stop: spin.stop, + cancel: spin.cancel, + error: spin.error, advance, isCancelled: spin.isCancelled, message: (msg: string) => advance(0, msg), diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 498a1f5..65dd591 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -24,7 +24,9 @@ export interface SpinnerOptions extends CommonOptions { export interface SpinnerResult { start(msg?: string): void; - stop(msg?: string, code?: number): void; + stop(msg?: string): void; + cancel(msg?: string): void; + error(msg?: string): void; message(msg?: string): void; readonly isCancelled: boolean; } @@ -61,7 +63,7 @@ export const spinner = ({ : (cancelMessage ?? settings.messages.cancel); isCancelled = code === 1; if (isSpinnerActive) { - stop(msg, code); + _stop(msg, code); if (isCancelled && typeof onCancel === 'function') { onCancel(); } @@ -163,7 +165,7 @@ export const spinner = ({ }, delay); }; - const stop = (msg = '', code = 0): void => { + const _stop = (msg = '', code = 0): void => { if (!isSpinnerActive) return; isSpinnerActive = false; clearInterval(loop); @@ -184,6 +186,10 @@ export const spinner = ({ unblock(); }; + const stop = (msg = ''): void => _stop(msg, 0); + const cancel = (msg = ''): void => _stop(msg, 1); + const error = (msg = ''): void => _stop(msg, 2); + const message = (msg = ''): void => { _message = removeTrailingDots(msg ?? _message); }; @@ -192,6 +198,8 @@ export const spinner = ({ start, stop, message, + cancel, + error, get isCancelled() { return isCancelled; }, diff --git a/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap b/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap index 72508fb..8296f51 100644 --- a/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap +++ b/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap @@ -125,7 +125,7 @@ exports[`prompts - progress (isCI = false) > start > renders timer when indicato ] `; -exports[`prompts - progress (isCI = false) > stop > renders cancel symbol if code = 1 1`] = ` +exports[`prompts - progress (isCI = false) > stop > renders cancel symbol when calling cancel() 1`] = ` [ "", "│ @@ -139,7 +139,7 @@ exports[`prompts - progress (isCI = false) > stop > renders cancel symbol if cod ] `; -exports[`prompts - progress (isCI = false) > stop > renders error symbol if code > 1 1`] = ` +exports[`prompts - progress (isCI = false) > stop > renders error symbol when calling error() 1`] = ` [ "", "│ @@ -167,6 +167,34 @@ exports[`prompts - progress (isCI = false) > stop > renders message 1`] = ` ] `; +exports[`prompts - progress (isCI = false) > stop > renders message when cancelling 1`] = ` +[ + "", + "│ +", + "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", + "", + "", + "■ cancelled :-( +", + "", +] +`; + +exports[`prompts - progress (isCI = false) > stop > renders message when erroring 1`] = ` +[ + "", + "│ +", + "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", + "", + "", + "▲ FATAL ERROR! +", + "", +] +`; + exports[`prompts - progress (isCI = false) > stop > renders message without removing dots 1`] = ` [ "", @@ -382,7 +410,7 @@ exports[`prompts - progress (isCI = true) > start > renders timer when indicator ] `; -exports[`prompts - progress (isCI = true) > stop > renders cancel symbol if code = 1 1`] = ` +exports[`prompts - progress (isCI = true) > stop > renders cancel symbol when calling cancel() 1`] = ` [ "", "│ @@ -398,7 +426,7 @@ exports[`prompts - progress (isCI = true) > stop > renders cancel symbol if code ] `; -exports[`prompts - progress (isCI = true) > stop > renders error symbol if code > 1 1`] = ` +exports[`prompts - progress (isCI = true) > stop > renders error symbol when calling error() 1`] = ` [ "", "│ @@ -430,6 +458,38 @@ exports[`prompts - progress (isCI = true) > stop > renders message 1`] = ` ] `; +exports[`prompts - progress (isCI = true) > stop > renders message when cancelling 1`] = ` +[ + "", + "│ +", + "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", + " +", + "", + "", + "■ cancelled :-( +", + "", +] +`; + +exports[`prompts - progress (isCI = true) > stop > renders message when erroring 1`] = ` +[ + "", + "│ +", + "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", + " +", + "", + "", + "▲ FATAL ERROR! +", + "", +] +`; + exports[`prompts - progress (isCI = true) > stop > renders message without removing dots 1`] = ` [ "", diff --git a/packages/prompts/test/__snapshots__/spinner.test.ts.snap b/packages/prompts/test/__snapshots__/spinner.test.ts.snap index 62a94c3..3ee9e29 100644 --- a/packages/prompts/test/__snapshots__/spinner.test.ts.snap +++ b/packages/prompts/test/__snapshots__/spinner.test.ts.snap @@ -461,7 +461,7 @@ exports[`spinner (isCI = false) > start > renders timer when indicator is "timer ] `; -exports[`spinner (isCI = false) > stop > renders cancel symbol if code = 1 1`] = ` +exports[`spinner (isCI = false) > stop > renders cancel symbol when calling cancel() 1`] = ` [ "", "│ @@ -475,7 +475,7 @@ exports[`spinner (isCI = false) > stop > renders cancel symbol if code = 1 1`] = ] `; -exports[`spinner (isCI = false) > stop > renders error symbol if code > 1 1`] = ` +exports[`spinner (isCI = false) > stop > renders error symbol when calling error() 1`] = ` [ "", "│ @@ -503,6 +503,34 @@ exports[`spinner (isCI = false) > stop > renders message 1`] = ` ] `; +exports[`spinner (isCI = false) > stop > renders message when cancelling 1`] = ` +[ + "", + "│ +", + "◒ ", + "", + "", + "■ too dizzy — spinning cancelled +", + "", +] +`; + +exports[`spinner (isCI = false) > stop > renders message when erroring 1`] = ` +[ + "", + "│ +", + "◒ ", + "", + "", + "▲ error: spun too fast! +", + "", +] +`; + exports[`spinner (isCI = false) > stop > renders message without removing dots 1`] = ` [ "", @@ -789,7 +817,7 @@ exports[`spinner (isCI = true) > start > renders timer when indicator is "timer" ] `; -exports[`spinner (isCI = true) > stop > renders cancel symbol if code = 1 1`] = ` +exports[`spinner (isCI = true) > stop > renders cancel symbol when calling cancel() 1`] = ` [ "", "│ @@ -805,7 +833,7 @@ exports[`spinner (isCI = true) > stop > renders cancel symbol if code = 1 1`] = ] `; -exports[`spinner (isCI = true) > stop > renders error symbol if code > 1 1`] = ` +exports[`spinner (isCI = true) > stop > renders error symbol when calling error() 1`] = ` [ "", "│ @@ -837,6 +865,38 @@ exports[`spinner (isCI = true) > stop > renders message 1`] = ` ] `; +exports[`spinner (isCI = true) > stop > renders message when cancelling 1`] = ` +[ + "", + "│ +", + "◒ ...", + " +", + "", + "", + "■ too dizzy — spinning cancelled +", + "", +] +`; + +exports[`spinner (isCI = true) > stop > renders message when erroring 1`] = ` +[ + "", + "│ +", + "◒ ...", + " +", + "", + "", + "▲ error: spun too fast! +", + "", +] +`; + exports[`spinner (isCI = true) > stop > renders message without removing dots 1`] = ` [ "", diff --git a/packages/prompts/test/progress-bar.test.ts b/packages/prompts/test/progress-bar.test.ts index 007b9ce..a64061c 100644 --- a/packages/prompts/test/progress-bar.test.ts +++ b/packages/prompts/test/progress-bar.test.ts @@ -87,26 +87,26 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); - test('renders cancel symbol if code = 1', () => { + test('renders cancel symbol when calling cancel()', () => { const result = prompts.progress({ output }); result.start(); vi.advanceTimersByTime(80); - result.stop('', 1); + result.cancel(); expect(output.buffer).toMatchSnapshot(); }); - test('renders error symbol if code > 1', () => { + test('renders error symbol when calling error()', () => { const result = prompts.progress({ output }); result.start(); vi.advanceTimersByTime(80); - result.stop('', 2); + result.error(); expect(output.buffer).toMatchSnapshot(); }); @@ -134,6 +134,30 @@ describe.each(['true', 'false'])('prompts - progress (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + + test('renders message when cancelling', () => { + const result = prompts.progress({ output }); + + result.start(); + + vi.advanceTimersByTime(80); + + result.cancel('cancelled :-('); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders message when erroring', () => { + const result = prompts.progress({ output }); + + result.start(); + + vi.advanceTimersByTime(80); + + result.error('FATAL ERROR!'); + + expect(output.buffer).toMatchSnapshot(); + }); }); describe('message', () => { diff --git a/packages/prompts/test/spinner.test.ts b/packages/prompts/test/spinner.test.ts index 63ecc53..d0f771c 100644 --- a/packages/prompts/test/spinner.test.ts +++ b/packages/prompts/test/spinner.test.ts @@ -117,26 +117,26 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); - test('renders cancel symbol if code = 1', () => { + test('renders cancel symbol when calling cancel()', () => { const result = prompts.spinner({ output }); result.start(); vi.advanceTimersByTime(80); - result.stop('', 1); + result.cancel(); expect(output.buffer).toMatchSnapshot(); }); - test('renders error symbol if code > 1', () => { + test('renders error symbol when calling error()', () => { const result = prompts.spinner({ output }); result.start(); vi.advanceTimersByTime(80); - result.stop('', 2); + result.error(); expect(output.buffer).toMatchSnapshot(); }); @@ -165,6 +165,30 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + test('renders message when cancelling', () => { + const result = prompts.spinner({ output }); + + result.start(); + + vi.advanceTimersByTime(80); + + result.cancel('too dizzy — spinning cancelled'); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders message when erroring', () => { + const result = prompts.spinner({ output }); + + result.start(); + + vi.advanceTimersByTime(80); + + result.error('error: spun too fast!'); + + expect(output.buffer).toMatchSnapshot(); + }); + test('does not throw if called before start', () => { const result = prompts.spinner({ output }); -- 2.51.2 From 7530af07b7febe74c2d63f046ced8dc7bdaf3ab5 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 7 Nov 2025 12:13:14 +0000 Subject: [PATCH 2/5] fix: wrap messages in select prompts (#410) --- .changeset/plenty-snakes-ring.md | 5 + packages/core/src/index.ts | 2 +- packages/core/src/utils/index.ts | 21 ++++ packages/prompts/src/common.ts | 14 +++ packages/prompts/src/select.ts | 38 ++++-- .../test/__snapshots__/select.test.ts.snap | 114 ++++++++++++++++++ packages/prompts/test/select.test.ts | 46 +++++++ 7 files changed, 230 insertions(+), 10 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 0000000..ca27a8b --- /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 139954a..41336b9 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 1c102d2..3bb9aac 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 57670ab..2489a81 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 b091161..466ee0f 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 bd8238d..805c5f6 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 24cbc72..10b73dc 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(); + }); }); -- 2.51.2 From b0fa7d89758d92f7e7e7a947ac11ce675d3ef3a4 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 9 Nov 2025 09:57:51 +0100 Subject: [PATCH 3/5] fix: wrapping in multi-selects (#411) --- .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 0000000..22753dc --- /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 75ca904..491b721 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 a0028fd..0a06406 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 95412ad..e29dd79 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(); + }); }); -- 2.51.2 From 4d1d83bff121e1400b4b1f79c953a5098136d6d1 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 16 Nov 2025 15:25:05 +0000 Subject: [PATCH 4/5] fix: handle multi-line select options (#413) --- .changeset/busy-baths-work.md | 5 + packages/prompts/src/select.ts | 18 ++- .../test/__snapshots__/select.test.ts.snap | 106 ++++++++++++++++++ packages/prompts/test/select.test.ts | 37 ++++++ 4 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 .changeset/busy-baths-work.md diff --git a/.changeset/busy-baths-work.md b/.changeset/busy-baths-work.md new file mode 100644 index 0000000..5debded --- /dev/null +++ b/.changeset/busy-baths-work.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": patch +--- + +Fixes rendering of multi-line messages and options in select prompt. diff --git a/packages/prompts/src/select.ts b/packages/prompts/src/select.ts index 466ee0f..b6bb672 100644 --- a/packages/prompts/src/select.ts +++ b/packages/prompts/src/select.ts @@ -72,6 +72,16 @@ export interface SelectOptions extends CommonOptions { maxItems?: number; } +const computeLabel = (label: string, format: (text: string) => string) => { + if (!label.includes('\n')) { + return format(label); + } + return label + .split('\n') + .map((line) => format(line)) + .join('\n'); +}; + export const select = (opts: SelectOptions) => { const opt = ( option: Option, @@ -80,19 +90,19 @@ export const select = (opts: SelectOptions) => { const label = option.label ?? String(option.value); switch (state) { case 'disabled': - return `${color.gray(S_RADIO_INACTIVE)} ${color.gray(label)}${ + return `${color.gray(S_RADIO_INACTIVE)} ${computeLabel(label, color.gray)}${ option.hint ? ` ${color.dim(`(${option.hint ?? 'disabled'})`)}` : '' }`; case 'selected': - return `${color.dim(label)}`; + return `${computeLabel(label, color.dim)}`; case 'active': return `${color.green(S_RADIO_ACTIVE)} ${label}${ option.hint ? ` ${color.dim(`(${option.hint})`)}` : '' }`; case 'cancelled': - return `${color.strikethrough(color.dim(label))}`; + return `${computeLabel(label, (str) => color.strikethrough(color.dim(str)))}`; default: - return `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}`; + return `${color.dim(S_RADIO_INACTIVE)} ${computeLabel(label, color.dim)}`; } }; diff --git a/packages/prompts/test/__snapshots__/select.test.ts.snap b/packages/prompts/test/__snapshots__/select.test.ts.snap index 805c5f6..cd5e341 100644 --- a/packages/prompts/test/__snapshots__/select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/select.test.ts.snap @@ -84,6 +84,35 @@ exports[`select (isCI = false) > renders disabled options 1`] = ` ] `; +exports[`select (isCI = false) > renders multi-line option labels 1`] = ` +[ + "", + "│ +◆ foo +│ ● Option 0 +│ with multiple lines +│ ○ Option 1 +└ +", + "", + "", + "", + "│ ○ Option 0 +│ with multiple lines +│ ● Option 1 +└ +", + "", + "", + "", + "◇ foo +│ Option 1", + " +", + "", +] +`; + exports[`select (isCI = false) > renders option hints 1`] = ` [ "", @@ -207,6 +236,30 @@ exports[`select (isCI = false) > wraps long cancelled message 1`] = ` ] `; +exports[`select (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 +└ +", + "", + "", + "", + "◇ foo foo foo foo foo foo foo +│ foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ opt0", + " +", + "", +] +`; + exports[`select (isCI = false) > wraps long results 1`] = ` [ "", @@ -319,6 +372,35 @@ exports[`select (isCI = true) > renders disabled options 1`] = ` ] `; +exports[`select (isCI = true) > renders multi-line option labels 1`] = ` +[ + "", + "│ +◆ foo +│ ● Option 0 +│ with multiple lines +│ ○ Option 1 +└ +", + "", + "", + "", + "│ ○ Option 0 +│ with multiple lines +│ ● Option 1 +└ +", + "", + "", + "", + "◇ foo +│ Option 1", + " +", + "", +] +`; + exports[`select (isCI = true) > renders option hints 1`] = ` [ "", @@ -442,6 +524,30 @@ exports[`select (isCI = true) > wraps long cancelled message 1`] = ` ] `; +exports[`select (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 +└ +", + "", + "", + "", + "◇ foo foo foo foo foo foo foo +│ foo foo foo foo foo foo +│ foo foo foo foo foo foo foo +│ opt0", + " +", + "", +] +`; + exports[`select (isCI = true) > wraps long results 1`] = ` [ "", diff --git a/packages/prompts/test/select.test.ts b/packages/prompts/test/select.test.ts index 10b73dc..6347ad1 100644 --- a/packages/prompts/test/select.test.ts +++ b/packages/prompts/test/select.test.ts @@ -211,4 +211,41 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + + test('wraps long messages', async () => { + output.columns = 40; + + const result = prompts.select({ + message: 'foo '.repeat(20).trim(), + options: [{ value: 'opt0' }, { value: 'opt1' }], + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toEqual('opt0'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders multi-line option labels', async () => { + const result = prompts.select({ + message: 'foo', + options: [ + { value: 'opt0', label: 'Option 0\nwith multiple lines' }, + { value: 'opt1', label: 'Option 1' }, + ], + input, + output, + }); + + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); }); -- 2.51.2 From 4ba2d7830a66e1b6d51288d641946e37e61dd083 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 16 Nov 2025 19:09:37 +0000 Subject: [PATCH 5/5] fix: support off-screen lines when re-rendering (#414) --- .changeset/little-ghosts-retire.md | 5 ++ packages/core/src/prompts/prompt.ts | 67 ++++++++++------ packages/core/src/utils/string.ts | 10 ++- .../__snapshots__/autocomplete.test.ts.snap | 5 +- .../test/__snapshots__/select.test.ts.snap | 76 +++++++++++++++++++ packages/prompts/test/select.test.ts | 27 +++++++ 6 files changed, 163 insertions(+), 27 deletions(-) create mode 100644 .changeset/little-ghosts-retire.md diff --git a/.changeset/little-ghosts-retire.md b/.changeset/little-ghosts-retire.md new file mode 100644 index 0000000..61a5c4c --- /dev/null +++ b/.changeset/little-ghosts-retire.md @@ -0,0 +1,5 @@ +--- +"@clack/core": patch +--- + +Support short terminal windows when re-rendering by accounting for off-screen lines diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 5c996e2..b30deb0 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -5,7 +5,14 @@ import { wrapAnsi } from 'fast-wrap-ansi'; import { cursor, erase } from 'sisteransi'; import type { ClackEvents, ClackState } from '../types.js'; import type { Action } from '../utils/index.js'; -import { CANCEL_SYMBOL, diffLines, isActionKey, setRawMode, settings } from '../utils/index.js'; +import { + CANCEL_SYMBOL, + diffLines, + getRows, + isActionKey, + setRawMode, + settings, +} from '../utils/index.js'; export interface PromptOptions> { render(this: Omit): string | undefined; @@ -274,28 +281,44 @@ export default class Prompt { this.output.write(cursor.hide); } else { const diff = diffLines(this._prevFrame, frame); + const rows = getRows(this.output); this.restoreCursor(); - // If a single line has changed, only update that line - if (diff && diff?.length === 1) { - const diffLine = diff[0]; - this.output.write(cursor.move(0, diffLine)); - this.output.write(erase.lines(1)); - const lines = frame.split('\n'); - this.output.write(lines[diffLine]); - this._prevFrame = frame; - this.output.write(cursor.move(0, lines.length - diffLine - 1)); - return; - // If many lines have changed, rerender everything past the first line - } - if (diff && diff?.length > 1) { - const diffLine = diff[0]; - this.output.write(cursor.move(0, diffLine)); - this.output.write(erase.down()); - const lines = frame.split('\n'); - const newLines = lines.slice(diffLine); - this.output.write(newLines.join('\n')); - this._prevFrame = frame; - return; + if (diff) { + const diffOffsetAfter = Math.max(0, diff.numLinesAfter - rows); + const diffOffsetBefore = Math.max(0, diff.numLinesBefore - rows); + let diffLine = diff.lines.find((line) => line >= diffOffsetAfter); + + if (diffLine === undefined) { + this._prevFrame = frame; + return; + } + + // If a single line has changed, only update that line + if (diff.lines.length === 1) { + this.output.write(cursor.move(0, diffLine - diffOffsetBefore)); + this.output.write(erase.lines(1)); + const lines = frame.split('\n'); + this.output.write(lines[diffLine]); + this._prevFrame = frame; + this.output.write(cursor.move(0, lines.length - diffLine - 1)); + return; + // If many lines have changed, rerender everything past the first line + } else if (diff.lines.length > 1) { + if (diffOffsetAfter < diffOffsetBefore) { + diffLine = diffOffsetAfter; + } else { + const adjustedDiffLine = diffLine - diffOffsetBefore; + if (adjustedDiffLine > 0) { + this.output.write(cursor.move(0, adjustedDiffLine)); + } + } + this.output.write(erase.down()); + const lines = frame.split('\n'); + const newLines = lines.slice(diffLine); + this.output.write(newLines.join('\n')); + this._prevFrame = frame; + return; + } } this.output.write(erase.down()); diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts index 6ec6456..a2e59b8 100644 --- a/packages/core/src/utils/string.ts +++ b/packages/core/src/utils/string.ts @@ -3,11 +3,17 @@ export function diffLines(a: string, b: string) { const aLines = a.split('\n'); const bLines = b.split('\n'); + const numLines = Math.max(aLines.length, bLines.length); const diff: number[] = []; - for (let i = 0; i < Math.max(aLines.length, bLines.length); i++) { + for (let i = 0; i < numLines; i++) { if (aLines[i] !== bLines[i]) diff.push(i); } - return diff; + return { + lines: diff, + numLinesBefore: aLines.length, + numLinesAfter: bLines.length, + numLines, + }; } diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap index 632967d..e7c7fa3 100644 --- a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -61,7 +61,6 @@ exports[`autocomplete > renders bottom ellipsis when items do not fit 1`] = ` │ ↑/↓ to select • Enter: confirm • Type: to search └", "", - "", "", "◇ Select an option │ Line 0 @@ -136,9 +135,9 @@ exports[`autocomplete > renders top ellipsis when scrolled down and its do not f │ ↑/↓ to select • Enter: confirm • Type: to search └", "", - "", "", - "◇ Select an option + "│ +◇ Select an option │ Option 2", " ", diff --git a/packages/prompts/test/__snapshots__/select.test.ts.snap b/packages/prompts/test/__snapshots__/select.test.ts.snap index cd5e341..1332d87 100644 --- a/packages/prompts/test/__snapshots__/select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/select.test.ts.snap @@ -63,6 +63,44 @@ exports[`select (isCI = false) > down arrow selects next option 1`] = ` ] `; +exports[`select (isCI = false) > handles mixed size re-renders 1`] = ` +[ + "", + "│ +◆ Whatever +│ ● Long Option +│ Long Option +│ Long Option +│ Long Option +│ Long Option +│ Long Option +│ Long Option +│ Long Option +│ ... +└ +", + "", + "", + "│ +◆ Whatever +│ ... +│ ○ Option 0 +│ ○ Option 1 +│ ○ Option 2 +│ ● Option 3 +└ +", + "", + "", + "", + "◇ Whatever +│ Option 3", + " +", + "", +] +`; + exports[`select (isCI = false) > renders disabled options 1`] = ` [ "", @@ -351,6 +389,44 @@ exports[`select (isCI = true) > down arrow selects next option 1`] = ` ] `; +exports[`select (isCI = true) > handles mixed size re-renders 1`] = ` +[ + "", + "│ +◆ Whatever +│ ● Long Option +│ Long Option +│ Long Option +│ Long Option +│ Long Option +│ Long Option +│ Long Option +│ Long Option +│ ... +└ +", + "", + "", + "│ +◆ Whatever +│ ... +│ ○ Option 0 +│ ○ Option 1 +│ ○ Option 2 +│ ● Option 3 +└ +", + "", + "", + "", + "◇ Whatever +│ Option 3", + " +", + "", +] +`; + exports[`select (isCI = true) > renders disabled options 1`] = ` [ "", diff --git a/packages/prompts/test/select.test.ts b/packages/prompts/test/select.test.ts index 6347ad1..ac1130a 100644 --- a/packages/prompts/test/select.test.ts +++ b/packages/prompts/test/select.test.ts @@ -248,4 +248,31 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + + test('handles mixed size re-renders', async () => { + output.rows = 10; + + const result = prompts.select({ + message: 'Whatever', + options: [ + { + value: 'longopt', + label: Array.from({ length: 8 }, () => 'Long Option').join('\n'), + }, + ...Array.from({ length: 4 }, (_, i) => ({ + value: `opt${i}`, + label: `Option ${i}`, + })), + ], + input, + output, + }); + + input.emit('keypress', '', { name: 'up' }); + input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); }); -- 2.51.2