[READ-ONLY] Mirror of https://github.com/bombshell-dev/clack. Effortlessly build beautiful command-line apps bomb.sh/docs/clack/basics/getting-started/
cli command-line command-line-app node prompt prompts
5

Configure Feed

Select the types of activity you want to include in your feed.

fix: prevent placeholder from being used as input value in text prompts

Paul Valladares (May 17, 2025, 4:49 PM -0600) bfe0dd3b 28378453

+290 -76
+6
.changeset/bright-hornets-destroy.md
··· 1 + --- 2 + "@clack/prompts": patch 3 + "@clack/core": patch 4 + --- 5 + 6 + Prevents placeholder from being used as input value in text prompts
+32
examples/basic/default-value.ts
··· 1 + import * as p from '@clack/prompts'; 2 + import color from 'picocolors'; 3 + 4 + async function main() { 5 + const defaultPath = 'my-project'; 6 + 7 + const result = await p.text( 8 + { 9 + message: 'Enter the directory to bootstrap the project', 10 + placeholder: ` (hit Enter to use '${defaultPath}')`, 11 + defaultValue: defaultPath, 12 + validate: (value) => { 13 + if (!value) { 14 + return 'Directory is required'; 15 + } 16 + if (value.includes(' ')) { 17 + return 'Directory cannot contain spaces'; 18 + } 19 + return undefined; 20 + }, 21 + } 22 + ); 23 + 24 + if (p.isCancel(result)) { 25 + p.cancel('Operation cancelled.'); 26 + process.exit(0); 27 + } 28 + 29 + p.outro(`Let's bootstrap the project in ${color.cyan(result)}`); 30 + } 31 + 32 + main().catch(console.error);
+7 -3
packages/core/src/prompts/prompt.ts
··· 14 14 render(this: Omit<Self, 'prompt'>): string | undefined; 15 15 placeholder?: string; 16 16 initialValue?: any; 17 + defaultValue?: any; 17 18 validate?: ((value: any) => string | Error | undefined) | undefined; 18 19 input?: Readable; 19 20 output?: Writable; ··· 222 223 this.emit('key', char?.toLowerCase(), key); 223 224 224 225 if (key?.name === 'return') { 225 - if (!this.value && this.opts.placeholder) { 226 - this.rl?.write(this.opts.placeholder); 227 - this._setValue(this.opts.placeholder); 226 + if (!this.value) { 227 + if (this.opts.defaultValue) { 228 + this._setValue(this.opts.defaultValue); 229 + } else { 230 + this._setValue(''); 231 + } 228 232 } 229 233 230 234 if (this.opts.validate) {
+3
packages/core/src/prompts/text.ts
··· 29 29 if (!this.value) { 30 30 this.value = opts.defaultValue; 31 31 } 32 + if (this.value === undefined) { 33 + this.value = ''; 34 + } 32 35 }); 33 36 } 34 37 }
+1 -1
packages/core/test/prompts/prompt.test.ts
··· 38 38 const resultPromise = instance.prompt(); 39 39 input.emit('keypress', '', { name: 'return' }); 40 40 const result = await resultPromise; 41 - expect(result).to.equal(undefined); 41 + expect(result).to.equal(''); 42 42 expect(isCancel(result)).to.equal(false); 43 43 expect(instance.state).to.equal('submit'); 44 44 expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]);
+27
packages/core/test/prompts/text.test.ts
··· 110 110 input.emit('keypress', 'right', { name: 'right' }); 111 111 expect(instance.valueWithCursor).to.equal('foo█'); 112 112 }); 113 + 114 + test('does not use placeholder as value when pressing enter', async () => { 115 + const instance = new TextPrompt({ 116 + input, 117 + output, 118 + render: () => 'foo', 119 + placeholder: ' (hit Enter to use default)', 120 + defaultValue: 'default-value' 121 + }); 122 + const resultPromise = instance.prompt(); 123 + input.emit('keypress', '', { name: 'return' }); 124 + const result = await resultPromise; 125 + expect(result).to.equal('default-value'); 126 + }); 127 + 128 + test('returns empty string when no value and no default', async () => { 129 + const instance = new TextPrompt({ 130 + input, 131 + output, 132 + render: () => 'foo', 133 + placeholder: ' (hit Enter to use default)' 134 + }); 135 + const resultPromise = instance.prompt(); 136 + input.emit('keypress', '', { name: 'return' }); 137 + const result = await resultPromise; 138 + expect(result).to.equal(''); 139 + }); 113 140 }); 114 141 });
+4 -2
packages/prompts/src/text.ts
··· 30 30 return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow( 31 31 S_BAR_END 32 32 )} ${color.yellow(this.error)}\n`; 33 - case 'submit': 34 - return `${title}${color.gray(S_BAR)} ${color.dim(this.value || opts.placeholder)}`; 33 + case 'submit': { 34 + const displayValue = typeof this.value === 'undefined' ? '' : this.value; 35 + return `${title}${color.gray(S_BAR)} ${color.dim(displayValue)}`; 36 + } 35 37 case 'cancel': 36 38 return `${title}${color.gray(S_BAR)} ${color.strikethrough( 37 39 color.dim(this.value ?? '')
+48 -32
packages/prompts/test/__snapshots__/path.test.ts.snap
··· 143 143 144 144 exports[`text (isCI = false) > validation errors render and clear (using Error) 1`] = ` 145 145 [ 146 - "<cursor.hide>", 146 + "<cursor.backward count=999>", 147 + "", 148 + "<erase.down>", 147 149 "│ 148 - ◆ foo 149 - │ /tmp/foo 150 - └ 150 + ▲ foo 151 + │ /tmp/foo 152 + └ should be /tmp/bar 151 153 ", 152 154 "<cursor.backward count=999><cursor.up count=4>", 153 - "<cursor.down count=2>", 154 - "<erase.line><cursor.left count=1>", 155 - "│ /tmp/b ", 156 - "<cursor.down count=2>", 155 + "<cursor.down count=1>", 156 + "<erase.down>", 157 + "◆ foo 158 + │ /tmp/b  159 + └ 160 + ", 157 161 "<cursor.backward count=999><cursor.up count=4>", 158 162 "<cursor.down count=1>", 159 163 "<erase.down>", ··· 186 190 187 191 exports[`text (isCI = false) > validation errors render and clear 1`] = ` 188 192 [ 189 - "<cursor.hide>", 193 + "<cursor.backward count=999>", 194 + "", 195 + "<erase.down>", 190 196 "│ 191 - ◆ foo 192 - │ /tmp/foo 193 - └ 197 + ▲ foo 198 + │ /tmp/foo 199 + └ should be /tmp/bar 194 200 ", 195 201 "<cursor.backward count=999><cursor.up count=4>", 196 - "<cursor.down count=2>", 197 - "<erase.line><cursor.left count=1>", 198 - "│ /tmp/b ", 199 - "<cursor.down count=2>", 202 + "<cursor.down count=1>", 203 + "<erase.down>", 204 + "◆ foo 205 + │ /tmp/b  206 + └ 207 + ", 200 208 "<cursor.backward count=999><cursor.up count=4>", 201 209 "<cursor.down count=1>", 202 210 "<erase.down>", ··· 370 378 371 379 exports[`text (isCI = true) > validation errors render and clear (using Error) 1`] = ` 372 380 [ 373 - "<cursor.hide>", 381 + "<cursor.backward count=999>", 382 + "", 383 + "<erase.down>", 374 384 "│ 375 - ◆ foo 376 - │ /tmp/foo 377 - └ 385 + ▲ foo 386 + │ /tmp/foo 387 + └ should be /tmp/bar 378 388 ", 379 389 "<cursor.backward count=999><cursor.up count=4>", 380 - "<cursor.down count=2>", 381 - "<erase.line><cursor.left count=1>", 382 - "│ /tmp/b ", 383 - "<cursor.down count=2>", 390 + "<cursor.down count=1>", 391 + "<erase.down>", 392 + "◆ foo 393 + │ /tmp/b  394 + └ 395 + ", 384 396 "<cursor.backward count=999><cursor.up count=4>", 385 397 "<cursor.down count=1>", 386 398 "<erase.down>", ··· 413 425 414 426 exports[`text (isCI = true) > validation errors render and clear 1`] = ` 415 427 [ 416 - "<cursor.hide>", 428 + "<cursor.backward count=999>", 429 + "", 430 + "<erase.down>", 417 431 "│ 418 - ◆ foo 419 - │ /tmp/foo 420 - └ 432 + ▲ foo 433 + │ /tmp/foo 434 + └ should be /tmp/bar 421 435 ", 422 436 "<cursor.backward count=999><cursor.up count=4>", 423 - "<cursor.down count=2>", 424 - "<erase.line><cursor.left count=1>", 425 - "│ /tmp/b ", 426 - "<cursor.down count=2>", 437 + "<cursor.down count=1>", 438 + "<erase.down>", 439 + "◆ foo 440 + │ /tmp/b  441 + └ 442 + ", 427 443 "<cursor.backward count=999><cursor.up count=4>", 428 444 "<cursor.down count=1>", 429 445 "<erase.down>",
+48 -32
packages/prompts/test/__snapshots__/suggestion.test.ts.snap
··· 142 142 143 143 exports[`text (isCI = false) > validation errors render and clear (using Error) 1`] = ` 144 144 [ 145 - "<cursor.hide>", 145 + "<cursor.backward count=999>", 146 + "", 147 + "<erase.down>", 146 148 "│ 147 - ◆ foo 148 - │ xyz 149 - └ 149 + ▲ foo 150 + │ xyz 151 + └ should be xy 150 152 ", 151 153 "<cursor.backward count=999><cursor.up count=4>", 152 - "<cursor.down count=2>", 153 - "<erase.line><cursor.left count=1>", 154 - "│ xyz", 155 - "<cursor.down count=2>", 154 + "<cursor.down count=1>", 155 + "<erase.down>", 156 + "◆ foo 157 + │ xyz 158 + └ 159 + ", 156 160 "<cursor.backward count=999><cursor.up count=4>", 157 161 "<cursor.down count=1>", 158 162 "<erase.down>", ··· 180 184 181 185 exports[`text (isCI = false) > validation errors render and clear 1`] = ` 182 186 [ 183 - "<cursor.hide>", 187 + "<cursor.backward count=999>", 188 + "", 189 + "<erase.down>", 184 190 "│ 185 - ◆ foo 186 - │ xyz 187 - └ 191 + ▲ foo 192 + │ xyz 193 + └ should be xy 188 194 ", 189 195 "<cursor.backward count=999><cursor.up count=4>", 190 - "<cursor.down count=2>", 191 - "<erase.line><cursor.left count=1>", 192 - "│ xyz", 193 - "<cursor.down count=2>", 196 + "<cursor.down count=1>", 197 + "<erase.down>", 198 + "◆ foo 199 + │ xyz 200 + └ 201 + ", 194 202 "<cursor.backward count=999><cursor.up count=4>", 195 203 "<cursor.down count=1>", 196 204 "<erase.down>", ··· 358 366 359 367 exports[`text (isCI = true) > validation errors render and clear (using Error) 1`] = ` 360 368 [ 361 - "<cursor.hide>", 369 + "<cursor.backward count=999>", 370 + "", 371 + "<erase.down>", 362 372 "│ 363 - ◆ foo 364 - │ xyz 365 - └ 373 + ▲ foo 374 + │ xyz 375 + └ should be xy 366 376 ", 367 377 "<cursor.backward count=999><cursor.up count=4>", 368 - "<cursor.down count=2>", 369 - "<erase.line><cursor.left count=1>", 370 - "│ xyz", 371 - "<cursor.down count=2>", 378 + "<cursor.down count=1>", 379 + "<erase.down>", 380 + "◆ foo 381 + │ xyz 382 + └ 383 + ", 372 384 "<cursor.backward count=999><cursor.up count=4>", 373 385 "<cursor.down count=1>", 374 386 "<erase.down>", ··· 396 408 397 409 exports[`text (isCI = true) > validation errors render and clear 1`] = ` 398 410 [ 399 - "<cursor.hide>", 411 + "<cursor.backward count=999>", 412 + "", 413 + "<erase.down>", 400 414 "│ 401 - ◆ foo 402 - │ xyz 403 - └ 415 + ▲ foo 416 + │ xyz 417 + └ should be xy 404 418 ", 405 419 "<cursor.backward count=999><cursor.up count=4>", 406 - "<cursor.down count=2>", 407 - "<erase.line><cursor.left count=1>", 408 - "│ xyz", 409 - "<cursor.down count=2>", 420 + "<cursor.down count=1>", 421 + "<erase.down>", 422 + "◆ foo 423 + │ xyz 424 + └ 425 + ", 410 426 "<cursor.backward count=999><cursor.up count=4>", 411 427 "<cursor.down count=1>", 412 428 "<erase.down>",
+80 -4
packages/prompts/test/__snapshots__/text.test.ts.snap
··· 38 38 ] 39 39 `; 40 40 41 + exports[`text (isCI = false) > empty string when no value and no default 1`] = ` 42 + [ 43 + "<cursor.hide>", 44 + "│ 45 + ◆ foo 46 + │   (hit Enter to use default) 47 + └ 48 + ", 49 + "<cursor.backward count=999><cursor.up count=4>", 50 + "<cursor.down count=1>", 51 + "<erase.down>", 52 + "◇ foo 53 + │", 54 + " 55 + ", 56 + "<cursor.show>", 57 + ] 58 + `; 59 + 60 + exports[`text (isCI = false) > placeholder is not used as value when pressing enter 1`] = ` 61 + [ 62 + "<cursor.hide>", 63 + "│ 64 + ◆ foo 65 + │   (hit Enter to use default) 66 + └ 67 + ", 68 + "<cursor.backward count=999><cursor.up count=4>", 69 + "<cursor.down count=1>", 70 + "<erase.down>", 71 + "◇ foo 72 + │ default-value", 73 + " 74 + ", 75 + "<cursor.show>", 76 + ] 77 + `; 78 + 41 79 exports[`text (isCI = false) > renders cancelled value if one set 1`] = ` 42 80 [ 43 81 "<cursor.hide>", ··· 80 118 "<cursor.down count=1>", 81 119 "<erase.down>", 82 120 "◇ foo 83 - │ undefined", 121 + │", 84 122 " 85 123 ", 86 124 "<cursor.show>", ··· 99 137 "<cursor.down count=1>", 100 138 "<erase.down>", 101 139 "◇ foo 102 - │ bar", 140 + │", 103 141 " 104 142 ", 105 143 "<cursor.show>", ··· 249 287 ] 250 288 `; 251 289 290 + exports[`text (isCI = true) > empty string when no value and no default 1`] = ` 291 + [ 292 + "<cursor.hide>", 293 + "│ 294 + ◆ foo 295 + │   (hit Enter to use default) 296 + └ 297 + ", 298 + "<cursor.backward count=999><cursor.up count=4>", 299 + "<cursor.down count=1>", 300 + "<erase.down>", 301 + "◇ foo 302 + │", 303 + " 304 + ", 305 + "<cursor.show>", 306 + ] 307 + `; 308 + 309 + exports[`text (isCI = true) > placeholder is not used as value when pressing enter 1`] = ` 310 + [ 311 + "<cursor.hide>", 312 + "│ 313 + ◆ foo 314 + │   (hit Enter to use default) 315 + └ 316 + ", 317 + "<cursor.backward count=999><cursor.up count=4>", 318 + "<cursor.down count=1>", 319 + "<erase.down>", 320 + "◇ foo 321 + │ default-value", 322 + " 323 + ", 324 + "<cursor.show>", 325 + ] 326 + `; 327 + 252 328 exports[`text (isCI = true) > renders cancelled value if one set 1`] = ` 253 329 [ 254 330 "<cursor.hide>", ··· 291 367 "<cursor.down count=1>", 292 368 "<erase.down>", 293 369 "◇ foo 294 - │ undefined", 370 + │", 295 371 " 296 372 ", 297 373 "<cursor.show>", ··· 310 386 "<cursor.down count=1>", 311 387 "<erase.down>", 312 388 "◇ foo 313 - │ bar", 389 + │", 314 390 " 315 391 ", 316 392 "<cursor.show>",
+34 -2
packages/prompts/test/text.test.ts
··· 52 52 const value = await result; 53 53 54 54 expect(output.buffer).toMatchSnapshot(); 55 - 56 - expect(value).toBe('bar'); 55 + expect(value).toBe(''); 57 56 }); 58 57 59 58 test('<tab> applies placeholder', async () => { ··· 172 171 const value = await result; 173 172 174 173 expect(value).toBe('xy'); 174 + expect(output.buffer).toMatchSnapshot(); 175 + }); 176 + 177 + test('placeholder is not used as value when pressing enter', async () => { 178 + const result = prompts.text({ 179 + message: 'foo', 180 + placeholder: ' (hit Enter to use default)', 181 + defaultValue: 'default-value', 182 + input, 183 + output, 184 + }); 185 + 186 + input.emit('keypress', '', { name: 'return' }); 187 + 188 + const value = await result; 189 + 190 + expect(value).toBe('default-value'); 191 + expect(output.buffer).toMatchSnapshot(); 192 + }); 193 + 194 + test('empty string when no value and no default', async () => { 195 + const result = prompts.text({ 196 + message: 'foo', 197 + placeholder: ' (hit Enter to use default)', 198 + input, 199 + output, 200 + }); 201 + 202 + input.emit('keypress', '', { name: 'return' }); 203 + 204 + const value = await result; 205 + 206 + expect(value).toBe(''); 175 207 expect(output.buffer).toMatchSnapshot(); 176 208 }); 177 209 });