Skip to content

Commit 37fca4e

Browse files
authored
feat(prompts): add tab-completion to path prompt (#594)
1 parent 227583e commit 37fca4e

7 files changed

Lines changed: 355 additions & 44 deletions

File tree

‎.changeset/modern-views-stay.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clack/prompts": minor
3+
"@clack/core": minor
4+
---
5+
6+
Add tab-completion to the `path` prompt: pressing Tab fills the input with the focused suggestion, so you can quickly descend into deep directories (type `/` and Tab again). Powered by a new opt-in `completeOnTab` option on `autocomplete`, which also shows a `Tab: complete` hint in the instructions footer. Default `autocomplete` behavior is unchanged.

‎packages/core/src/prompts/autocomplete.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ export interface AutocompleteOptions<T extends OptionLike>
5858
* the prompt's filter (so the value remains selectable).
5959
*/
6060
placeholder?: string;
61+
/**
62+
* When `true` (single-select only), pressing Tab fills the input with the
63+
* currently focused option's value, as if the user had typed it.
64+
*/
65+
completeOnTab?: boolean;
6166
}
6267

6368
export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
@@ -74,6 +79,7 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
7479
#filterFn: FilterFunction<T> | undefined;
7580
#options: T[] | (() => T[]);
7681
#placeholder: string | undefined;
82+
#completeOnTab: boolean;
7783

7884
get cursor(): number {
7985
return this.#cursor;
@@ -104,6 +110,7 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
104110

105111
this.#options = opts.options;
106112
this.#placeholder = opts.placeholder;
113+
this.#completeOnTab = opts.completeOnTab === true;
107114
const options = this.options;
108115
this.filteredOptions = [...options];
109116
this.multiple = opts.multiple === true;
@@ -174,6 +181,20 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
174181
return;
175182
}
176183

184+
// Tab completion: fill input with the focused option's value
185+
if (
186+
key.name === 'tab' &&
187+
this.#completeOnTab &&
188+
!this.multiple &&
189+
this.focusedValue !== undefined
190+
) {
191+
const completion = String(this.focusedValue); // capture before clearing resets focusedValue
192+
this._clearUserInput();
193+
this._setUserInput(completion, true);
194+
this.isNavigating = false;
195+
return;
196+
}
197+
177198
// Start navigation mode with up/down arrows
178199
if (isUpKey || isDownKey) {
179200
this.#cursor = findCursor(this.#cursor, isUpKey ? -1 : 1, this.filteredOptions);

‎packages/core/test/prompts/autocomplete.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,4 +272,60 @@ describe('AutocompletePrompt', () => {
272272
// Placeholder does not match any option, so input must not be filled with placeholder
273273
expect(instance.userInput).not.to.equal('Type to search...');
274274
});
275+
276+
test('completeOnTab fills input with focused option and submit returns it', async () => {
277+
const instance = new AutocompletePrompt({
278+
input,
279+
output,
280+
render: () => 'foo',
281+
options: testOptions,
282+
completeOnTab: true,
283+
});
284+
285+
const promise = instance.prompt();
286+
input.emit('keypress', 'b', { name: 'b' });
287+
input.emit('keypress', 'a', { name: 'a' });
288+
input.emit('keypress', 'n', { name: 'n' });
289+
input.emit('keypress', '\t', { name: 'tab' });
290+
input.emit('keypress', '', { name: 'return' });
291+
const result = await promise;
292+
293+
expect(instance.userInput).to.equal('banana');
294+
expect(result).to.equal('banana');
295+
});
296+
297+
test('completeOnTab with no matches leaves input unchanged', () => {
298+
const instance = new AutocompletePrompt({
299+
input,
300+
output,
301+
render: () => 'foo',
302+
options: testOptions,
303+
completeOnTab: true,
304+
});
305+
306+
instance.prompt();
307+
input.emit('keypress', 'z', { name: 'z' });
308+
input.emit('keypress', 'z', { name: 'z' });
309+
input.emit('keypress', 'z', { name: 'z' });
310+
input.emit('keypress', '\t', { name: 'tab' });
311+
312+
expect(instance.userInput).to.equal('zzz');
313+
});
314+
315+
test('Tab does not complete when completeOnTab is not set', () => {
316+
const instance = new AutocompletePrompt({
317+
input,
318+
output,
319+
render: () => 'foo',
320+
options: testOptions,
321+
});
322+
323+
instance.prompt();
324+
input.emit('keypress', 'b', { name: 'b' });
325+
input.emit('keypress', 'a', { name: 'a' });
326+
input.emit('keypress', 'n', { name: 'n' });
327+
input.emit('keypress', '\t', { name: 'tab' });
328+
329+
expect(instance.userInput).to.equal('ban');
330+
});
275331
});

‎packages/prompts/src/autocomplete.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ export interface AutocompleteOptions<Value> extends AutocompleteSharedOptions<Va
9393
* The starting value shown in the users input box.
9494
*/
9595
initialUserInput?: string;
96+
97+
/**
98+
* When `true`, pressing Tab fills the input with the currently focused
99+
* option's value, as if the user had typed it.
100+
*/
101+
completeOnTab?: boolean;
96102
}
97103

98104
/**
@@ -126,6 +132,7 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
126132
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
127133
initialUserInput: opts.initialUserInput,
128134
placeholder: opts.placeholder,
135+
completeOnTab: opts.completeOnTab,
129136
filter:
130137
opts.filter ??
131138
((search: string, opt: Option<Value>) => {
@@ -223,6 +230,7 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
223230
// Show instructions
224231
const instructions = [
225232
`${styleText('dim', '↑/↓')} to select`,
233+
...(opts.completeOnTab ? [`${styleText('dim', 'Tab:')} complete`] : []),
226234
`${styleText('dim', 'Enter:')} confirm`,
227235
`${styleText('dim', 'Type:')} to search`,
228236
];

‎packages/prompts/src/path.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const path = (opts: PathOptions) => {
6565
...opts,
6666
initialUserInput: opts.initialValue ?? opts.root ?? process.cwd(),
6767
maxItems: 5,
68+
completeOnTab: true,
6869
validate(value) {
6970
if (Array.isArray(value)) {
7071
// Shouldn't ever happen since we don't enable `multiple: true`

0 commit comments

Comments
 (0)