diff --git a/.changeset/neat-birds-collect.md b/.changeset/neat-birds-collect.md new file mode 100644 index 00000000..4efe851c --- /dev/null +++ b/.changeset/neat-birds-collect.md @@ -0,0 +1,6 @@ +--- +'@clack/prompts': minor +'@clack/core': minor +--- + +Feat multiline support diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 25125f99..a1a1dafe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,4 +7,4 @@ export { default as SelectPrompt } from './prompts/select'; export { default as SelectKeyPrompt } from './prompts/select-key'; export { default as TextPrompt } from './prompts/text'; export type { ClackState as State } from './types'; -export { block, isCancel, setGlobalAliases } from './utils'; +export { block, isCancel, strLength, setGlobalAliases } from './utils'; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index f2c85771..bf1c6fe6 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -4,6 +4,7 @@ import type { Readable, Writable } from 'node:stream'; import { WriteStream } from 'node:tty'; import { cursor, erase } from 'sisteransi'; import wrap from 'wrap-ansi'; +import { strLength } from '../utils'; import { ALIASES, CANCEL_SYMBOL, KEYS, diffLines, hasAliasKey, setRawMode } from '../utils'; @@ -19,6 +20,88 @@ export interface PromptOptions { debug?: boolean; } +export type LineOption = 'firstLine' | 'newLine' | 'lastLine'; + +export interface FormatLineOptions { + /** + * Define the start of line + * @example + * format('foo', { + * line: { + * start: '-' + * } + * }) + * //=> '- foo' + */ + start: string; + /** + * Define the end of line + * @example + * format('foo', { + * line: { + * end: '-' + * } + * }) + * //=> 'foo -' + */ + end: string; + /** + * Define the sides of line + * @example + * format('foo', { + * line: { + * sides: '-' + * } + * }) + * //=> '- foo -' + */ + sides: string; + /** + * Define the style of line + * @example + * format('foo', { + * line: { + * style: (line) => `(${line})` + * } + * }) + * //=> '(foo)' + */ + style: (line: string) => string; +} + +export interface FormatOptions extends Record> { + /** + * Shorthand to define values for each line + * @example + * format('foo', { + * default: { + * start: '-' + * } + * // equals + * firstLine{ + * start: '-' + * }, + * newLine{ + * start: '-' + * }, + * lastLine{ + * start: '-' + * }, + * }) + */ + default: Partial; + /** + * Define the max width of each line + * @example + * format('foo bar baz', { + * maxWidth: 7 + * }) + * //=> 'foo bar\nbaz' + */ + maxWidth: number; + minWidth: number; +} + export default class Prompt { protected input: Readable; protected output: Writable; @@ -221,8 +304,105 @@ export default class Prompt { this.output.write(cursor.move(-999, lines * -1)); } + public format(text: string, options?: Partial): string { + const getLineOption = ( + line: TLine, + key: TKey + ): NonNullable => { + return ( + key === 'style' + ? (options?.[line]?.[key] ?? options?.default?.[key] ?? ((line) => line)) + : (options?.[line]?.[key] ?? options?.[line]?.sides ?? options?.default?.[key] ?? '') + ) as NonNullable; + }; + const getLineOptions = (line: LineOption): Omit => { + return { + start: getLineOption(line, 'start'), + end: getLineOption(line, 'end'), + style: getLineOption(line, 'style'), + }; + }; + + const firstLine = getLineOptions('firstLine'); + const newLine = getLineOptions('newLine'); + const lastLine = getLineOptions('lastLine'); + + const emptySlots = + Math.max( + strLength(firstLine.start + firstLine.end), + strLength(newLine.start + newLine.end), + strLength(lastLine.start + lastLine.end) + ) + 2; + const terminalWidth = process.stdout.columns || 80; + const maxWidth = options?.maxWidth ?? terminalWidth; + const minWidth = options?.minWidth ?? 1; + + const formattedLines: string[] = []; + const paragraphs = text.split(/\n/g); + + for (const paragraph of paragraphs) { + const words = paragraph.split(/\s/g); + let currentLine = ''; + + for (const word of words) { + if (strLength(currentLine + word) + emptySlots + 1 <= maxWidth) { + currentLine += ` ${word}`; + } else if (strLength(word) + emptySlots >= maxWidth) { + const splitIndex = maxWidth - strLength(currentLine) - emptySlots - 1; + formattedLines.push(`${currentLine} ${word.slice(0, splitIndex)}`); + + const chunkLength = maxWidth - emptySlots; + let chunk = word.slice(splitIndex); + while (strLength(chunk) > chunkLength) { + formattedLines.push(chunk.slice(0, chunkLength)); + chunk = chunk.slice(chunkLength); + } + currentLine = chunk; + } else { + formattedLines.push(currentLine); + currentLine = word; + } + } + + formattedLines.push(currentLine); + } + + return formattedLines + .map((line, i, ar) => { + const opt = >( + position: TPosition + ): FormatLineOptions[TPosition] => { + return ( + i === 0 && ar.length === 1 + ? (options?.firstLine?.[position] ?? + options?.lastLine?.[position] ?? + firstLine[position]) + : i === 0 + ? firstLine[position] + : i + 1 === ar.length + ? lastLine[position] + : newLine[position] + ) as FormatLineOptions[TPosition]; + }; + const startLine = opt('start'); + const endLine = opt('end'); + const styleLine = opt('style'); + // only format the line without the leading space. + const leadingSpaceRegex = /^\s/; + const styledLine = leadingSpaceRegex.test(line) + ? ` ${styleLine(line.slice(1))}` + : styleLine(line); + const fullLine = + styledLine + ' '.repeat(Math.max(minWidth - strLength(styledLine) - emptySlots, 0)); + return [startLine, fullLine, endLine].join(' '); + }) + .join('\n'); + } + private render() { - const frame = wrap(this._render(this) ?? '', process.stdout.columns, { hard: true }); + const frame = wrap(this._render(this) ?? '', process.stdout.columns, { + hard: true, + }); if (frame === this._prevFrame) return; if (this.state === 'initial') { diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index ef0707c8..64a5a353 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -69,3 +69,55 @@ export function block({ rl.close(); }; } + +function ansiRegex(): RegExp { + const pattern = [ + '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', + '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))', + ].join('|'); + + return new RegExp(pattern, 'g'); +} + +function stripAnsi(str: string): string { + return str.replace(ansiRegex(), ''); +} + +function isControlCharacter(code: number): boolean { + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); +} + +function isCombiningCharacter(code: number): boolean { + return code >= 0x300 && code <= 0x36f; +} + +function isSurrogatePair(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff; +} + +export function strLength(str: string): number { + if (str === '') { + return 0; + } + + // Remove ANSI escape codes from the input string. + const stripedStr = stripAnsi(str); + + let length = 0; + + for (let i = 0; i < stripedStr.length; i++) { + const code = stripedStr.codePointAt(i); + + if (!code || isControlCharacter(code) || isCombiningCharacter(code)) { + continue; + } + + if (isSurrogatePair(code)) { + i++; // Skip the next code unit. + } + + length++; + } + + return length; +} diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 2d753648..e370f662 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -3,12 +3,14 @@ import { GroupMultiSelectPrompt, MultiSelectPrompt, PasswordPrompt, + Prompt, SelectKeyPrompt, SelectPrompt, type State, TextPrompt, block, isCancel, + strLength, } from '@clack/core'; import isUnicodeSupported from 'is-unicode-supported'; import color from 'picocolors'; @@ -18,6 +20,7 @@ export { isCancel, setGlobalAliases } from '@clack/core'; const unicode = isUnicodeSupported(); const s = (c: string, fallback: string) => (unicode ? c : fallback); + const S_STEP_ACTIVE = s('◆', '*'); const S_STEP_CANCEL = s('■', 'x'); const S_STEP_ERROR = s('▲', 'x'); @@ -58,6 +61,8 @@ const symbol = (state: State) => { } }; +const format = Prompt.prototype.format; + interface LimitOptionsParams { options: TOption[]; maxItems: number | undefined; @@ -95,6 +100,102 @@ const limitOptions = (params: LimitOptionsParams): string[] => }); }; +interface ThemeParams { + ctx: Omit; + message: string; + value: string; + valueWithCursor: string | undefined; + placeholder?: string | undefined; + error?: string | undefined; +} + +function applyTheme(data: ThemeParams): string { + const { ctx, message } = data; + + const title = [ + color.gray(S_BAR), + format(message, { + firstLine: { + start: symbol(ctx.state), + }, + default: { + start: color.gray(S_BAR), + }, + }), + ].join('\n'); + + const placeholder = data.placeholder + ? color.inverse(data.placeholder[0]) + color.dim(data.placeholder.slice(1)) + : color.inverse(color.hidden('_')); + + const value = data.value ?? ''; + + switch (ctx.state) { + case 'cancel': + return [ + title, + format(value, { + default: { + start: color.gray(S_BAR), + style: (line) => color.strikethrough(color.dim(line)), + }, + }), + ] + .filter(Boolean) + .join('\n'); + + case 'error': + return [ + title, + format(value, { + default: { + start: color.yellow(S_BAR), + }, + }), + data.error ?? + format(ctx.error, { + default: { + start: color.yellow(S_BAR), + style: color.yellow, + }, + lastLine: { + start: color.yellow(S_BAR_END), + }, + }), + ].join('\n'); + + case 'submit': + return [ + title, + format(value, { + default: { + start: color.gray(S_BAR), + style: color.dim, + }, + }), + ].join('\n'); + + default: + return [ + color.gray(S_BAR), + format(message, { + firstLine: { + start: symbol(ctx.state), + }, + default: { + start: color.cyan(S_BAR), + }, + }), + format(data.placeholder && !data.value ? placeholder : (data.valueWithCursor ?? value), { + default: { + start: color.cyan(S_BAR), + }, + }), + color.cyan(S_BAR_END), + ].join('\n'); + } +} + export interface TextOptions { message: string; placeholder?: string; @@ -109,26 +210,13 @@ export const text = (opts: TextOptions) => { defaultValue: opts.defaultValue, initialValue: opts.initialValue, render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - const placeholder = opts.placeholder - ? color.inverse(opts.placeholder[0]) + color.dim(opts.placeholder.slice(1)) - : color.inverse(color.hidden('_')); - const value = !this.value ? placeholder : this.valueWithCursor; - - switch (this.state) { - case 'error': - return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow( - S_BAR_END - )} ${color.yellow(this.error)}\n`; - case 'submit': - return `${title}${color.gray(S_BAR)} ${color.dim(this.value || opts.placeholder)}`; - case 'cancel': - return `${title}${color.gray(S_BAR)} ${color.strikethrough( - color.dim(this.value ?? '') - )}${this.value?.trim() ? `\n${color.gray(S_BAR)}` : ''}`; - default: - return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`; - } + return applyTheme({ + ctx: this, + message: opts.message, + value: this.value, + valueWithCursor: this.valueWithCursor, + placeholder: opts.placeholder, + }); }, }).prompt() as Promise; }; @@ -143,24 +231,12 @@ export const password = (opts: PasswordOptions) => { validate: opts.validate, mask: opts.mask ?? S_PASSWORD_MASK, render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - const value = this.valueWithCursor; - const masked = this.masked; - - switch (this.state) { - case 'error': - return `${title.trim()}\n${color.yellow(S_BAR)} ${masked}\n${color.yellow( - S_BAR_END - )} ${color.yellow(this.error)}\n`; - case 'submit': - return `${title}${color.gray(S_BAR)} ${color.dim(masked)}`; - case 'cancel': - return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(masked ?? ''))}${ - masked ? `\n${color.gray(S_BAR)}` : '' - }`; - default: - return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`; - } + return applyTheme({ + ctx: this, + message: opts.message, + value: this.valueWithCursor, + valueWithCursor: this.valueWithCursor, + }); }, }).prompt() as Promise; }; @@ -179,28 +255,22 @@ export const confirm = (opts: ConfirmOptions) => { inactive, initialValue: opts.initialValue ?? true, render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - const value = this.value ? active : inactive; - - switch (this.state) { - case 'submit': - return `${title}${color.gray(S_BAR)} ${color.dim(value)}`; - case 'cancel': - return `${title}${color.gray(S_BAR)} ${color.strikethrough( - color.dim(value) - )}\n${color.gray(S_BAR)}`; - default: { - return `${title}${color.cyan(S_BAR)} ${ - this.value - ? `${color.green(S_RADIO_ACTIVE)} ${active}` - : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}` - } ${color.dim('/')} ${ - !this.value - ? `${color.green(S_RADIO_ACTIVE)} ${inactive}` - : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(inactive)}` - }\n${color.cyan(S_BAR_END)}\n`; - } - } + const opt = (state: boolean, message: string): string => { + return state + ? `${color.green(S_RADIO_ACTIVE)} ${message}` + : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(message)}`; + }; + return applyTheme({ + ctx: this, + message: opts.message, + value: + this.state === 'submit' || this.state === 'cancel' + ? this.value + ? active + : inactive + : `${opt(!!this.value, active)} ${color.dim('/')} ${opt(!this.value, inactive)}`, + valueWithCursor: undefined, + }); }, }).prompt() as Promise; }; @@ -239,25 +309,30 @@ export const select = (opts: SelectOptions) => { options: opts.options, initialValue: opts.initialValue, render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - + let value: string; switch (this.state) { case 'submit': - return `${title}${color.gray(S_BAR)} ${opt(this.options[this.cursor], 'selected')}`; + value = opt(this.options[this.cursor], 'selected'); + break; case 'cancel': - return `${title}${color.gray(S_BAR)} ${opt( - this.options[this.cursor], - 'cancelled' - )}\n${color.gray(S_BAR)}`; + value = opt(this.options[this.cursor], 'cancelled'); + break; default: { - return `${title}${color.cyan(S_BAR)} ${limitOptions({ + value = limitOptions({ cursor: this.cursor, options: this.options, maxItems: opts.maxItems, style: (item, active) => opt(item, active ? 'active' : 'inactive'), - }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + }).join('\n'); + break; } } + return applyTheme({ + ctx: this, + message: opts.message, + value, + valueWithCursor: undefined, + }); }, }).prompt() as Promise; }; @@ -279,32 +354,33 @@ export const selectKey = (opts: SelectOptions) => { option.hint ? color.dim(`(${option.hint})`) : '' }`; } - return `${color.gray(color.bgWhite(color.inverse(` ${option.value} `)))} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : '' - }`; + return `${color.gray( + color.bgWhite(color.inverse(` ${option.value} `)) + )} ${label} ${option.hint ? color.dim(`(${option.hint})`) : ''}`; }; return new SelectKeyPrompt({ options: opts.options, initialValue: opts.initialValue, render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}`; switch (this.state) { case 'submit': - return `${title}${color.gray(S_BAR)} ${opt( - this.options.find((opt) => opt.value === this.value) ?? opts.options[0], + return `${title}\n${color.gray(S_BAR)} ${opt( + // biome-ignore lint/style/noNonNullAssertion: + this.options.find((opt) => opt.value === this.value)!, 'selected' )}`; case 'cancel': - return `${title}${color.gray(S_BAR)} ${opt(this.options[0], 'cancelled')}\n${color.gray( - S_BAR - )}`; - default: { - return `${title}${color.cyan(S_BAR)} ${this.options + return `${title}\n${color.gray(S_BAR)} ${opt( + this.options[0], + 'cancelled' + )}\n${color.gray(S_BAR)}`; + default: + return `${title}\n${color.cyan(S_BAR)} ${this.options .map((option, i) => opt(option, i === this.cursor ? 'active' : 'inactive')) .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; - } } }, }).prompt() as Promise; @@ -362,7 +438,8 @@ export const multiselect = (opts: MultiSelectOptions) => { )}`; }, render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + let value: string; + let error: string | undefined; const styleOption = (option: Option, active: boolean) => { const selected = this.value.includes(option.value); @@ -377,45 +454,61 @@ export const multiselect = (opts: MultiSelectOptions) => { switch (this.state) { case 'submit': { - return `${title}${color.gray(S_BAR)} ${ + value = this.options .filter(({ value }) => this.value.includes(value)) .map((option) => opt(option, 'submitted')) - .join(color.dim(', ')) || color.dim('none') - }`; + .join(color.dim(', ')) || color.dim('none'); + break; } case 'cancel': { - const label = this.options - .filter(({ value }) => this.value.includes(value)) - .map((option) => opt(option, 'cancelled')) - .join(color.dim(', ')); - return `${title}${color.gray(S_BAR)} ${ - label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' - }`; + value = + this.options + .filter(({ value }) => this.value.includes(value)) + .map((option) => opt(option, 'cancelled')) + .join(color.dim(', ')) ?? ''; + break; } case 'error': { - const footer = this.error - .split('\n') - .map((ln, i) => - i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}` - ) - .join('\n'); - return `${title + color.yellow(S_BAR)} ${limitOptions({ - options: this.options, + error = format( + this.error + .split('\n') + .map((ln, i) => (i === 0 ? color.yellow(ln) : ln)) + .join('\n'), + { + firstLine: { + start: color.yellow(S_BAR_END), + }, + default: { + start: color.hidden('-'), + }, + } + ); + value = limitOptions({ cursor: this.cursor, maxItems: opts.maxItems, + options: this.options, style: styleOption, - }).join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`; + }).join('\n'); + break; } default: { - return `${title}${color.cyan(S_BAR)} ${limitOptions({ - options: this.options, + value = limitOptions({ cursor: this.cursor, maxItems: opts.maxItems, + options: this.options, style: styleOption, - }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + }).join('\n'); + break; } } + return applyTheme({ + ctx: this, + message: opts.message, + value, + error, + valueWithCursor: undefined, + }); }, }).prompt() as Promise; }; @@ -465,9 +558,9 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => return `${color.strikethrough(color.dim(label))}`; } if (state === 'active-selected') { - return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : '' - }`; + return `${color.dim(prefix)}${color.green( + S_CHECKBOX_SELECTED + )} ${label} ${option.hint ? color.dim(`(${option.hint})`) : ''}`; } if (state === 'submitted') { return `${color.dim(label)}`; @@ -491,11 +584,25 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => )}`; }, render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const symbol = (state: State) => { + switch (state) { + case 'initial': + case 'active': + return color.cyan(S_STEP_ACTIVE); + case 'cancel': + return color.red(S_STEP_CANCEL); + case 'error': + return color.yellow(S_STEP_ERROR); + case 'submit': + return color.green(S_STEP_SUBMIT); + } + }; + + const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}`; switch (this.state) { case 'submit': { - return `${title}${color.gray(S_BAR)} ${this.options + return `${title}\n${color.gray(S_BAR)} ${this.options .filter(({ value }) => this.value.includes(value)) .map((option) => opt(option, 'submitted')) .join(color.dim(', '))}`; @@ -505,7 +612,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => .filter(({ value }) => this.value.includes(value)) .map((option) => opt(option, 'cancelled')) .join(color.dim(', ')); - return `${title}${color.gray(S_BAR)} ${ + return `${title}\n${color.gray(S_BAR)} ${ label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' }`; } @@ -516,7 +623,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}` ) .join('\n'); - return `${title}${color.yellow(S_BAR)} ${this.options + return `${title}\n${color.yellow(S_BAR)} ${this.options .map((option, i, options) => { const selected = this.value.includes(option.value) || @@ -540,7 +647,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => .join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`; } default: { - return `${title}${color.cyan(S_BAR)} ${this.options + return `${title}\n${color.cyan(S_BAR)} ${this.options .map((option, i, options) => { const selected = this.value.includes(option.value) || @@ -568,43 +675,84 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => }).prompt() as Promise; }; -const strip = (str: string) => str.replace(ansiRegex(), ''); export const note = (message = '', title = '') => { - const lines = `\n${message}\n`.split('\n'); - const titleLen = strip(title).length; - const len = - Math.max( - lines.reduce((sum, ln) => { - const line = strip(ln); - return line.length > sum ? line.length : sum; - }, 0), - titleLen - ) + 2; - const msg = lines - .map( - (ln) => - `${color.gray(S_BAR)} ${color.dim(ln)}${' '.repeat(len - strip(ln).length)}${color.gray( - S_BAR - )}` - ) - .join('\n'); - process.stdout.write( - `${color.gray(S_BAR)}\n${color.green(S_STEP_SUBMIT)} ${color.reset(title)} ${color.gray( - S_BAR_H.repeat(Math.max(len - titleLen - 1, 1)) + S_CORNER_TOP_RIGHT - )}\n${msg}\n${color.gray(S_CONNECT_LEFT + S_BAR_H.repeat(len + 2) + S_CORNER_BOTTOM_RIGHT)}\n` - ); + const maxWidth = Math.floor((process.stdout.columns ?? 80) * 0.8); + const lines = format(message, { + default: { + start: color.gray(S_BAR), + }, + maxWidth: maxWidth - 2, + }).split(/\n/g); + const titleLen = strLength(title); + const messageLen = lines.reduce((sum, line) => { + const length = strLength(line); + return length > sum ? length : sum; + }, 0); + const len = Math.min(Math.max(messageLen, titleLen) + 2, maxWidth); + const noteBox = [ + color.gray(S_BAR), + `${color.green(S_STEP_SUBMIT)} ${color.reset(title)} ${color.gray( + S_BAR_H.repeat(Math.max(len - titleLen - 3, 0)) + S_CORNER_TOP_RIGHT + )}`, + color.gray(S_BAR + ' '.repeat(len) + S_BAR), + lines + .map( + (line) => + line + + ' '.repeat(Math.max(len + (unicode ? 2 : 1) - strLength(line), 0)) + + color.gray(S_BAR) + ) + .join('\n'), + color.gray(S_BAR + ' '.repeat(len) + S_BAR), + color.gray(S_CONNECT_LEFT + S_BAR_H.repeat(len) + S_CORNER_BOTTOM_RIGHT), + '', + ].join('\n'); + process.stdout.write(noteBox); }; export const cancel = (message = '') => { - process.stdout.write(`${color.gray(S_BAR_END)} ${color.red(message)}\n\n`); + process.stdout.write( + `${format(message, { + default: { + start: color.gray(S_BAR), + style: color.red, + }, + lastLine: { + start: color.gray(S_BAR_END), + }, + })}\n\n` + ); }; export const intro = (title = '') => { - process.stdout.write(`${color.gray(S_BAR_START)} ${title}\n`); + process.stdout.write( + `${format(title, { + firstLine: { + start: color.gray(S_BAR_START), + }, + default: { + start: color.gray(S_BAR), + }, + })}\n` + ); }; export const outro = (message = '') => { - process.stdout.write(`${color.gray(S_BAR)}\n${color.gray(S_BAR_END)} ${message}\n\n`); + process.stdout.write( + [ + color.gray(S_BAR), + format(message, { + default: { + start: color.gray(S_BAR), + }, + lastLine: { + start: color.gray(S_BAR_END), + }, + }), + '', + '', + ].join('\n') + ); }; export type LogMessageOptions = { @@ -612,31 +760,45 @@ export type LogMessageOptions = { }; export const log = { message: (message = '', { symbol = color.gray(S_BAR) }: LogMessageOptions = {}) => { - const parts = [`${color.gray(S_BAR)}`]; - if (message) { - const [firstLine, ...lines] = message.split('\n'); - parts.push(`${symbol} ${firstLine}`, ...lines.map((ln) => `${color.gray(S_BAR)} ${ln}`)); - } - process.stdout.write(`${parts.join('\n')}\n`); + process.stdout.write( + `${format(message, { + firstLine: { + start: symbol, + }, + default: { + start: color.gray(S_BAR), + }, + })}\n` + ); }, info: (message: string) => { - log.message(message, { symbol: color.blue(S_INFO) }); + log.message(message, { + symbol: color.blue(S_INFO), + }); }, success: (message: string) => { - log.message(message, { symbol: color.green(S_SUCCESS) }); + log.message(message, { + symbol: color.green(S_SUCCESS), + }); }, step: (message: string) => { - log.message(message, { symbol: color.green(S_STEP_SUBMIT) }); + log.message(message, { + symbol: color.green(S_STEP_SUBMIT), + }); }, warn: (message: string) => { - log.message(message, { symbol: color.yellow(S_WARN) }); + log.message(message, { + symbol: color.yellow(S_WARN), + }); }, /** alias for `log.warn()`. */ warning: (message: string) => { log.warn(message); }, error: (message: string) => { - log.message(message, { symbol: color.red(S_ERROR) }); + log.message(message, { + symbol: color.red(S_ERROR), + }); }, }; @@ -651,6 +813,25 @@ export const spinner = () => { let _message = ''; let _prevMessage: string | undefined = undefined; + const formatMessage = (symbol: string, msg: string): string => { + return format(msg, { + firstLine: { + start: symbol, + }, + default: { + start: color.gray(S_BAR), + }, + }); + }; + + const clearPrevMessage = (): void => { + if (_prevMessage === undefined) return; + if (isCI) process.stdout.write('\n'); + const linesCounter = _prevMessage.split(/\n/g).length; + process.stdout.write(cursor.move(-999, (linesCounter - 1) * -1)); + process.stdout.write(erase.down(linesCounter)); + }; + const handleExit = (code: number) => { const msg = code > 1 ? 'Something went wrong' : 'Canceled'; if (isSpinnerActive) stop(msg, code); @@ -678,14 +859,6 @@ export const spinner = () => { process.removeListener('exit', handleExit); }; - const clearPrevMessage = () => { - if (_prevMessage === undefined) return; - if (isCI) process.stdout.write('\n'); - const prevLines = _prevMessage.split('\n'); - process.stdout.write(cursor.move(-999, prevLines.length - 1)); - process.stdout.write(erase.down(prevLines.length)); - }; - const parseMessage = (msg: string): string => { return msg.replace(/\.+$/, ''); }; @@ -706,7 +879,8 @@ export const spinner = () => { _prevMessage = _message; const frame = color.magenta(frames[frameIndex]); const loadingDots = isCI ? '...' : '.'.repeat(Math.floor(dotsTimer)).slice(0, 3); - process.stdout.write(`${frame} ${_message}${loadingDots}`); + _prevMessage = _message; + process.stdout.write(formatMessage(frame, _message + loadingDots)); frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0; dotsTimer = dotsTimer < frames.length ? dotsTimer + 0.125 : 0; }, delay); @@ -729,7 +903,7 @@ export const spinner = () => { }; const message = (msg = ''): void => { - _message = parseMessage(msg ?? _message); + _message = parseMessage(msg || _message); }; return { @@ -739,17 +913,6 @@ export const spinner = () => { }; }; -// Adapted from https://github.com/chalk/ansi-regex -// @see LICENSE -function ansiRegex() { - const pattern = [ - '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', - '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))', - ].join('|'); - - return new RegExp(pattern, 'g'); -} - export type PromptGroupAwaitedReturn = { [P in keyof T]: Exclude, symbol>; }; @@ -759,7 +922,9 @@ export interface PromptGroupOptions { * Control how the group can be canceled * if one of the prompts is canceled. */ - onCancel?: (opts: { results: Prettify>> }) => void; + onCancel?: (opts: { + results: Prettify>>; + }) => void; } type Prettify = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da2a1e3c..6113bdee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3254,7 +3254,7 @@ snapshots: fastq: 1.17.1 '@rollup/plugin-alias@5.1.1(rollup@3.29.5)': - optionalDependencies: + dependencies: rollup: 3.29.5 '@rollup/plugin-commonjs@25.0.8(rollup@3.29.5)': @@ -3265,13 +3265,11 @@ snapshots: glob: 8.1.0 is-reference: 1.2.1 magic-string: 0.30.13 - optionalDependencies: rollup: 3.29.5 '@rollup/plugin-json@6.1.0(rollup@3.29.5)': dependencies: '@rollup/pluginutils': 5.1.3(rollup@3.29.5) - optionalDependencies: rollup: 3.29.5 '@rollup/plugin-node-resolve@15.3.0(rollup@3.29.5)': @@ -3281,14 +3279,12 @@ snapshots: deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.8 - optionalDependencies: rollup: 3.29.5 '@rollup/plugin-replace@5.0.7(rollup@3.29.5)': dependencies: '@rollup/pluginutils': 5.1.3(rollup@3.29.5) magic-string: 0.30.13 - optionalDependencies: rollup: 3.29.5 '@rollup/pluginutils@5.1.3(rollup@3.29.5)': @@ -3296,7 +3292,6 @@ snapshots: '@types/estree': 1.0.6 estree-walker: 2.0.2 picomatch: 4.0.2 - optionalDependencies: rollup: 3.29.5 '@rollup/rollup-android-arm-eabi@4.27.3': @@ -3998,7 +3993,7 @@ snapshots: reusify: 1.0.4 fdir@6.4.2(picomatch@4.0.2): - optionalDependencies: + dependencies: picomatch: 4.0.2 fill-range@7.1.1: @@ -4431,7 +4426,6 @@ snapshots: postcss-nested: 6.2.0(postcss@8.4.49) semver: 7.6.3 tinyglobby: 0.2.10 - optionalDependencies: typescript: 5.2.2 mlly@1.7.3: @@ -5149,9 +5143,8 @@ snapshots: rollup: 3.29.5 rollup-plugin-dts: 6.1.1(rollup@3.29.5)(typescript@5.2.2) scule: 1.3.0 - untyped: 1.5.1 - optionalDependencies: typescript: 5.2.2 + untyped: 1.5.1 transitivePeerDependencies: - sass - supports-color @@ -5204,15 +5197,16 @@ snapshots: vite@5.4.11(@types/node@18.16.0): dependencies: + '@types/node': 18.16.0 esbuild: 0.21.5 postcss: 8.4.49 rollup: 4.27.3 optionalDependencies: - '@types/node': 18.16.0 fsevents: 2.3.3 vitest@1.6.0(@types/node@18.16.0): dependencies: + '@types/node': 18.16.0 '@vitest/expect': 1.6.0 '@vitest/runner': 1.6.0 '@vitest/snapshot': 1.6.0 @@ -5233,8 +5227,6 @@ snapshots: vite: 5.4.11(@types/node@18.16.0) vite-node: 1.6.0(@types/node@18.16.0) why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 18.16.0 transitivePeerDependencies: - less - lightningcss @@ -5369,4 +5361,4 @@ snapshots: yocto-queue@0.1.0: {} yocto-queue@1.1.1: {} - \ No newline at end of file +