diff --git a/examples/changesets/index.ts b/examples/changesets/index.ts index 044afddc..34a0284b 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 af4413ed..b3ca8248 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,5 +9,6 @@ export { default as Prompt } from './prompts/prompt.js'; 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 { default as MultiLinePrompt } from './prompts/multi-line.js'; export { block, isCancel } from './utils/index.js'; export { updateSettings } from './utils/settings.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..51dca57a --- /dev/null +++ b/packages/core/src/prompts/multi-line.ts @@ -0,0 +1,100 @@ +import color from 'picocolors'; +import { type Action, settings } from '../utils/index.js'; +import Prompt from './prompt.js'; +import type { TextOptions } from './text.js'; + +export default class MultiLinePrompt extends Prompt { + get valueWithCursor() { + if (this.state === 'submit') { + return this.value; + } + if (this.cursor >= this.value.length) { + return `${this.value}█`; + } + const s1 = this.value.slice(0, this.cursor); + const [s2, ...s3] = this.value.slice(this.cursor); + return `${s1}${color.inverse(s2)}${s3.join('')}`; + } + get cursor() { + return this._cursor; + } + insertAtCursor(char: string) { + if (!this.value || this.value.length === 0) { + this.value = char; + return; + } + this.value = this.value.substr(0, this.cursor) + char + this.value.substr(this.cursor); + } + handleCursor(key?: Action) { + const text = this.value ?? ''; + const lines = text.split('\n'); + const beforeCursor = text.substr(0, this.cursor); + const currentLine = beforeCursor.split('\n').length - 1; + const lineStart = beforeCursor.lastIndexOf('\n'); + const cursorOffet = this.cursor - lineStart; + switch (key) { + case 'up': + if (currentLine === 0) { + this._cursor = 0; + return; + } + this._cursor += + -cursorOffet - + lines[currentLine - 1].length + + Math.min(lines[currentLine - 1].length, cursorOffet - 1); + return; + case 'down': + if (currentLine === lines.length - 1) { + this._cursor = text.length; + return; + } + this._cursor += + -cursorOffet + + 1 + + lines[currentLine].length + + Math.min(lines[currentLine + 1].length + 1, cursorOffet); + return; + case 'left': + this._cursor = Math.max(0, this._cursor - 1); + return; + case 'right': + this._cursor = Math.min(text.length, this._cursor + 1); + return; + } + } + constructor(opts: TextOptions) { + super(opts, false); + + this.on('rawKey', (char, key) => { + if (settings.actions.has(key?.name)) { + this.handleCursor(key?.name); + } + if (char === '\r') { + this.insertAtCursor('\n'); + this._cursor++; + return; + } + if (char === '\u0004') { + return; + } + if (key?.name === 'backspace' && this.cursor > 0) { + this.value = this.value.substr(0, this.cursor - 1) + this.value.substr(this.cursor); + this._cursor--; + return; + } + if (key?.name === 'delete' && this.cursor < this.value.length) { + this.value = this.value.substr(0, this.cursor) + this.value.substr(this.cursor + 1); + return; + } + if (char) { + this.insertAtCursor(char ?? ''); + this._cursor++; + } + }); + this.on('finalize', () => { + if (!this.value) { + this.value = opts.defaultValue; + } + }); + } +} diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index a5101dd4..b46a6b59 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -5,7 +5,14 @@ import { WriteStream } from 'node:tty'; import { cursor, erase } from 'sisteransi'; import wrap from 'wrap-ansi'; -import { CANCEL_SYMBOL, diffLines, isActionKey, setRawMode, settings } from '../utils/index.js'; +import { + CANCEL_SYMBOL, + diffLines, + isActionKey, + isSameKey, + setRawMode, + settings, +} from '../utils/index.js'; import type { ClackEvents, ClackState } from '../types.js'; import type { Action } from '../utils/index.js'; @@ -19,6 +26,7 @@ export interface PromptOptions { output?: Writable; debug?: boolean; signal?: AbortSignal; + submitKey?: Key; } export default class Prompt { @@ -33,6 +41,7 @@ export default class Prompt { private _prevFrame = ''; private _subscribers = new Map any; once?: boolean }[]>(); protected _cursor = 0; + private _submitKey: Key; public state: ClackState = 'initial'; public error = ''; @@ -48,6 +57,7 @@ export default class Prompt { this._render = render.bind(this); this._track = trackValue; this._abortSignal = signal; + this._submitKey = options.submitKey ?? { name: 'return' }; this.input = input; this.output = output; @@ -202,8 +212,9 @@ export default class Prompt { if (char) { this.emit('key', char.toLowerCase()); } + this.emit('rawKey', char, key); - if (key?.name === 'return') { + if (isSameKey(key, this._submitKey)) { if (this.opts.validate) { const problem = this.opts.validate(this.value); if (problem) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 12eaf917..d84846b1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -16,6 +16,7 @@ export interface ClackEvents { error: (value?: any) => void; cursor: (key?: Action) => void; key: (key?: string) => void; + rawKey: (char?: string, key?: any) => void; value: (value?: string) => void; confirm: (value?: boolean) => void; finalize: () => void; diff --git a/packages/core/src/utils/settings.ts b/packages/core/src/utils/settings.ts index 2d785f7a..724a5793 100644 --- a/packages/core/src/utils/settings.ts +++ b/packages/core/src/utils/settings.ts @@ -1,3 +1,5 @@ +import type { Key } from 'node:readline'; + const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const; export type Action = (typeof actions)[number]; @@ -71,3 +73,15 @@ export function isActionKey(key: string | Array, action: Act } return false; } + +export function isSameKey(actual: Key | undefined, expected: Key): boolean { + if (actual === undefined) { + return false; + } + return ( + actual.name === expected.name && + (actual.ctrl ?? false) === (expected.ctrl ?? false) && + (actual.meta ?? false) === (expected.meta ?? false) && + (actual.shift ?? false) === (expected.shift ?? false) + ); +} diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index e50ecf3a..6acb6ac1 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -1,7 +1,9 @@ +import type { Key } from 'node:readline'; import { stripVTControlCharacters as strip } from 'node:util'; import { ConfirmPrompt, GroupMultiSelectPrompt, + MultiLinePrompt, MultiSelectPrompt, PasswordPrompt, SelectKeyPrompt, @@ -135,6 +137,46 @@ export const text = (opts: TextOptions) => { }).prompt() as Promise; }; +export const multiline = (opts: TextOptions) => { + function wrap( + text: string, + barStyle: (v: string) => string, + textStyle: (v: string) => string + ): string { + return `${barStyle(S_BAR)} ${text + .split('\n') + .map(textStyle) + .join(`\n${barStyle(S_BAR)} `)}`; + } + return new MultiLinePrompt({ + validate: opts.validate, + placeholder: opts.placeholder, + defaultValue: opts.defaultValue, + initialValue: opts.initialValue, + submitKey: { name: 'd', ctrl: true }, + 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: string = `${!this.value ? placeholder : this.valueWithCursor}`; + switch (this.state) { + case 'error': + return `${title.trim()}${wrap(value, color.yellow, color.yellow)}\n${color.yellow( + S_BAR_END + )} ${color.yellow(this.error)}\n`; + case 'submit': + return `${title}${wrap(this.value || opts.placeholder, color.gray, color.dim)}`; + case 'cancel': + return `${title}${wrap(this.value ?? '', color.gray, (v) => + color.strikethrough(color.dim(v)) + )}${this.value?.trim() ? `\n${color.gray(S_BAR)}` : ''}`; + default: + return `${title}${wrap(value, color.cyan, (v) => v)}\n${color.cyan(S_BAR_END)}\n`; + } + }, + }).prompt() as Promise; +}; export interface PasswordOptions { message: string; mask?: string;