Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-keys-listen.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion packages/core/src/prompts/multi-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import Prompt, { type PromptOptions } from './prompt.js';
type CursorAction = 'up' | 'down' | 'left' | 'right';
const cursorActions = new Set<CursorAction>(['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<string, MultiLinePrompt> {
placeholder?: string;
defaultValue?: string;
Expand Down Expand Up @@ -130,7 +136,7 @@ export default class MultiLinePrompt extends Prompt<string> {
);
return;
}
if (char) {
if (char && !isControlCharacter(char)) {
if (this.#showSubmit && this.focused === 'submit') {
this.focused = 'editor';
}
Expand Down
53 changes: 53 additions & 0 deletions packages/core/test/prompts/multi-line.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading