diff --git a/.changeset/tangy-meals-allow.md b/.changeset/tangy-meals-allow.md new file mode 100644 index 00000000..9f770560 --- /dev/null +++ b/.changeset/tangy-meals-allow.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": patch +--- + +Respect `withGuide: false` in autocomplete and multiselect prompts. diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index bb4dc69f..f840c717 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -285,8 +285,11 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti input: opts.input, output: opts.output, render() { + const hasGuide = opts.withGuide ?? settings.withGuide; // Title and symbol - const title = `${styleText('gray', S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} ${ + opts.message + }\n`; // Selection counter const userInput = this.userInput; @@ -312,13 +315,21 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti // Render prompt state switch (this.state) { case 'submit': { - return `${title}${styleText('gray', S_BAR)} ${styleText('dim', `${this.selectedValues.length} items selected`)}`; + return `${title}${hasGuide ? `${styleText('gray', S_BAR)} ` : ''}${styleText( + 'dim', + `${this.selectedValues.length} items selected` + )}`; } case 'cancel': { - return `${title}${styleText('gray', S_BAR)} ${styleText(['strikethrough', 'dim'], userInput)}`; + return `${title}${hasGuide ? `${styleText('gray', S_BAR)} ` : ''}${styleText( + ['strikethrough', 'dim'], + userInput + )}`; } default: { const barStyle = this.state === 'error' ? 'yellow' : 'cyan'; + const guidePrefix = hasGuide ? `${styleText(barStyle, S_BAR)} ` : ''; + const guidePrefixEnd = hasGuide ? styleText(barStyle, S_BAR_END) : ''; // Instructions const instructions = [ `${styleText('dim', '↑/↓')} to navigate`, @@ -330,25 +341,20 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti // No results message const noResults = this.filteredOptions.length === 0 && userInput - ? [`${styleText(barStyle, S_BAR)} ${styleText('yellow', 'No matches found')}`] + ? [`${guidePrefix}${styleText('yellow', 'No matches found')}`] : []; const errorMessage = - this.state === 'error' - ? [`${styleText(barStyle, S_BAR)} ${styleText('yellow', this.error)}`] - : []; + this.state === 'error' ? [`${guidePrefix}${styleText('yellow', this.error)}`] : []; // Calculate header and footer line counts for rowPadding const headerLines = [ - ...`${title}${styleText(barStyle, S_BAR)}`.split('\n'), - `${styleText(barStyle, S_BAR)} ${styleText('dim', 'Search:')} ${searchText}${matches}`, + ...`${title}${hasGuide ? styleText(barStyle, S_BAR) : ''}`.split('\n'), + `${guidePrefix}${styleText('dim', 'Search:')} ${searchText}${matches}`, ...noResults, ...errorMessage, ]; - const footerLines = [ - `${styleText(barStyle, S_BAR)} ${instructions.join(' • ')}`, - styleText(barStyle, S_BAR_END), - ]; + const footerLines = [`${guidePrefix}${instructions.join(' • ')}`, guidePrefixEnd]; // Get limited options for display const displayOptions = limitOptions({ @@ -364,7 +370,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti // Build the prompt display return [ ...headerLines, - ...displayOptions.map((option) => `${styleText(barStyle, S_BAR)} ${option}`), + ...displayOptions.map((option) => `${guidePrefix}${option}`), ...footerLines, ].join('\n'); } diff --git a/packages/prompts/src/group-multi-select.ts b/packages/prompts/src/group-multi-select.ts index 80b3d1fb..99c4dbf6 100644 --- a/packages/prompts/src/group-multi-select.ts +++ b/packages/prompts/src/group-multi-select.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import { GroupMultiSelectPrompt } from '@clack/core'; +import { GroupMultiSelectPrompt, settings } from '@clack/core'; import { type CommonOptions, S_BAR, @@ -104,7 +104,8 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => )}`; }, render() { - const title = `${styleText('gray', S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const hasGuide = opts.withGuide ?? settings.withGuide; + const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} ${opts.message}\n`; const value = this.value ?? []; switch (this.state) { @@ -114,25 +115,27 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => .map((option) => opt(option, 'submitted')); const optionsText = selectedOptions.length === 0 ? '' : ` ${selectedOptions.join(styleText('dim', ', '))}`; - return `${title}${styleText('gray', S_BAR)}${optionsText}`; + return `${title}${hasGuide ? styleText('gray', S_BAR) : ''}${optionsText}`; } case 'cancel': { const label = this.options .filter(({ value: optionValue }) => value.includes(optionValue)) .map((option) => opt(option, 'cancelled')) .join(styleText('dim', ', ')); - return `${title}${styleText('gray', S_BAR)} ${ - label.trim() ? `${label}\n${styleText('gray', S_BAR)}` : '' + return `${title}${hasGuide ? `${styleText('gray', S_BAR)} ` : ''}${ + label.trim() ? `${label}${hasGuide ? `\n${styleText('gray', S_BAR)}` : ''}` : '' }`; } case 'error': { const footer = this.error .split('\n') .map((ln, i) => - i === 0 ? `${styleText('yellow', S_BAR_END)} ${styleText('yellow', ln)}` : ` ${ln}` + i === 0 + ? `${hasGuide ? `${styleText('yellow', S_BAR_END)} ` : ''}${styleText('yellow', ln)}` + : ` ${ln}` ) .join('\n'); - return `${title}${styleText('yellow', S_BAR)} ${this.options + return `${title}${hasGuide ? `${styleText('yellow', S_BAR)} ` : ''}${this.options .map((option, i, options) => { const selected = value.includes(option.value) || @@ -153,7 +156,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => } return opt(option, active ? 'active' : 'inactive', options); }) - .join(`\n${styleText('yellow', S_BAR)} `)}\n${footer}\n`; + .join(`\n${hasGuide ? `${styleText('yellow', S_BAR)} ` : ''}`)}\n${footer}\n`; } default: { const optionsText = this.options @@ -183,9 +186,11 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => const prefix = i !== 0 && !optionText.startsWith('\n') ? ' ' : ''; return `${prefix}${optionText}`; }) - .join(`\n${styleText('cyan', S_BAR)}`); + .join(`\n${hasGuide ? styleText('cyan', S_BAR) : ''}`); const optionsPrefix = optionsText.startsWith('\n') ? '' : ' '; - return `${title}${styleText('cyan', S_BAR)}${optionsPrefix}${optionsText}\n${styleText('cyan', S_BAR_END)}\n`; + return `${title}${hasGuide ? styleText('cyan', S_BAR) : ''}${optionsPrefix}${optionsText}\n${ + hasGuide ? styleText('cyan', S_BAR_END) : '' + }\n`; } } }, diff --git a/packages/prompts/src/multi-select.ts b/packages/prompts/src/multi-select.ts index f2b2cb07..28b27aab 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import { MultiSelectPrompt, wrapTextWithPrefix } from '@clack/core'; +import { MultiSelectPrompt, settings, wrapTextWithPrefix } from '@clack/core'; import { type CommonOptions, S_BAR, @@ -93,13 +93,14 @@ export const multiselect = (opts: MultiSelectOptions) => { )}`; }, render() { + const hasGuide = opts.withGuide ?? settings.withGuide; const wrappedMessage = wrapTextWithPrefix( opts.output, opts.message, - `${symbolBar(this.state)} `, + hasGuide ? `${symbolBar(this.state)} ` : '', `${symbol(this.state)} ` ); - const title = `${styleText('gray', S_BAR)}\n${wrappedMessage}\n`; + const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${wrappedMessage}\n`; const value = this.value ?? []; const styleOption = (option: Option, active: boolean) => { @@ -126,7 +127,7 @@ export const multiselect = (opts: MultiSelectOptions) => { const wrappedSubmitText = wrapTextWithPrefix( opts.output, submitText, - `${styleText('gray', S_BAR)} ` + hasGuide ? `${styleText('gray', S_BAR)} ` : '' ); return `${title}${wrappedSubmitText}`; } @@ -141,16 +142,18 @@ export const multiselect = (opts: MultiSelectOptions) => { const wrappedLabel = wrapTextWithPrefix( opts.output, label, - `${styleText('gray', S_BAR)} ` + hasGuide ? `${styleText('gray', S_BAR)} ` : '' ); - return `${title}${wrappedLabel}\n${styleText('gray', S_BAR)}`; + return `${title}${wrappedLabel}${hasGuide ? `\n${styleText('gray', S_BAR)}` : ''}`; } case 'error': { - const prefix = `${styleText('yellow', S_BAR)} `; + const prefix = hasGuide ? `${styleText('yellow', S_BAR)} ` : ''; const footer = this.error .split('\n') .map((ln, i) => - i === 0 ? `${styleText('yellow', S_BAR_END)} ${styleText('yellow', ln)}` : ` ${ln}` + i === 0 + ? `${hasGuide ? `${styleText('yellow', S_BAR_END)} ` : ''}${styleText('yellow', ln)}` + : ` ${ln}` ) .join('\n'); // Calculate rowPadding: title lines + footer lines (error message + trailing newline) @@ -167,10 +170,10 @@ export const multiselect = (opts: MultiSelectOptions) => { }).join(`\n${prefix}`)}\n${footer}\n`; } default: { - const prefix = `${styleText('cyan', S_BAR)} `; + const prefix = hasGuide ? `${styleText('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 + const footerLineCount = hasGuide ? 2 : 1; // S_BAR_END + trailing newline return `${title}${prefix}${limitOptions({ output: opts.output, options: this.options, @@ -179,7 +182,7 @@ export const multiselect = (opts: MultiSelectOptions) => { columnPadding: prefix.length, rowPadding: titleLineCount + footerLineCount, style: styleOption, - }).join(`\n${prefix}`)}\n${styleText('cyan', S_BAR_END)}\n`; + }).join(`\n${prefix}`)}\n${hasGuide ? styleText('cyan', S_BAR_END) : ''}\n`; } } }, diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap index 1947c22b..e508d4f4 100644 --- a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -1,5 +1,83 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`autocomplete > autocompleteMultiselect respects global withGuide: false 1`] = ` +[ + "", + "◆ Select fruits + +Search: _ +◻ Apple +◻ Banana +◻ Cherry +◻ Grape +◻ Orange +↑/↓ to navigate • Tab: select • Enter: confirm • Type: to search +", + "", + "", + "", + "Search:  +◻ Apple +◻ Banana +◻ Cherry +◻ Grape +◻ Orange +↑/↓ to navigate • Space/Tab: select • Enter: confirm • Type: to search +", + "", + "", + "", + "◼ Banana", + "", + "", + "", + "◇ Select fruits +1 items selected", + " +", + "", +] +`; + +exports[`autocomplete > autocompleteMultiselect respects withGuide: false 1`] = ` +[ + "", + "◆ Select fruits + +Search: _ +◻ Apple +◻ Banana +◻ Cherry +◻ Grape +◻ Orange +↑/↓ to navigate • Tab: select • Enter: confirm • Type: to search +", + "", + "", + "", + "Search:  +◻ Apple +◻ Banana +◻ Cherry +◻ Grape +◻ Orange +↑/↓ to navigate • Space/Tab: select • Enter: confirm • Type: to search +", + "", + "", + "", + "◼ Banana", + "", + "", + "", + "◇ Select fruits +1 items selected", + " +", + "", +] +`; + exports[`autocomplete > can be aborted by a signal 1`] = ` [ "", diff --git a/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap b/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap index 8f0717bd..f439de6e 100644 --- a/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap @@ -248,6 +248,38 @@ exports[`groupMultiselect (isCI = false) > cursorAt sets initial selection 1`] = ] `; +exports[`groupMultiselect (isCI = false) > global withGuide: false removes guide 1`] = ` +[ + "", + "◆ foo + ◻ group1 + │ ◻ group1value0 + └ ◻ group1value1 + +", + "", + "", + "", + " ◻ group1 + │ ◻ group1value0 + └ ◻ group1value1 + +", + "", + "", + "", + " │ ◼ group1value0", + "", + "", + "", + "◇ foo + group1value0", + " +", + "", +] +`; + exports[`groupMultiselect (isCI = false) > groupSpacing > negative spacing is ignored 1`] = ` [ "", @@ -538,6 +570,38 @@ exports[`groupMultiselect (isCI = false) > values can be non-primitive 1`] = ` ] `; +exports[`groupMultiselect (isCI = false) > withGuide: false removes guide 1`] = ` +[ + "", + "◆ foo + ◻ group1 + │ ◻ group1value0 + └ ◻ group1value1 + +", + "", + "", + "", + " ◻ group1 + │ ◻ group1value0 + └ ◻ group1value1 + +", + "", + "", + "", + " │ ◼ group1value0", + "", + "", + "", + "◇ foo + group1value0", + " +", + "", +] +`; + exports[`groupMultiselect (isCI = true) > can be aborted by a signal 1`] = ` [ "", @@ -786,6 +850,38 @@ exports[`groupMultiselect (isCI = true) > cursorAt sets initial selection 1`] = ] `; +exports[`groupMultiselect (isCI = true) > global withGuide: false removes guide 1`] = ` +[ + "", + "◆ foo + ◻ group1 + │ ◻ group1value0 + └ ◻ group1value1 + +", + "", + "", + "", + " ◻ group1 + │ ◻ group1value0 + └ ◻ group1value1 + +", + "", + "", + "", + " │ ◼ group1value0", + "", + "", + "", + "◇ foo + group1value0", + " +", + "", +] +`; + exports[`groupMultiselect (isCI = true) > groupSpacing > negative spacing is ignored 1`] = ` [ "", @@ -1075,3 +1171,35 @@ exports[`groupMultiselect (isCI = true) > values can be non-primitive 1`] = ` "", ] `; + +exports[`groupMultiselect (isCI = true) > withGuide: false removes guide 1`] = ` +[ + "", + "◆ foo + ◻ group1 + │ ◻ group1value0 + └ ◻ group1value1 + +", + "", + "", + "", + " ◻ group1 + │ ◻ group1value0 + └ ◻ group1value1 + +", + "", + "", + "", + " │ ◼ group1value0", + "", + "", + "", + "◇ foo + group1value0", + " +", + "", +] +`; diff --git a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap index 109218e5..38714036 100644 --- a/packages/prompts/test/__snapshots__/multi-select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/multi-select.test.ts.snap @@ -150,6 +150,29 @@ exports[`multiselect (isCI = false) > can submit without selection when required ] `; +exports[`multiselect (isCI = false) > global withGuide: false removes guide 1`] = ` +[ + "", + "◆ foo +◻ opt0 +◻ opt1 + +", + "", + "", + "", + "◼ opt0", + "", + "", + "", + "◇ foo +opt0", + " +", + "", +] +`; + exports[`multiselect (isCI = false) > maxItems renders a sliding window 1`] = ` [ "", @@ -636,6 +659,29 @@ exports[`multiselect (isCI = false) > sliding window loops upwards 1`] = ` ] `; +exports[`multiselect (isCI = false) > withGuide: false removes guide 1`] = ` +[ + "", + "◆ foo +◻ opt0 +◻ opt1 + +", + "", + "", + "", + "◼ opt0", + "", + "", + "", + "◇ foo +opt0", + " +", + "", +] +`; + exports[`multiselect (isCI = false) > wraps cancelled state with long options 1`] = ` [ "", @@ -884,6 +930,29 @@ exports[`multiselect (isCI = true) > can submit without selection when required ] `; +exports[`multiselect (isCI = true) > global withGuide: false removes guide 1`] = ` +[ + "", + "◆ foo +◻ opt0 +◻ opt1 + +", + "", + "", + "", + "◼ opt0", + "", + "", + "", + "◇ foo +opt0", + " +", + "", +] +`; + exports[`multiselect (isCI = true) > maxItems renders a sliding window 1`] = ` [ "", @@ -1370,6 +1439,29 @@ exports[`multiselect (isCI = true) > sliding window loops upwards 1`] = ` ] `; +exports[`multiselect (isCI = true) > withGuide: false removes guide 1`] = ` +[ + "", + "◆ foo +◻ opt0 +◻ opt1 + +", + "", + "", + "", + "◼ opt0", + "", + "", + "", + "◇ foo +opt0", + " +", + "", +] +`; + exports[`multiselect (isCI = true) > wraps cancelled state with long options 1`] = ` [ "", diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index 4a070736..fe55444c 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { autocomplete, autocompleteMultiselect } from '../src/autocomplete.js'; -import { isCancel } from '../src/index.js'; +import { isCancel, updateSettings } from '../src/index.js'; import { MockReadable, MockWritable } from './test-utils.js'; describe('autocomplete', () => { @@ -21,6 +21,7 @@ describe('autocomplete', () => { afterEach(() => { vi.restoreAllMocks(); + updateSettings({ withGuide: true }); }); test('renders initial UI with message and instructions', async () => { @@ -200,6 +201,45 @@ describe('autocomplete', () => { expect(output.buffer).toMatchSnapshot(); }); + test('autocompleteMultiselect respects withGuide: false', async () => { + const result = autocompleteMultiselect({ + message: 'Select fruits', + options: testOptions, + withGuide: false, + input, + output, + }); + + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toEqual(['banana']); + expect(output.buffer).toMatchSnapshot(); + }); + + test('autocompleteMultiselect respects global withGuide: false', async () => { + updateSettings({ withGuide: false }); + + const result = autocompleteMultiselect({ + message: 'Select fruits', + options: testOptions, + input, + output, + }); + + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toEqual(['banana']); + expect(output.buffer).toMatchSnapshot(); + }); + test('renders bottom ellipsis when items do not fit', async () => { output.rows = 5; diff --git a/packages/prompts/test/group-multi-select.test.ts b/packages/prompts/test/group-multi-select.test.ts index c4b5376d..7d24dc18 100644 --- a/packages/prompts/test/group-multi-select.test.ts +++ b/packages/prompts/test/group-multi-select.test.ts @@ -23,6 +23,7 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { afterEach(() => { vi.restoreAllMocks(); + prompts.updateSettings({ withGuide: true }); }); test('renders message with options', async () => { @@ -367,4 +368,47 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { expect(prompts.isCancel(value)).toBe(true); expect(output.buffer).toMatchSnapshot(); }); + + test('withGuide: false removes guide', async () => { + const result = prompts.groupMultiselect({ + message: 'foo', + input, + output, + withGuide: false, + options: { + group1: [{ value: 'group1value0' }, { value: 'group1value1' }], + }, + }); + + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toEqual(['group1value0']); + expect(output.buffer).toMatchSnapshot(); + }); + + test('global withGuide: false removes guide', async () => { + prompts.updateSettings({ withGuide: false }); + + const result = prompts.groupMultiselect({ + message: 'foo', + input, + output, + options: { + group1: [{ value: 'group1value0' }, { value: 'group1value1' }], + }, + }); + + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toEqual(['group1value0']); + expect(output.buffer).toMatchSnapshot(); + }); }); diff --git a/packages/prompts/test/multi-select.test.ts b/packages/prompts/test/multi-select.test.ts index e29dd791..3f79555b 100644 --- a/packages/prompts/test/multi-select.test.ts +++ b/packages/prompts/test/multi-select.test.ts @@ -23,6 +23,7 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { afterEach(() => { vi.restoreAllMocks(); + prompts.updateSettings({ withGuide: true }); }); test('renders message', async () => { @@ -399,4 +400,41 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => { expect(value).toEqual(['opt0']); expect(output.buffer).toMatchSnapshot(); }); + + test('withGuide: false removes guide', async () => { + const result = prompts.multiselect({ + message: 'foo', + options: [{ value: 'opt0' }, { value: 'opt1' }], + withGuide: false, + 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('global withGuide: false removes guide', async () => { + prompts.updateSettings({ withGuide: false }); + + const result = prompts.multiselect({ + message: 'foo', + 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(); + }); });