Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/modern-views-stay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clack/prompts": minor
"@clack/core": minor
---

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.
21 changes: 21 additions & 0 deletions packages/core/src/prompts/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export interface AutocompleteOptions<T extends OptionLike>
* the prompt's filter (so the value remains selectable).
*/
placeholder?: string;
/**
* When `true` (single-select only), pressing Tab fills the input with the
* currently focused option's value, as if the user had typed it.
*/
completeOnTab?: boolean;
}

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

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

this.#options = opts.options;
this.#placeholder = opts.placeholder;
this.#completeOnTab = opts.completeOnTab === true;
const options = this.options;
this.filteredOptions = [...options];
this.multiple = opts.multiple === true;
Expand Down Expand Up @@ -174,6 +181,20 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
return;
}

// Tab completion: fill input with the focused option's value
if (
key.name === 'tab' &&
this.#completeOnTab &&
!this.multiple &&
this.focusedValue !== undefined
) {
const completion = String(this.focusedValue); // capture before clearing resets focusedValue
this._clearUserInput();
this._setUserInput(completion, true);
this.isNavigating = false;
return;
}

// Start navigation mode with up/down arrows
if (isUpKey || isDownKey) {
this.#cursor = findCursor(this.#cursor, isUpKey ? -1 : 1, this.filteredOptions);
Expand Down
56 changes: 56 additions & 0 deletions packages/core/test/prompts/autocomplete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,4 +272,60 @@ describe('AutocompletePrompt', () => {
// Placeholder does not match any option, so input must not be filled with placeholder
expect(instance.userInput).not.to.equal('Type to search...');
});

test('completeOnTab fills input with focused option and submit returns it', async () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
completeOnTab: true,
});

const promise = instance.prompt();
input.emit('keypress', 'b', { name: 'b' });
input.emit('keypress', 'a', { name: 'a' });
input.emit('keypress', 'n', { name: 'n' });
input.emit('keypress', '\t', { name: 'tab' });
input.emit('keypress', '', { name: 'return' });
const result = await promise;

expect(instance.userInput).to.equal('banana');
expect(result).to.equal('banana');
});

test('completeOnTab with no matches leaves input unchanged', () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
completeOnTab: true,
});

instance.prompt();
input.emit('keypress', 'z', { name: 'z' });
input.emit('keypress', 'z', { name: 'z' });
input.emit('keypress', 'z', { name: 'z' });
input.emit('keypress', '\t', { name: 'tab' });

expect(instance.userInput).to.equal('zzz');
});

test('Tab does not complete when completeOnTab is not set', () => {
const instance = new AutocompletePrompt({
input,
output,
render: () => 'foo',
options: testOptions,
});

instance.prompt();
input.emit('keypress', 'b', { name: 'b' });
input.emit('keypress', 'a', { name: 'a' });
input.emit('keypress', 'n', { name: 'n' });
input.emit('keypress', '\t', { name: 'tab' });

expect(instance.userInput).to.equal('ban');
});
});
8 changes: 8 additions & 0 deletions packages/prompts/src/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ export interface AutocompleteOptions<Value> extends AutocompleteSharedOptions<Va
* The starting value shown in the users input box.
*/
initialUserInput?: string;

/**
* When `true`, pressing Tab fills the input with the currently focused
* option's value, as if the user had typed it.
*/
completeOnTab?: boolean;
}

/**
Expand Down Expand Up @@ -126,6 +132,7 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
initialUserInput: opts.initialUserInput,
placeholder: opts.placeholder,
completeOnTab: opts.completeOnTab,
filter:
opts.filter ??
((search: string, opt: Option<Value>) => {
Expand Down Expand Up @@ -223,6 +230,7 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
// Show instructions
const instructions = [
`${styleText('dim', '↑/↓')} to select`,
...(opts.completeOnTab ? [`${styleText('dim', 'Tab:')} complete`] : []),
`${styleText('dim', 'Enter:')} confirm`,
`${styleText('dim', 'Type:')} to search`,
];
Expand Down
1 change: 1 addition & 0 deletions packages/prompts/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const path = (opts: PathOptions) => {
...opts,
initialUserInput: opts.initialValue ?? opts.root ?? process.cwd(),
maxItems: 5,
completeOnTab: true,
validate(value) {
if (Array.isArray(value)) {
// Shouldn't ever happen since we don't enable `multiple: true`
Expand Down
Loading
Loading