Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions .changeset/calm-feet-lead.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clack/prompts": minor
---

Add an opt-in `instructions` option to `select`, `multiselect`, and `groupMultiselect`. When enabled, the active prompt shows a keyboard hints footer matching the autocomplete style. Defaults to `false`, so existing prompts are unchanged.
3 changes: 3 additions & 0 deletions examples/changesets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ async function main() {
packages: () =>
p.groupMultiselect({
message: 'Which packages would you like to include?',
instructions: true,
options: {
'changed packages': [
{ value: '@scope/a' },
Expand All @@ -36,6 +37,7 @@ async function main() {
const packages = results.packages ?? [];
return p.multiselect({
message: `Which packages should have a ${color.red('major')} bump?`,
instructions: true,
options: packages.map((value) => ({ value })),
required: false,
});
Expand All @@ -47,6 +49,7 @@ async function main() {
if (possiblePackages.length === 0) return;
return p.multiselect({
message: `Which packages should have a ${color.yellow('minor')} bump?`,
instructions: true,
options: possiblePackages.map((value) => ({ value })),
required: false,
});
Expand Down
9 changes: 9 additions & 0 deletions packages/prompts/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,12 @@ export interface CommonOptions {
signal?: AbortSignal;
withGuide?: boolean;
}

export function formatInstructionFooter(instructions: string[], hasGuide: boolean): string[] {
const guidePrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : '';
const footerLines = [`${guidePrefix}${instructions.join(' • ')}`];
if (hasGuide) {
footerLines.push(styleText('cyan', S_BAR_END));
}
return footerLines;
}
22 changes: 17 additions & 5 deletions packages/prompts/src/group-multi-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { styleText } from 'node:util';
import { GroupMultiSelectPrompt, settings, wrapTextWithPrefix } from '@clack/core';
import {
type CommonOptions,
formatInstructionFooter,
S_BAR,
S_BAR_END,
S_CHECKBOX_ACTIVE,
Expand All @@ -10,6 +11,7 @@ import {
symbol,
} from './common.js';
import { limitOptions } from './limit-options.js';
import { MULTISELECT_INSTRUCTIONS } from './multi-select.js';
import type { Option } from './select.js';

/**
Expand Down Expand Up @@ -58,6 +60,12 @@ export interface GroupMultiSelectOptions<Value> extends CommonOptions {
* @default 0
*/
groupSpacing?: number;

/**
* Show keyboard instructions below the option list.
* @default false
*/
instructions?: boolean;
}

/**
Expand Down Expand Up @@ -189,6 +197,7 @@ export const groupMultiselect = <Value>(opts: GroupMultiSelectOptions<Value>) =>
);
};
const required = opts.required ?? true;
const showInstructions = opts.instructions ?? false;

return new GroupMultiSelectPrompt({
options: opts.options,
Expand Down Expand Up @@ -285,9 +294,14 @@ export const groupMultiselect = <Value>(opts: GroupMultiSelectOptions<Value>) =>
}
default: {
const guidePrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : '';
// Calculate rowPadding: title lines + footer lines (S_BAR_END + trailing newline)
const titleLineCount = title.split('\n').length;
const footerLineCount = (hasGuide ? 1 : 0) + 1; // guide line + trailing newline
const footerLines = showInstructions
? formatInstructionFooter(MULTISELECT_INSTRUCTIONS, hasGuide)
: hasGuide
? [styleText('cyan', S_BAR_END)]
: [];
const footerText = footerLines.join('\n');
const footerLineCount = footerLines.length + 1;
const optionsText = limitOptions({
output: opts.output,
options: this.options,
Expand All @@ -297,9 +311,7 @@ export const groupMultiselect = <Value>(opts: GroupMultiSelectOptions<Value>) =>
rowPadding: titleLineCount + footerLineCount,
style: styleOption,
}).join(`\n${guidePrefix}`);
return `${title}${guidePrefix}${optionsText}\n${
hasGuide ? styleText('cyan', S_BAR_END) : ''
}\n`;
return `${title}${guidePrefix}${optionsText}\n${footerText}\n`;
}
}
},
Expand Down
24 changes: 21 additions & 3 deletions packages/prompts/src/multi-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { styleText } from 'node:util';
import { MultiSelectPrompt, settings, wrapTextWithPrefix } from '@clack/core';
import {
type CommonOptions,
formatInstructionFooter,
S_BAR,
S_BAR_END,
S_CHECKBOX_ACTIVE,
Expand All @@ -13,13 +14,24 @@ import {
import { limitOptions } from './limit-options.js';
import type { Option } from './select.js';

export const MULTISELECT_INSTRUCTIONS = [
`${styleText('dim', '↑/↓')} to navigate`,
`${styleText('dim', 'Space:')} select`,
`${styleText('dim', 'Enter:')} confirm`,
];

export interface MultiSelectOptions<Value> extends CommonOptions {
message: string;
options: Option<Value>[];
initialValues?: Value[];
maxItems?: number;
required?: boolean;
cursorAt?: Value;
/**
* Show keyboard instructions below the option list.
* @default false
*/
instructions?: boolean;
}
const computeLabel = (label: string, format: (text: string) => string) => {
return label
Expand Down Expand Up @@ -70,6 +82,7 @@ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
return `${styleText('dim', S_CHECKBOX_INACTIVE)} ${computeLabel(label, (text) => styleText('dim', text))}`;
};
const required = opts.required ?? true;
const showInstructions = opts.instructions ?? false;

return new MultiSelectPrompt({
options: opts.options,
Expand Down Expand Up @@ -171,9 +184,14 @@ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
}
default: {
const prefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : '';
// Calculate rowPadding: title lines + footer lines (S_BAR_END + trailing newline)
const titleLineCount = title.split('\n').length;
const footerLineCount = hasGuide ? 2 : 1; // S_BAR_END + trailing newline
const footerLines = showInstructions
? formatInstructionFooter(MULTISELECT_INSTRUCTIONS, hasGuide)
: hasGuide
? [styleText('cyan', S_BAR_END)]
: [];
const footerText = footerLines.join('\n');
const footerLineCount = footerLines.length + 1;
return `${title}${prefix}${limitOptions({
output: opts.output,
options: this.options,
Expand All @@ -182,7 +200,7 @@ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
columnPadding: prefix.length,
rowPadding: titleLineCount + footerLineCount,
style: styleOption,
}).join(`\n${prefix}`)}\n${hasGuide ? styleText('cyan', S_BAR_END) : ''}\n`;
}).join(`\n${prefix}`)}\n${footerText}\n`;
}
}
},
Expand Down
26 changes: 22 additions & 4 deletions packages/prompts/src/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { styleText } from 'node:util';
import { SelectPrompt, settings, wrapTextWithPrefix } from '@clack/core';
import {
type CommonOptions,
formatInstructionFooter,
S_BAR,
S_BAR_END,
S_RADIO_ACTIVE,
Expand All @@ -11,6 +12,11 @@ import {
} from './common.js';
import { limitOptions } from './limit-options.js';

export const SELECT_INSTRUCTIONS = [
`${styleText('dim', '↑/↓')} to navigate`,
`${styleText('dim', 'Enter:')} confirm`,
];

type Primitive = Readonly<string | boolean | number>;

export type Option<Value> = Value extends Primitive
Expand Down Expand Up @@ -70,6 +76,12 @@ export interface SelectOptions<Value> extends CommonOptions {
options: Option<Value>[];
initialValue?: Value;
maxItems?: number;
/**
* Show keyboard instructions below the option list.
* @default false
* @since 1.1.0
*/
instructions?: boolean;
}

const computeLabel = (label: string, format: (text: string) => string) => {
Expand Down Expand Up @@ -106,6 +118,8 @@ export const select = <Value>(opts: SelectOptions<Value>) => {
}
};

const showInstructions = opts.instructions ?? false;

return new SelectPrompt({
options: opts.options,
signal: opts.signal,
Expand Down Expand Up @@ -145,10 +159,14 @@ export const select = <Value>(opts: SelectOptions<Value>) => {
}
default: {
const prefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : '';
const prefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : '';
// Calculate rowPadding: title lines + footer lines (S_BAR_END + trailing newline)
const titleLineCount = title.split('\n').length;
const footerLineCount = hasGuide ? 2 : 1; // S_BAR_END + trailing newline (or just trailing newline)
const footerLines = showInstructions
? formatInstructionFooter(SELECT_INSTRUCTIONS, hasGuide)
: hasGuide
? [styleText('cyan', S_BAR_END)]
: [];
const footerText = footerLines.join('\n');
const footerLineCount = footerLines.length + 1;
return `${title}${prefix}${limitOptions({
output: opts.output,
cursor: this.cursor,
Expand All @@ -158,7 +176,7 @@ export const select = <Value>(opts: SelectOptions<Value>) => {
rowPadding: titleLineCount + footerLineCount,
style: (item, active) =>
opt(item, item.disabled ? 'disabled' : active ? 'active' : 'inactive'),
}).join(`\n${prefix}`)}\n${prefixEnd}\n`;
}).join(`\n${prefix}`)}\n${footerText}\n`;
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,28 @@ exports[`groupMultiselect (isCI = false) > initial values can be set 1`] = `
]
`;

exports[`groupMultiselect (isCI = false) > instructions: true renders footer 1`] = `
[
"<cursor.hide>",
"│
◆ foo
│ ◻ group1
│ │ ◻ group1value0
│ └ ◻ group1value1
│ ↑/↓ to navigate • Space: select • Enter: confirm
└
",
"<cursor.backward count=999><cursor.up count=7>",
"<cursor.down count=1>",
"<erase.down>",
"◇ foo
│",
"
",
"<cursor.show>",
]
`;

exports[`groupMultiselect (isCI = false) > maxItems renders a sliding window 1`] = `
[
"<cursor.hide>",
Expand Down Expand Up @@ -1298,6 +1320,28 @@ exports[`groupMultiselect (isCI = true) > initial values can be set 1`] = `
]
`;

exports[`groupMultiselect (isCI = true) > instructions: true renders footer 1`] = `
[
"<cursor.hide>",
"│
◆ foo
│ ◻ group1
│ │ ◻ group1value0
│ └ ◻ group1value1
│ ↑/↓ to navigate • Space: select • Enter: confirm
└
",
"<cursor.backward count=999><cursor.up count=7>",
"<cursor.down count=1>",
"<erase.down>",
"◇ foo
│",
"
",
"<cursor.show>",
]
`;

exports[`groupMultiselect (isCI = true) > maxItems renders a sliding window 1`] = `
[
"<cursor.hide>",
Expand Down
Loading
Loading