[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: treat placeholders as visual element (#323)

authored by

James Garbutt and committed by
GitHub
(May 26, 2025, 8:10 PM +0100) 94fee2a9 4f6b3c26

+61 -73
+6
.changeset/mean-turkeys-help.md
··· 1 + --- 2 + "@clack/prompts": patch 3 + "@clack/core": patch 4 + --- 5 + 6 + Changes `placeholder` to be a visual hint rather than a tabbable value.
-1
packages/core/src/prompts/autocomplete.ts
··· 87 87 this.options = opts.options; 88 88 this.filteredOptions = [...this.options]; 89 89 this.multiple = opts.multiple === true; 90 - this._usePlaceholderAsValue = false; 91 90 this.#filterFn = opts.filter ?? defaultFilter; 92 91 let initialValues: unknown[] | undefined; 93 92 if (opts.initialValue && Array.isArray(opts.initialValue)) {
-17
packages/core/src/prompts/prompt.ts
··· 12 12 13 13 export interface PromptOptions<Self extends Prompt> { 14 14 render(this: Omit<Self, 'prompt'>): string | undefined; 15 - placeholder?: string; 16 15 initialValue?: any; 17 - defaultValue?: any; 18 16 validate?: ((value: any) => string | Error | undefined) | undefined; 19 17 input?: Readable; 20 18 output?: Writable; ··· 34 32 private _prevFrame = ''; 35 33 private _subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>(); 36 34 protected _cursor = 0; 37 - protected _usePlaceholderAsValue = true; 38 35 39 36 public state: ClackState = 'initial'; 40 37 public error = ''; ··· 212 209 if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) { 213 210 this.emit('confirm', char.toLowerCase() === 'y'); 214 211 } 215 - if (this._usePlaceholderAsValue && char === '\t' && this.opts.placeholder) { 216 - if (!this.value) { 217 - this.rl?.write(this.opts.placeholder); 218 - this._setValue(this.opts.placeholder); 219 - } 220 - } 221 212 222 213 // Call the key event handler and emit the key event 223 214 this.emit('key', char?.toLowerCase(), key); 224 215 225 216 if (key?.name === 'return') { 226 - if (!this.value) { 227 - if (this.opts.defaultValue) { 228 - this._setValue(this.opts.defaultValue); 229 - } else { 230 - this._setValue(''); 231 - } 232 - } 233 - 234 217 if (this.opts.validate) { 235 218 const problem = this.opts.validate(this.value); 236 219 if (problem) {
+1 -32
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(''); 41 + expect(result).to.equal(undefined); 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]); ··· 134 134 input.emit('keypress', 'n', { name: 'n' }); 135 135 136 136 expect(eventFn).toBeCalledWith(false); 137 - }); 138 - 139 - test('sets value as placeholder on tab if one is set', () => { 140 - const instance = new Prompt({ 141 - input, 142 - output, 143 - render: () => 'foo', 144 - placeholder: 'piwa', 145 - }); 146 - 147 - instance.prompt(); 148 - 149 - input.emit('keypress', '\t', { name: 'tab' }); 150 - 151 - expect(instance.value).to.equal('piwa'); 152 - }); 153 - 154 - test('does not set placeholder value on tab if value already set', () => { 155 - const instance = new Prompt({ 156 - input, 157 - output, 158 - render: () => 'foo', 159 - placeholder: 'piwa', 160 - initialValue: 'trzy', 161 - }); 162 - 163 - instance.prompt(); 164 - 165 - input.emit('keypress', '\t', { name: 'tab' }); 166 - 167 - expect(instance.value).to.equal('trzy'); 168 137 }); 169 138 170 139 test('emits key event for unknown chars', () => {
+12 -6
packages/prompts/src/autocomplete.ts
··· 70 70 export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => { 71 71 const prompt = new AutocompletePrompt({ 72 72 options: opts.options, 73 - placeholder: opts.placeholder, 74 73 initialValue: opts.initialValue ? [opts.initialValue] : undefined, 75 74 filter: (search: string, opt: Option<Value>) => { 76 75 return getFilteredOption(search, opt); ··· 81 80 // Title and message display 82 81 const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; 83 82 const valueAsString = String(this.value ?? ''); 83 + const placeholder = opts.placeholder; 84 + const showPlaceholder = valueAsString === '' && placeholder !== undefined; 84 85 85 86 // Handle different states 86 87 switch (this.state) { ··· 97 98 98 99 default: { 99 100 // Display cursor position - show plain text in navigation mode 100 - const searchText = this.isNavigating ? color.dim(valueAsString) : this.valueWithCursor; 101 + const searchText = 102 + this.isNavigating || showPlaceholder 103 + ? color.dim(showPlaceholder ? placeholder : valueAsString) 104 + : this.valueWithCursor; 101 105 102 106 // Show match count if filtered 103 107 const matches = ··· 209 213 } 210 214 return undefined; 211 215 }, 212 - placeholder: opts.placeholder, 213 216 initialValue: opts.initialValues, 214 217 input: opts.input, 215 218 output: opts.output, ··· 219 222 220 223 // Selection counter 221 224 const value = String(this.value ?? ''); 225 + const placeholder = opts.placeholder; 226 + const showPlaceholder = value === '' && placeholder !== undefined; 222 227 223 228 // Search input display 224 - const searchText = this.isNavigating 225 - ? color.dim(value) // Just show plain text when in navigation mode 226 - : this.valueWithCursor; 229 + const searchText = 230 + this.isNavigating || showPlaceholder 231 + ? color.dim(showPlaceholder ? placeholder : value) // Just show plain text when in navigation mode 232 + : this.valueWithCursor; 227 233 228 234 const matches = 229 235 this.filteredOptions.length !== opts.options.length
+1 -1
packages/prompts/src/text.ts
··· 31 31 S_BAR_END 32 32 )} ${color.yellow(this.error)}\n`; 33 33 case 'submit': { 34 - const displayValue = typeof this.value === 'undefined' ? '' : this.value; 34 + const displayValue = this.value === undefined ? '' : this.value; 35 35 return `${title}${color.gray(S_BAR)} ${color.dim(displayValue)}`; 36 36 } 37 37 case 'cancel':
+25
packages/prompts/test/__snapshots__/autocomplete.test.ts.snap
··· 51 51 ] 52 52 `; 53 53 54 + exports[`autocomplete > renders placeholder if set 1`] = ` 55 + [ 56 + "<cursor.hide>", 57 + "│ 58 + ◆ Select a fruit 59 + 60 + │ Search: Type to search... 61 + │ ● Apple 62 + │ ○ Banana 63 + │ ○ Cherry 64 + │ ○ Grape 65 + │ ○ Orange 66 + │ ↑/↓ to select • Enter: confirm • Type: to search 67 + └", 68 + "<cursor.backward count=999><cursor.up count=10>", 69 + "<cursor.down count=1>", 70 + "<erase.down>", 71 + "◇ Select a fruit 72 + │ Apple", 73 + " 74 + ", 75 + "<cursor.show>", 76 + ] 77 + `; 78 + 54 79 exports[`autocomplete > shows hint when option has hint and is focused 1`] = ` 55 80 [ 56 81 "<cursor.hide>",
+16
packages/prompts/test/autocomplete.test.ts
··· 121 121 expect(output.buffer).toMatchSnapshot(); 122 122 }); 123 123 124 + test('renders placeholder if set', async () => { 125 + const result = autocomplete({ 126 + message: 'Select a fruit', 127 + placeholder: 'Type to search...', 128 + options: testOptions, 129 + input, 130 + output, 131 + }); 132 + 133 + input.emit('keypress', '', { name: 'return' }); 134 + const value = await result; 135 + expect(output.buffer).toMatchSnapshot(); 136 + expect(value).toBe('apple'); 137 + }); 138 + 124 139 test('supports initialValue', async () => { 125 140 const result = autocomplete({ 126 141 message: 'Select a fruit', ··· 132 147 133 148 input.emit('keypress', '', { name: 'return' }); 134 149 const value = await result; 150 + 135 151 expect(value).toBe('cherry'); 136 152 expect(output.buffer).toMatchSnapshot(); 137 153 });
-16
packages/prompts/test/text.test.ts
··· 55 55 expect(value).toBe(''); 56 56 }); 57 57 58 - test('<tab> applies placeholder', async () => { 59 - const result = prompts.text({ 60 - message: 'foo', 61 - placeholder: 'bar', 62 - input, 63 - output, 64 - }); 65 - 66 - input.emit('keypress', '\t', { name: 'tab' }); 67 - input.emit('keypress', '', { name: 'return' }); 68 - 69 - const value = await result; 70 - 71 - expect(value).toBe('bar'); 72 - }); 73 - 74 58 test('can cancel', async () => { 75 59 const result = prompts.text({ 76 60 message: 'foo',