diff --git a/.changeset/ninety-seals-teach.md b/.changeset/ninety-seals-teach.md new file mode 100644 index 00000000..ce392422 --- /dev/null +++ b/.changeset/ninety-seals-teach.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Add new multiline prompt for multi-line text input. diff --git a/examples/changesets/index.ts b/examples/changesets/index.ts index b4f216c3..cb9226db 100644 --- a/examples/changesets/index.ts +++ b/examples/changesets/index.ts @@ -70,7 +70,7 @@ async function main() { } ); - const message = await p.text({ + const message = await p.multiline({ placeholder: 'Summary', message: 'Please enter a summary for this change', }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6a83a200..478095b8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,8 @@ export type { DateFormat, DateOptions, DateParts } from './prompts/date.js'; export { default as DatePrompt } from './prompts/date.js'; export type { GroupMultiSelectOptions } from './prompts/group-multiselect.js'; export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect.js'; +export type { MultiLineOptions } from './prompts/multi-line.js'; +export { default as MultiLinePrompt } from './prompts/multi-line.js'; export type { MultiSelectOptions } from './prompts/multi-select.js'; export { default as MultiSelectPrompt } from './prompts/multi-select.js'; export type { PasswordOptions } from './prompts/password.js'; diff --git a/packages/core/src/prompts/multi-line.ts b/packages/core/src/prompts/multi-line.ts new file mode 100644 index 00000000..d3765fc1 --- /dev/null +++ b/packages/core/src/prompts/multi-line.ts @@ -0,0 +1,138 @@ +import type { Key } from 'node:readline'; +import { styleText } from 'node:util'; +import { findTextCursor } from '../utils/cursor.js'; +import { type Action, settings } from '../utils/index.js'; +import Prompt, { type PromptOptions } from './prompt.js'; + +export interface MultiLineOptions extends PromptOptions { + placeholder?: string; + defaultValue?: string; + showSubmit?: boolean; +} + +export default class MultiLinePrompt extends Prompt { + #lastKeyWasReturn = false; + #showSubmit: boolean; + public focused: 'editor' | 'submit' = 'editor'; + + get userInputWithCursor() { + if (this.state === 'submit') { + return this.userInput; + } + const userInput = this.userInput; + if (this.cursor >= userInput.length) { + return `${userInput}█`; + } + const s1 = userInput.slice(0, this.cursor); + const s2 = userInput[this.cursor]; + const s3 = userInput.slice(this.cursor + 1); + if (s2 === '\n') return `${s1}█\n${s3}`; + return `${s1}${styleText('inverse', s2)}${s3}`; + } + get cursor() { + return this._cursor; + } + #insertAtCursor(char: string) { + if (this.userInput.length === 0) { + this._setUserInput(char); + return; + } + this._setUserInput( + this.userInput.slice(0, this.cursor) + char + this.userInput.slice(this.cursor) + ); + } + #handleCursor(key?: Action) { + const text = this.value ?? ''; + switch (key) { + case 'up': + this._cursor = findTextCursor(this._cursor, 0, -1, text); + return; + case 'down': + this._cursor = findTextCursor(this._cursor, 0, 1, text); + return; + case 'left': + this._cursor = findTextCursor(this._cursor, -1, 0, text); + return; + case 'right': + this._cursor = findTextCursor(this._cursor, 1, 0, text); + return; + } + } + + protected override _shouldSubmit(_char: string | undefined, _key: Key): boolean { + if (this.#showSubmit) { + if (this.focused === 'submit') { + return true; + } + this.#insertAtCursor('\n'); + this._cursor++; + return false; + } + const wasReturn = this.#lastKeyWasReturn; + this.#lastKeyWasReturn = true; + if (wasReturn) { + if (this.userInput[this.cursor - 1] === '\n') { + this._setUserInput( + this.userInput.slice(0, this.cursor - 1) + this.userInput.slice(this.cursor) + ); + this._cursor--; + } + return true; + } + this.#insertAtCursor('\n'); + this._cursor++; + return false; + } + + constructor(opts: MultiLineOptions) { + super(opts, false); + this.#showSubmit = opts.showSubmit ?? false; + + this.on('key', (char, key) => { + if (key?.name && settings.actions.has(key.name as Action)) { + this.#handleCursor(key.name as Action); + return; + } + if (char === '\t' && this.#showSubmit) { + this.focused = this.focused === 'editor' ? 'submit' : 'editor'; + return; + } + if (key?.name === 'return') { + return; + } + this.#lastKeyWasReturn = false; + if (key?.name === 'backspace' && this.cursor > 0) { + this._setUserInput( + this.userInput.slice(0, this.cursor - 1) + this.userInput.slice(this.cursor) + ); + this._cursor--; + return; + } + if (key?.name === 'delete' && this.cursor < this.userInput.length) { + this._setUserInput( + this.userInput.slice(0, this.cursor) + this.userInput.slice(this.cursor + 1) + ); + return; + } + if (char) { + if (this.#showSubmit && this.focused === 'submit') { + this.focused = 'editor'; + } + this.#insertAtCursor(char ?? ''); + this._cursor++; + } + }); + + this.on('userInput', (input) => { + this._setValue(input); + }); + this.on('finalize', () => { + if (!this.value) { + this.value = opts.defaultValue; + } + if (this.value === undefined) { + this.value = ''; + } + }); + } +} diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index b30deb02..6c241f16 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -179,6 +179,10 @@ export default class Prompt { return char === '\t'; } + protected _shouldSubmit(_char: string | undefined, _key: Key): boolean { + return true; + } + protected _setValue(value: TValue | undefined): void { this.value = value; this.emit('value', this.value); @@ -225,7 +229,7 @@ export default class Prompt { // Call the key event handler and emit the key event this.emit('key', char?.toLowerCase(), key); - if (key?.name === 'return') { + if (key?.name === 'return' && this._shouldSubmit(char, key)) { if (this.opts.validate) { const problem = this.opts.validate(this.value); if (problem) { diff --git a/packages/core/src/utils/cursor.ts b/packages/core/src/utils/cursor.ts index 865067dd..75df1ac3 100644 --- a/packages/core/src/utils/cursor.ts +++ b/packages/core/src/utils/cursor.ts @@ -16,3 +16,41 @@ export function findCursor( } return clampedCursor; } + +export function findTextCursor( + cursor: number, + deltaX: number, + deltaY: number, + value: string +): number { + const lines = value.split('\n'); + let cursorY = 0; + let cursorX = cursor; + + for (const line of lines) { + if (cursorX <= line.length) { + break; + } + cursorX -= line.length + 1; + cursorY++; + } + + cursorY = Math.max(0, Math.min(lines.length - 1, cursorY + deltaY)); + + cursorX = Math.min(cursorX, lines[cursorY].length) + deltaX; + while (cursorX < 0 && cursorY > 0) { + cursorY--; + cursorX += lines[cursorY].length + 1; + } + while (cursorX > lines[cursorY].length && cursorY < lines.length - 1) { + cursorX -= lines[cursorY].length + 1; + cursorY++; + } + cursorX = Math.max(0, Math.min(lines[cursorY].length, cursorX)); + + let newCursor = 0; + for (let i = 0; i < cursorY; i++) { + newCursor += lines[i].length + 1; + } + return newCursor + cursorX; +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 3bb9aac6..b2215721 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -102,7 +102,8 @@ export function wrapTextWithPrefix( output: Writable | undefined, text: string, prefix: string, - startPrefix: string = prefix + startPrefix: string = prefix, + lineFormatter?: (line: string, index: number) => string ): string { const columns = getColumns(output ?? stdout); const wrapped = wrapAnsi(text, columns - prefix.length, { @@ -112,7 +113,8 @@ export function wrapTextWithPrefix( const lines = wrapped .split('\n') .map((line, index) => { - return `${index === 0 ? startPrefix : prefix}${line}`; + const lineString = lineFormatter ? lineFormatter(line, index) : line; + return `${index === 0 ? startPrefix : prefix}${lineString}`; }) .join('\n'); return lines; diff --git a/packages/core/test/prompts/multi-line.test.ts b/packages/core/test/prompts/multi-line.test.ts new file mode 100644 index 00000000..53f86a5d --- /dev/null +++ b/packages/core/test/prompts/multi-line.test.ts @@ -0,0 +1,355 @@ +import { styleText } from 'node:util'; +import { cursor } from 'sisteransi'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { default as MultiLinePrompt } from '../../src/prompts/multi-line.js'; +import { MockReadable } from '../mock-readable.js'; +import { MockWritable } from '../mock-writable.js'; + +describe('MultiLinePrompt', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('renders render() result', () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + instance.prompt(); + expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + }); + + test('sets default value on finalize if no value', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + defaultValue: 'bleep bloop', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('bleep bloop'); + }); + + test('keeps value on finalize', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + defaultValue: 'bleep bloop', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + describe('cursor', () => { + test('can get cursor', () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + + expect(instance.cursor).to.equal(0); + }); + }); + + describe('userInputWithCursor', () => { + test('returns value on submit', () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + expect(instance.userInputWithCursor).to.equal('x'); + }); + + test('highlights cursor position', () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + instance.prompt(); + const keys = 'foo'; + for (let i = 0; i < keys.length; i++) { + input.emit('keypress', keys[i], { name: keys[i] }); + } + input.emit('keypress', undefined, { name: 'left' }); + expect(instance.userInputWithCursor).to.equal(`fo${styleText('inverse', 'o')}`); + }); + + test('shows cursor at end if beyond value', () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + instance.prompt(); + const keys = 'foo'; + for (let i = 0; i < keys.length; i++) { + input.emit('keypress', keys[i], { name: keys[i] }); + } + input.emit('keypress', undefined, { name: 'right' }); + expect(instance.userInputWithCursor).to.equal('foo█'); + }); + }); + + describe('key', () => { + test('return inserts newline', () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + expect(instance.userInput).to.equal('x\n'); + }); + + test('double return submits', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + test('double return inserts when showSubmit is true', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + showSubmit: true, + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '\t', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x\n\n'); + }); + + test('typing when submit selected jumps back to text', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + showSubmit: true, + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '\t', { name: 'tab' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '\t', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('xy'); + }); + + test('backspace deletes previous char', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'backspace' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + test('delete deletes next char', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'left' }); + input.emit('keypress', '', { name: 'delete' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + test('delete does nothing at end', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'delete' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + test('backspace does nothing at start', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'left' }); + input.emit('keypress', '', { name: 'backspace' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + test('left moves left until start', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'left' }); + input.emit('keypress', '', { name: 'left' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('yx'); + }); + + test('right moves right until end', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'left' }); + input.emit('keypress', '', { name: 'right' }); + input.emit('keypress', '', { name: 'right' }); + input.emit('keypress', 'z', { name: 'z' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('xyz'); + }); + + test('left moves across lines', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'left' }); + input.emit('keypress', '', { name: 'left' }); + input.emit('keypress', 'z', { name: 'z' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('xz\ny'); + }); + + test('right moves across lines', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'left' }); + input.emit('keypress', '', { name: 'left' }); + input.emit('keypress', '', { name: 'right' }); + input.emit('keypress', '', { name: 'right' }); + input.emit('keypress', 'z', { name: 'z' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x\nyz'); + }); + + test('up moves up a line', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'up' }); + input.emit('keypress', 'z', { name: 'z' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('xz\ny'); + }); + + test('down moves down a line', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'up' }); + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', 'z', { name: 'z' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x\nyz'); + }); + }); +}); diff --git a/packages/core/test/utils/cursor.test.ts b/packages/core/test/utils/cursor.test.ts new file mode 100644 index 00000000..458d231e --- /dev/null +++ b/packages/core/test/utils/cursor.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'vitest'; +import { findCursor, findTextCursor } from '../../src/utils/cursor.js'; + +describe('findCursor', () => { + test('returns the same cursor if all options are disabled', () => { + const options = [{ disabled: true }, { disabled: true }]; + expect(findCursor(0, 1, options)).toBe(0); + }); + + test('skips disabled options', () => { + const options = [{ disabled: false }, { disabled: true }, { disabled: false }]; + expect(findCursor(0, 1, options)).toBe(2); + expect(findCursor(2, -1, options)).toBe(0); + }); + + test('wraps around the options', () => { + const options = [{ disabled: false }, { disabled: false }, { disabled: false }]; + expect(findCursor(2, 1, options)).toBe(0); + expect(findCursor(0, -1, options)).toBe(2); + }); + + test('handles empty options', () => { + const options: { disabled?: boolean }[] = []; + expect(findCursor(0, 1, options)).toBe(0); + expect(findCursor(0, -1, options)).toBe(0); + }); +}); + +describe('findTextCursor', () => { + test('moves cursor horizontally', () => { + const value = 'Hello\nWorld'; + expect(findTextCursor(0, 1, 0, value)).toBe(1); + expect(findTextCursor(5, 1, 0, value)).toBe(6); + expect(findTextCursor(5, -1, 0, value)).toBe(4); + }); + + test('moves cursor vertically', () => { + const value = 'Hello\nWorld'; + expect(findTextCursor(0, 0, 1, value)).toBe(6); + expect(findTextCursor(6, 0, -1, value)).toBe(0); + }); + + test('moves on both axes', () => { + const value = 'Line 1\nLine 2\nLine 3'; + expect(findTextCursor(0, 1, 1, value)).toBe(8); + expect(findTextCursor(7, 1, 1, value)).toBe(15); + expect(findTextCursor(14, -1, -1, value)).toBe(6); + }); + + test('handles empty value', () => { + const value = ''; + expect(findTextCursor(0, 1, 0, value)).toBe(0); + expect(findTextCursor(0, 0, 1, value)).toBe(0); + }); + + test('handles single line value', () => { + const value = 'Single line'; + expect(findTextCursor(0, 1, 0, value)).toBe(1); + expect(findTextCursor(5, -1, 0, value)).toBe(4); + expect(findTextCursor(0, 0, 1, value)).toBe(0); + }); + + test('handles cursor at end of line', () => { + const value = 'Hello\nWorld'; + expect(findTextCursor(5, 1, 0, value)).toBe(6); + expect(findTextCursor(11, -1, 0, value)).toBe(10); + }); +}); diff --git a/packages/prompts/README.md b/packages/prompts/README.md index 5cbe88e4..db99f207 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -132,6 +132,31 @@ const basket = await groupMultiselect({ }); ``` +### Multi-Line Text + +The multi-line text component accepts multiple lines of text input. By default, pressing `Enter` twice submits the input. + +```js +import { multiline } from '@clack/prompts'; + +const bio = await multiline({ + message: 'Tell us about yourself.', + placeholder: 'Start typing...', + validate(value) { + if (value.length === 0) return `value is required`; + }, +}); +``` + +Set `showSubmit` to display an explicit submit button instead of double `Enter` submission: + +```js +const bio = await multiline({ + message: 'Tell us about yourself.', + showSubmit: true, +}); +``` + ### Spinner The spinner component surfaces a pending action, such as a long-running download or dependency installation. diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 7dfef5b9..e3a1671a 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -10,6 +10,7 @@ export * from './group-multi-select.js'; export * from './limit-options.js'; export * from './log.js'; export * from './messages.js'; +export * from './multi-line.js'; export * from './multi-select.js'; export * from './note.js'; export * from './password.js'; diff --git a/packages/prompts/src/multi-line.ts b/packages/prompts/src/multi-line.ts new file mode 100644 index 00000000..4eac8d8b --- /dev/null +++ b/packages/prompts/src/multi-line.ts @@ -0,0 +1,74 @@ +import { styleText } from 'node:util'; +import { MultiLinePrompt, settings, wrapTextWithPrefix } from '@clack/core'; +import { S_BAR, S_BAR_END, symbol } from './common.js'; +import type { TextOptions } from './text.js'; + +export interface MultiLineOptions extends TextOptions { + showSubmit?: boolean; +} + +export const multiline = (opts: MultiLineOptions) => { + return new MultiLinePrompt({ + validate: opts.validate, + placeholder: opts.placeholder, + defaultValue: opts.defaultValue, + initialValue: opts.initialValue, + showSubmit: opts.showSubmit, + output: opts.output, + signal: opts.signal, + input: opts.input, + render() { + const hasGuide = opts?.withGuide ?? settings.withGuide; + const titlePrefix = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} `; + const title = `${titlePrefix}${opts.message}\n`; + const placeholder = opts.placeholder + ? styleText('inverse', opts.placeholder[0]) + styleText('dim', opts.placeholder.slice(1)) + : styleText(['inverse', 'hidden'], '_'); + const userInput = !this.userInput ? placeholder : this.userInputWithCursor; + const value = this.value ?? ''; + const submitButton = opts.showSubmit + ? `\n ${styleText(this.focused === 'submit' ? 'cyan' : 'dim', '[ submit ]')}` + : ''; + switch (this.state) { + case 'error': { + const errorPrefix = `${styleText('yellow', S_BAR)} `; + const lines = hasGuide + ? wrapTextWithPrefix(opts.output, userInput, errorPrefix, undefined) + : userInput; + const errorPrefixEnd = styleText('yellow', S_BAR_END); + return `${title}${lines}\n${errorPrefixEnd} ${styleText('yellow', this.error)}${submitButton}\n`; + } + case 'submit': { + const submitPrefix = `${styleText('gray', S_BAR)} `; + const lines = hasGuide + ? wrapTextWithPrefix(opts.output, value, submitPrefix, undefined, (str) => + styleText('dim', str) + ) + : value + ? styleText('dim', value) + : ''; + return `${title}${lines}`; + } + case 'cancel': { + const cancelPrefix = `${styleText('gray', S_BAR)} `; + const lines = hasGuide + ? wrapTextWithPrefix(opts.output, value, cancelPrefix, undefined, (str) => + styleText(['strikethrough', 'dim'], str) + ) + : value + ? styleText(['strikethrough', 'dim'], value) + : ''; + return `${title}${lines}`; + } + default: { + const defaultPrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''; + const defaultPrefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : ''; + const lines = hasGuide + ? wrapTextWithPrefix(opts.output, userInput, defaultPrefix) + : userInput; + return `${title}${lines}\n${defaultPrefixEnd}${submitButton}\n`; + } + } + }, + }).prompt() as Promise; +}; diff --git a/packages/prompts/test/__snapshots__/multi-line.test.ts.snap b/packages/prompts/test/__snapshots__/multi-line.test.ts.snap new file mode 100644 index 00000000..2acfe22c --- /dev/null +++ b/packages/prompts/test/__snapshots__/multi-line.test.ts.snap @@ -0,0 +1,831 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`multiline (isCI = false) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > can cancel 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "■ foo +│ escape", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > defaultValue sets the value but does not render 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ +│ █ +└ +", + "", + "", + "", + "◇ foo +│ bar", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > empty string when no value and no default 1`] = ` +[ + "", + "│ +◆ foo +│   (submit to use default) +└ +", + "", + "", + "", + "│ +│ █ +└ +", + "", + "", + "", + "◇ foo +│ ", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > global withGuide: false removes guide 1`] = ` +[ + "", + "◆ foo +_ + +", + "", + "", + "", + " +█ + +", + "", + "", + "◇ foo +", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > placeholder is not used as value when pressing enter 1`] = ` +[ + "", + "│ +◆ foo +│   (submit to use default) +└ +", + "", + "", + "", + "│ +│ █ +└ +", + "", + "", + "", + "◇ foo +│ default-value", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > renders cancelled value if one set 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ x█", + "", + "", + "", + "", + "│ xy█", + "", + "", + "", + "", + "■ foo +│ xy", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > renders message 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ +│ █ +└ +", + "", + "", + "", + "◇ foo +│ ", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > renders placeholder if set 1`] = ` +[ + "", + "│ +◆ foo +│ bar +└ +", + "", + "", + "", + "│ +│ █ +└ +", + "", + "", + "", + "◇ foo +│ ", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > renders submit button 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ + [ submit ] +", + "", + "", + "", + "│ x█", + "", + "", + "", + "", + "│ xy█", + "", + "", + "", + "", + " [ submit ]", + "", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > renders submitted value 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ x█", + "", + "", + "", + "", + "│ xy█", + "", + "", + "", + "", + "│ xy +│ █ +└ +", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > validation errors render and clear (using Error) 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ x█", + "", + "", + "", + "", + "│ x +│ █ +└ +", + "", + "", + "", + "▲ foo +│ x█ +└ should be xy +", + "", + "", + "", + "◆ foo +│ xy█ +└ +", + "", + "", + "", + "│ xy +│ █ +└ +", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > validation errors render and clear 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ x█", + "", + "", + "", + "", + "│ x +│ █ +└ +", + "", + "", + "", + "▲ foo +│ x█ +└ should be xy +", + "", + "", + "", + "◆ foo +│ xy█ +└ +", + "", + "", + "", + "│ xy +│ █ +└ +", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`multiline (isCI = false) > withGuide: false removes guide 1`] = ` +[ + "", + "◆ foo +_ + +", + "", + "", + "", + " +█ + +", + "", + "", + "◇ foo +", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > can be aborted by a signal 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > can cancel 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "■ foo +│ escape", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > defaultValue sets the value but does not render 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ +│ █ +└ +", + "", + "", + "", + "◇ foo +│ bar", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > empty string when no value and no default 1`] = ` +[ + "", + "│ +◆ foo +│   (submit to use default) +└ +", + "", + "", + "", + "│ +│ █ +└ +", + "", + "", + "", + "◇ foo +│ ", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > global withGuide: false removes guide 1`] = ` +[ + "", + "◆ foo +_ + +", + "", + "", + "", + " +█ + +", + "", + "", + "◇ foo +", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > placeholder is not used as value when pressing enter 1`] = ` +[ + "", + "│ +◆ foo +│   (submit to use default) +└ +", + "", + "", + "", + "│ +│ █ +└ +", + "", + "", + "", + "◇ foo +│ default-value", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > renders cancelled value if one set 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ x█", + "", + "", + "", + "", + "│ xy█", + "", + "", + "", + "", + "■ foo +│ xy", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > renders message 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ +│ █ +└ +", + "", + "", + "", + "◇ foo +│ ", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > renders placeholder if set 1`] = ` +[ + "", + "│ +◆ foo +│ bar +└ +", + "", + "", + "", + "│ +│ █ +└ +", + "", + "", + "", + "◇ foo +│ ", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > renders submit button 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ + [ submit ] +", + "", + "", + "", + "│ x█", + "", + "", + "", + "", + "│ xy█", + "", + "", + "", + "", + " [ submit ]", + "", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > renders submitted value 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ x█", + "", + "", + "", + "", + "│ xy█", + "", + "", + "", + "", + "│ xy +│ █ +└ +", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > validation errors render and clear (using Error) 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ x█", + "", + "", + "", + "", + "│ x +│ █ +└ +", + "", + "", + "", + "▲ foo +│ x█ +└ should be xy +", + "", + "", + "", + "◆ foo +│ xy█ +└ +", + "", + "", + "", + "│ xy +│ █ +└ +", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > validation errors render and clear 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ x█", + "", + "", + "", + "", + "│ x +│ █ +└ +", + "", + "", + "", + "▲ foo +│ x█ +└ should be xy +", + "", + "", + "", + "◆ foo +│ xy█ +└ +", + "", + "", + "", + "│ xy +│ █ +└ +", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + +exports[`multiline (isCI = true) > withGuide: false removes guide 1`] = ` +[ + "", + "◆ foo +_ + +", + "", + "", + "", + " +█ + +", + "", + "", + "◇ foo +", + " +", + "", +] +`; diff --git a/packages/prompts/test/multi-line.test.ts b/packages/prompts/test/multi-line.test.ts new file mode 100644 index 00000000..69edc66d --- /dev/null +++ b/packages/prompts/test/multi-line.test.ts @@ -0,0 +1,272 @@ +import { updateSettings } from '@clack/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as prompts from '../src/index.js'; +import { MockReadable, MockWritable } from './test-utils.js'; + +describe.each(['true', 'false'])('multiline (isCI = %s)', (isCI) => { + let originalCI: string | undefined; + let output: MockWritable; + let input: MockReadable; + + beforeAll(() => { + originalCI = process.env.CI; + process.env.CI = isCI; + }); + + afterAll(() => { + process.env.CI = originalCI; + }); + + beforeEach(() => { + output = new MockWritable(); + input = new MockReadable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + updateSettings({ withGuide: true }); + }); + + test('renders message', async () => { + const result = prompts.multiline({ + message: 'foo', + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders placeholder if set', async () => { + const result = prompts.multiline({ + message: 'foo', + placeholder: 'bar', + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(output.buffer).toMatchSnapshot(); + expect(value).toBe(''); + }); + + test('can cancel', async () => { + const result = prompts.multiline({ + message: 'foo', + input, + output, + }); + + input.emit('keypress', 'escape', { name: 'escape' }); + + const value = await result; + + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders cancelled value if one set', async () => { + const result = prompts.multiline({ + message: 'foo', + input, + output, + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'escape' }); + + const value = await result; + + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders submitted value', async () => { + const result = prompts.multiline({ + message: 'foo', + input, + output, + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('defaultValue sets the value but does not render', async () => { + const result = prompts.multiline({ + message: 'foo', + defaultValue: 'bar', + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('bar'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('validation errors render and clear', async () => { + const result = prompts.multiline({ + message: 'foo', + validate: (val) => (val !== 'xy' ? 'should be xy' : undefined), + input, + output, + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('validation errors render and clear (using Error)', async () => { + const result = prompts.multiline({ + message: 'foo', + validate: (val) => (val !== 'xy' ? new Error('should be xy') : undefined), + input, + output, + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('placeholder is not used as value when pressing enter', async () => { + const result = prompts.multiline({ + message: 'foo', + placeholder: ' (submit to use default)', + defaultValue: 'default-value', + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('default-value'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('empty string when no value and no default', async () => { + const result = prompts.multiline({ + message: 'foo', + placeholder: ' (submit to use default)', + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe(''); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can be aborted by a signal', async () => { + const controller = new AbortController(); + const result = prompts.multiline({ + message: 'foo', + input, + output, + signal: controller.signal, + }); + + controller.abort(); + const value = await result; + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('withGuide: false removes guide', async () => { + const result = prompts.multiline({ + message: 'foo', + withGuide: false, + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('global withGuide: false removes guide', async () => { + updateSettings({ withGuide: false }); + + const result = prompts.multiline({ + message: 'foo', + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders submit button', async () => { + const result = prompts.multiline({ + message: 'foo', + input, + output, + showSubmit: true, + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '\t', { name: 'tab' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(output.buffer).toMatchSnapshot(); + }); +});