diff --git a/.changeset/calm-keys-listen.md b/.changeset/calm-keys-listen.md new file mode 100644 index 00000000..11b6a6a7 --- /dev/null +++ b/.changeset/calm-keys-listen.md @@ -0,0 +1,5 @@ +--- +"@clack/core": patch +--- + +Stop the multi-line prompt from inserting control characters into its value, such as the DEL sent by backspace at the start of the input or the characters sent by ctrl+key combinations. diff --git a/packages/core/src/prompts/multi-line.ts b/packages/core/src/prompts/multi-line.ts index f6b6139c..dc3d8f5d 100644 --- a/packages/core/src/prompts/multi-line.ts +++ b/packages/core/src/prompts/multi-line.ts @@ -6,6 +6,12 @@ import Prompt, { type PromptOptions } from './prompt.js'; type CursorAction = 'up' | 'down' | 'left' | 'right'; const cursorActions = new Set(['up', 'down', 'left', 'right']); +// readline reports keys like backspace or ctrl+a as a control character (`\x7f`, `\x01`) +function isControlCharacter(char: string): boolean { + const code = char.charCodeAt(0); + return code === 0x7f || (code < 0x20 && char !== '\t' && char !== '\n'); +} + export interface MultiLineOptions extends PromptOptions { placeholder?: string; defaultValue?: string; @@ -130,7 +136,7 @@ export default class MultiLinePrompt extends Prompt { ); return; } - if (char) { + if (char && !isControlCharacter(char)) { if (this.#showSubmit && this.focused === 'submit') { this.focused = 'editor'; } diff --git a/packages/core/test/prompts/multi-line.test.ts b/packages/core/test/prompts/multi-line.test.ts index c04e6183..cf220438 100644 --- a/packages/core/test/prompts/multi-line.test.ts +++ b/packages/core/test/prompts/multi-line.test.ts @@ -309,6 +309,59 @@ describe('MultiLinePrompt', () => { await resultPromise; }); + test('backspace at start does not insert its control character', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + // a terminal sends DEL (or BS) as the character of the backspace key + input.emit('keypress', '\x7f', { name: 'backspace', sequence: '\x7f' }); + expect(instance.userInput).to.equal(''); + input.emit('keypress', '\b', { name: 'backspace', sequence: '\b' }); + expect(instance.userInput).to.equal(''); + 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('ctrl key combinations do not insert control characters', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + // ctrl+a and ctrl+u arrive as the control characters SOH and NAK + input.emit('keypress', '\x01', { name: 'a', sequence: '\x01', ctrl: true }); + input.emit('keypress', '\x15', { name: 'u', sequence: '\x15', ctrl: true }); + expect(instance.userInput).to.equal('x'); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('x'); + }); + + test('tab and newline characters are still inserted', async () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '\t', { name: 'tab', sequence: '\t' }); + input.emit('keypress', '\n', { name: 'enter', sequence: '\n' }); + input.emit('keypress', 'y', { name: 'y' }); + expect(instance.userInput).to.equal('x\t\ny'); + input.emit('keypress', '', { name: 'escape' }); + await resultPromise; + }); + test('left moves left until start', async () => { const instance = new MultiLinePrompt({ input,