From 73b9c18244d9ac682fc1ced83fe8696186fde7ad Mon Sep 17 00:00:00 2001 From: MattStypa Date: Fri, 8 May 2026 19:53:15 -0700 Subject: [PATCH 1/4] fix: spaces and uppercase characters in multiline input --- .changeset/lucky-rivers-repair.md | 5 ++++ packages/core/src/prompts/multi-line.ts | 10 +++++--- packages/core/test/prompts/multi-line.test.ts | 24 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 .changeset/lucky-rivers-repair.md diff --git a/.changeset/lucky-rivers-repair.md b/.changeset/lucky-rivers-repair.md new file mode 100644 index 00000000..6103a99e --- /dev/null +++ b/.changeset/lucky-rivers-repair.md @@ -0,0 +1,5 @@ +--- +"@clack/core": patch +--- + +Fixed spaces and uppercase characters in multiline prompt diff --git a/packages/core/src/prompts/multi-line.ts b/packages/core/src/prompts/multi-line.ts index d3765fc1..a04931a4 100644 --- a/packages/core/src/prompts/multi-line.ts +++ b/packages/core/src/prompts/multi-line.ts @@ -1,9 +1,12 @@ 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'; +const actions = ['up', 'down', 'left', 'right']; +type Action = (typeof actions)[number]; +const actionSet = new Set(actions); + export interface MultiLineOptions extends PromptOptions { placeholder?: string; defaultValue?: string; @@ -89,7 +92,7 @@ export default class MultiLinePrompt extends Prompt { this.#showSubmit = opts.showSubmit ?? false; this.on('key', (char, key) => { - if (key?.name && settings.actions.has(key.name as Action)) { + if (key?.name && actionSet.has(key.name as Action)) { this.#handleCursor(key.name as Action); return; } @@ -118,7 +121,8 @@ export default class MultiLinePrompt extends Prompt { if (this.#showSubmit && this.focused === 'submit') { this.focused = 'editor'; } - this.#insertAtCursor(char ?? ''); + const casedChar = key.shift ? char.toUpperCase() : char; + this.#insertAtCursor(casedChar ?? ''); this._cursor++; } }); diff --git a/packages/core/test/prompts/multi-line.test.ts b/packages/core/test/prompts/multi-line.test.ts index 53f86a5d..428c97d1 100644 --- a/packages/core/test/prompts/multi-line.test.ts +++ b/packages/core/test/prompts/multi-line.test.ts @@ -175,6 +175,30 @@ describe('MultiLinePrompt', () => { expect(result).to.equal('xy'); }); + test('space inserts space', () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', ' ', { name: 'space' }); + expect(instance.userInput).to.equal('x '); + }); + + test('shift modifier inserts uppercase characters', () => { + const instance = new MultiLinePrompt({ + input, + output, + render: () => 'foo', + }); + instance.prompt(); + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', 'x', { name: 'x', shift: true }); + expect(instance.userInput).to.equal('xX'); + }); + test('backspace deletes previous char', async () => { const instance = new MultiLinePrompt({ input, From 2af8aba2c7533bf243a3cd307ab8d8485bc94a16 Mon Sep 17 00:00:00 2001 From: MattStypa Date: Sat, 9 May 2026 09:33:05 -0500 Subject: [PATCH 2/4] Updated how the key event is emitted from the prompt instances to handle case sensitivity better --- .changeset/weak-clocks-switch.md | 6 ++++++ packages/core/src/prompts/multi-line.ts | 14 +++++--------- packages/core/src/prompts/multi-select.ts | 6 +++--- packages/core/src/prompts/prompt.ts | 2 +- packages/core/src/prompts/select-key.ts | 7 ++++--- packages/core/src/utils/settings.ts | 6 +++++- packages/core/test/prompts/multi-line.test.ts | 2 +- packages/prompts/test/select-key.test.ts | 4 ++-- 8 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 .changeset/weak-clocks-switch.md diff --git a/.changeset/weak-clocks-switch.md b/.changeset/weak-clocks-switch.md new file mode 100644 index 00000000..74afdf1c --- /dev/null +++ b/.changeset/weak-clocks-switch.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": patch +"@clack/core": patch +--- + +Fixed spaces and uppercase characters in multiline prompt diff --git a/packages/core/src/prompts/multi-line.ts b/packages/core/src/prompts/multi-line.ts index a04931a4..2714373a 100644 --- a/packages/core/src/prompts/multi-line.ts +++ b/packages/core/src/prompts/multi-line.ts @@ -1,12 +1,9 @@ import type { Key } from 'node:readline'; import { styleText } from 'node:util'; import { findTextCursor } from '../utils/cursor.js'; +import { type ArrowKeyAction, settings } from '../utils/index.js'; import Prompt, { type PromptOptions } from './prompt.js'; -const actions = ['up', 'down', 'left', 'right']; -type Action = (typeof actions)[number]; -const actionSet = new Set(actions); - export interface MultiLineOptions extends PromptOptions { placeholder?: string; defaultValue?: string; @@ -44,7 +41,7 @@ export default class MultiLinePrompt extends Prompt { this.userInput.slice(0, this.cursor) + char + this.userInput.slice(this.cursor) ); } - #handleCursor(key?: Action) { + #handleCursor(key?: ArrowKeyAction) { const text = this.value ?? ''; switch (key) { case 'up': @@ -92,8 +89,8 @@ export default class MultiLinePrompt extends Prompt { this.#showSubmit = opts.showSubmit ?? false; this.on('key', (char, key) => { - if (key?.name && actionSet.has(key.name as Action)) { - this.#handleCursor(key.name as Action); + if (key?.name && settings.arrowKeyActions.has(key.name as ArrowKeyAction)) { + this.#handleCursor(key.name as ArrowKeyAction); return; } if (char === '\t' && this.#showSubmit) { @@ -121,8 +118,7 @@ export default class MultiLinePrompt extends Prompt { if (this.#showSubmit && this.focused === 'submit') { this.focused = 'editor'; } - const casedChar = key.shift ? char.toUpperCase() : char; - this.#insertAtCursor(casedChar ?? ''); + this.#insertAtCursor(char ?? ''); this._cursor++; } }); diff --git a/packages/core/src/prompts/multi-select.ts b/packages/core/src/prompts/multi-select.ts index a19817a5..f5b04254 100644 --- a/packages/core/src/prompts/multi-select.ts +++ b/packages/core/src/prompts/multi-select.ts @@ -60,11 +60,11 @@ export default class MultiSelectPrompt extends Prompt(cursor, 1, this.options) : cursor; - this.on('key', (char) => { - if (char === 'a') { + this.on('key', (_char, key) => { + if (key.name === 'a') { this.toggleAll(); } - if (char === 'i') { + if (key.name === 'i') { this.toggleInvert(); } }); diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 4333da9d..c48a5181 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -226,7 +226,7 @@ export default class Prompt { } // Call the key event handler and emit the key event - this.emit('key', char?.toLowerCase(), key); + this.emit('key', char, key); if (key?.name === 'return' && this._shouldSubmit(char, key)) { if (this.opts.validate) { diff --git a/packages/core/src/prompts/select-key.ts b/packages/core/src/prompts/select-key.ts index 2e8e0b10..73510b05 100644 --- a/packages/core/src/prompts/select-key.ts +++ b/packages/core/src/prompts/select-key.ts @@ -19,17 +19,18 @@ export default class SelectKeyPrompt extends Prompt }); this.cursor = Math.max(keys.indexOf(opts.initialValue), 0); - this.on('key', (key, keyInfo) => { + this.on('key', (key) => { if (!key) { return; } - const casedKey = caseSensitive && keyInfo.shift ? key.toUpperCase() : key; + const casedKey = caseSensitive ? key : key.toLowerCase(); if (!keys.includes(casedKey)) { return; } const value = this.options.find(({ value: [initial] }) => { - return caseSensitive ? initial === casedKey : initial?.toLowerCase() === key; + const casedInitial = caseSensitive ? initial : initial?.toLowerCase(); + return casedInitial === casedKey; }); if (value) { this.value = value.value; diff --git a/packages/core/src/utils/settings.ts b/packages/core/src/utils/settings.ts index b8b3222d..4e64d633 100644 --- a/packages/core/src/utils/settings.ts +++ b/packages/core/src/utils/settings.ts @@ -1,4 +1,6 @@ -const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const; +const arrowKeyActions = ['up', 'down', 'left', 'right']; +const actions = [...arrowKeyActions, 'space', 'enter', 'cancel'] as const; +export type ArrowKeyAction = (typeof arrowKeyActions)[number]; export type Action = (typeof actions)[number]; const DEFAULT_MONTH_NAMES = [ @@ -18,6 +20,7 @@ const DEFAULT_MONTH_NAMES = [ /** Global settings for Clack programs, stored in memory */ interface InternalClackSettings { + arrowKeyActions: Set; actions: Set; aliases: Map; messages: { @@ -38,6 +41,7 @@ interface InternalClackSettings { } export const settings: InternalClackSettings = { + arrowKeyActions: new Set(arrowKeyActions), actions: new Set(actions), aliases: new Map([ // vim support diff --git a/packages/core/test/prompts/multi-line.test.ts b/packages/core/test/prompts/multi-line.test.ts index 428c97d1..2b92f804 100644 --- a/packages/core/test/prompts/multi-line.test.ts +++ b/packages/core/test/prompts/multi-line.test.ts @@ -195,7 +195,7 @@ describe('MultiLinePrompt', () => { }); instance.prompt(); input.emit('keypress', 'x', { name: 'x' }); - input.emit('keypress', 'x', { name: 'x', shift: true }); + input.emit('keypress', 'X', { name: 'x', shift: true }); expect(instance.userInput).to.equal('xX'); }); diff --git a/packages/prompts/test/select-key.test.ts b/packages/prompts/test/select-key.test.ts index 2e143a88..856afde8 100644 --- a/packages/prompts/test/select-key.test.ts +++ b/packages/prompts/test/select-key.test.ts @@ -114,7 +114,7 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { output, }); - input.emit('keypress', 'a', { name: 'a', shift: true }); + input.emit('keypress', 'A', { name: 'a', shift: true }); const value = await result; @@ -156,7 +156,7 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { output, }); - input.emit('keypress', 'a', { name: 'a', shift: true }); + input.emit('keypress', 'A', { name: 'a', shift: true }); const value = await result; From b491efc5e25c83eb19ef03e7bb27e629e90d749f Mon Sep 17 00:00:00 2001 From: MattStypa Date: Sat, 9 May 2026 09:35:01 -0500 Subject: [PATCH 3/4] Changeset cleanup --- .changeset/lucky-rivers-repair.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/lucky-rivers-repair.md diff --git a/.changeset/lucky-rivers-repair.md b/.changeset/lucky-rivers-repair.md deleted file mode 100644 index 6103a99e..00000000 --- a/.changeset/lucky-rivers-repair.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@clack/core": patch ---- - -Fixed spaces and uppercase characters in multiline prompt From 19255e09d3366bc0354b2f5ec74a3124657f3591 Mon Sep 17 00:00:00 2001 From: MattStypa Date: Tue, 12 May 2026 08:26:38 -0500 Subject: [PATCH 4/4] PR feedback --- packages/core/src/prompts/multi-line.ts | 10 ++++++---- packages/core/src/prompts/select-key.ts | 3 +-- packages/core/src/utils/settings.ts | 6 +----- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/core/src/prompts/multi-line.ts b/packages/core/src/prompts/multi-line.ts index 2714373a..dcec90af 100644 --- a/packages/core/src/prompts/multi-line.ts +++ b/packages/core/src/prompts/multi-line.ts @@ -1,9 +1,11 @@ import type { Key } from 'node:readline'; import { styleText } from 'node:util'; import { findTextCursor } from '../utils/cursor.js'; -import { type ArrowKeyAction, settings } from '../utils/index.js'; import Prompt, { type PromptOptions } from './prompt.js'; +type CursorAction = 'up' | 'down' | 'left' | 'right'; +const cursorActions = new Set(['up', 'down', 'left', 'right']); + export interface MultiLineOptions extends PromptOptions { placeholder?: string; defaultValue?: string; @@ -41,7 +43,7 @@ export default class MultiLinePrompt extends Prompt { this.userInput.slice(0, this.cursor) + char + this.userInput.slice(this.cursor) ); } - #handleCursor(key?: ArrowKeyAction) { + #handleCursor(key?: CursorAction) { const text = this.value ?? ''; switch (key) { case 'up': @@ -89,8 +91,8 @@ export default class MultiLinePrompt extends Prompt { this.#showSubmit = opts.showSubmit ?? false; this.on('key', (char, key) => { - if (key?.name && settings.arrowKeyActions.has(key.name as ArrowKeyAction)) { - this.#handleCursor(key.name as ArrowKeyAction); + if (key?.name && cursorActions.has(key.name as CursorAction)) { + this.#handleCursor(key.name as CursorAction); return; } if (char === '\t' && this.#showSubmit) { diff --git a/packages/core/src/prompts/select-key.ts b/packages/core/src/prompts/select-key.ts index 73510b05..04a49a52 100644 --- a/packages/core/src/prompts/select-key.ts +++ b/packages/core/src/prompts/select-key.ts @@ -29,8 +29,7 @@ export default class SelectKeyPrompt extends Prompt } const value = this.options.find(({ value: [initial] }) => { - const casedInitial = caseSensitive ? initial : initial?.toLowerCase(); - return casedInitial === casedKey; + return caseSensitive ? initial === casedKey : initial?.toLowerCase() === casedKey; }); if (value) { this.value = value.value; diff --git a/packages/core/src/utils/settings.ts b/packages/core/src/utils/settings.ts index 4e64d633..b8b3222d 100644 --- a/packages/core/src/utils/settings.ts +++ b/packages/core/src/utils/settings.ts @@ -1,6 +1,4 @@ -const arrowKeyActions = ['up', 'down', 'left', 'right']; -const actions = [...arrowKeyActions, 'space', 'enter', 'cancel'] as const; -export type ArrowKeyAction = (typeof arrowKeyActions)[number]; +const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const; export type Action = (typeof actions)[number]; const DEFAULT_MONTH_NAMES = [ @@ -20,7 +18,6 @@ const DEFAULT_MONTH_NAMES = [ /** Global settings for Clack programs, stored in memory */ interface InternalClackSettings { - arrowKeyActions: Set; actions: Set; aliases: Map; messages: { @@ -41,7 +38,6 @@ interface InternalClackSettings { } export const settings: InternalClackSettings = { - arrowKeyActions: new Set(arrowKeyActions), actions: new Set(actions), aliases: new Map([ // vim support