Skip to content

Commit 3f7e1a4

Browse files
authored
feat: reorganise prompts (#283)
1 parent 0aaee4c commit 3f7e1a4

File tree

20 files changed

+1269
-1155
lines changed

20 files changed

+1269
-1155
lines changed

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ export { default as Prompt } from './prompts/prompt.js';
99
export { default as SelectPrompt } from './prompts/select.js';
1010
export { default as SelectKeyPrompt } from './prompts/select-key.js';
1111
export { default as TextPrompt } from './prompts/text.js';
12-
export { block, isCancel } from './utils/index.js';
12+
export { block, isCancel, getColumns } from './utils/index.js';
1313
export { updateSettings, settings } from './utils/settings.js';

packages/core/src/utils/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { stdin, stdout } from 'node:process';
22
import type { Key } from 'node:readline';
33
import * as readline from 'node:readline';
44
import type { Readable, Writable } from 'node:stream';
5-
import { ReadStream } from 'node:tty';
5+
import { ReadStream, WriteStream } from 'node:tty';
66
import { cursor } from 'sisteransi';
77
import { isActionKey } from './settings.js';
88

@@ -82,3 +82,10 @@ export function block({
8282
rl.close();
8383
};
8484
}
85+
86+
export const getColumns = (output: Writable): number => {
87+
if (output instanceof WriteStream && output.columns) {
88+
return output.columns;
89+
}
90+
return 80;
91+
};

packages/prompts/src/common.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { Readable, Writable } from 'node:stream';
2+
import type { State } from '@clack/core';
3+
import isUnicodeSupported from 'is-unicode-supported';
4+
import color from 'picocolors';
5+
6+
export const unicode = isUnicodeSupported();
7+
const s = (c: string, fallback: string) => (unicode ? c : fallback);
8+
export const S_STEP_ACTIVE = s('◆', '*');
9+
export const S_STEP_CANCEL = s('■', 'x');
10+
export const S_STEP_ERROR = s('▲', 'x');
11+
export const S_STEP_SUBMIT = s('◇', 'o');
12+
13+
export const S_BAR_START = s('┌', 'T');
14+
export const S_BAR = s('│', '|');
15+
export const S_BAR_END = s('└', '—');
16+
17+
export const S_RADIO_ACTIVE = s('●', '>');
18+
export const S_RADIO_INACTIVE = s('○', ' ');
19+
export const S_CHECKBOX_ACTIVE = s('◻', '[•]');
20+
export const S_CHECKBOX_SELECTED = s('◼', '[+]');
21+
export const S_CHECKBOX_INACTIVE = s('◻', '[ ]');
22+
export const S_PASSWORD_MASK = s('▪', '•');
23+
24+
export const S_BAR_H = s('─', '-');
25+
export const S_CORNER_TOP_RIGHT = s('╮', '+');
26+
export const S_CONNECT_LEFT = s('├', '+');
27+
export const S_CORNER_BOTTOM_RIGHT = s('╯', '+');
28+
29+
export const S_INFO = s('●', '•');
30+
export const S_SUCCESS = s('◆', '*');
31+
export const S_WARN = s('▲', '!');
32+
export const S_ERROR = s('■', 'x');
33+
34+
export const symbol = (state: State) => {
35+
switch (state) {
36+
case 'initial':
37+
case 'active':
38+
return color.cyan(S_STEP_ACTIVE);
39+
case 'cancel':
40+
return color.red(S_STEP_CANCEL);
41+
case 'error':
42+
return color.yellow(S_STEP_ERROR);
43+
case 'submit':
44+
return color.green(S_STEP_SUBMIT);
45+
}
46+
};
47+
48+
export interface CommonOptions {
49+
input?: Readable;
50+
output?: Writable;
51+
}

packages/prompts/src/confirm.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { ConfirmPrompt } from '@clack/core';
2+
import color from 'picocolors';
3+
import {
4+
type CommonOptions,
5+
S_BAR,
6+
S_BAR_END,
7+
S_RADIO_ACTIVE,
8+
S_RADIO_INACTIVE,
9+
symbol,
10+
} from './common.js';
11+
12+
export interface ConfirmOptions extends CommonOptions {
13+
message: string;
14+
active?: string;
15+
inactive?: string;
16+
initialValue?: boolean;
17+
}
18+
export const confirm = (opts: ConfirmOptions) => {
19+
const active = opts.active ?? 'Yes';
20+
const inactive = opts.inactive ?? 'No';
21+
return new ConfirmPrompt({
22+
active,
23+
inactive,
24+
input: opts.input,
25+
output: opts.output,
26+
initialValue: opts.initialValue ?? true,
27+
render() {
28+
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
29+
const value = this.value ? active : inactive;
30+
31+
switch (this.state) {
32+
case 'submit':
33+
return `${title}${color.gray(S_BAR)} ${color.dim(value)}`;
34+
case 'cancel':
35+
return `${title}${color.gray(S_BAR)} ${color.strikethrough(
36+
color.dim(value)
37+
)}\n${color.gray(S_BAR)}`;
38+
default: {
39+
return `${title}${color.cyan(S_BAR)} ${
40+
this.value
41+
? `${color.green(S_RADIO_ACTIVE)} ${active}`
42+
: `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}`
43+
} ${color.dim('/')} ${
44+
!this.value
45+
? `${color.green(S_RADIO_ACTIVE)} ${inactive}`
46+
: `${color.dim(S_RADIO_INACTIVE)} ${color.dim(inactive)}`
47+
}\n${color.cyan(S_BAR_END)}\n`;
48+
}
49+
}
50+
},
51+
}).prompt() as Promise<boolean | symbol>;
52+
};
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { GroupMultiSelectPrompt } from '@clack/core';
2+
import color from 'picocolors';
3+
import {
4+
type CommonOptions,
5+
S_BAR,
6+
S_BAR_END,
7+
S_CHECKBOX_ACTIVE,
8+
S_CHECKBOX_INACTIVE,
9+
S_CHECKBOX_SELECTED,
10+
symbol,
11+
} from './common.js';
12+
import type { Option } from './select.js';
13+
14+
export interface GroupMultiSelectOptions<Value> extends CommonOptions {
15+
message: string;
16+
options: Record<string, Option<Value>[]>;
17+
initialValues?: Value[];
18+
required?: boolean;
19+
cursorAt?: Value;
20+
selectableGroups?: boolean;
21+
groupSpacing?: number;
22+
}
23+
export const groupMultiselect = <Value>(opts: GroupMultiSelectOptions<Value>) => {
24+
const { selectableGroups = true, groupSpacing = 0 } = opts;
25+
const opt = (
26+
option: Option<Value>,
27+
state:
28+
| 'inactive'
29+
| 'active'
30+
| 'selected'
31+
| 'active-selected'
32+
| 'group-active'
33+
| 'group-active-selected'
34+
| 'submitted'
35+
| 'cancelled',
36+
options: Option<Value>[] = []
37+
) => {
38+
const label = option.label ?? String(option.value);
39+
const isItem = typeof (option as any).group === 'string';
40+
const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true });
41+
const isLast = isItem && (next as any).group === true;
42+
const prefix = isItem ? (selectableGroups ? `${isLast ? S_BAR_END : S_BAR} ` : ' ') : '';
43+
const spacingPrefix =
44+
groupSpacing > 0 && !isItem ? `\n${color.cyan(S_BAR)} `.repeat(groupSpacing) : '';
45+
46+
if (state === 'active') {
47+
return `${spacingPrefix}${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${
48+
option.hint ? color.dim(`(${option.hint})`) : ''
49+
}`;
50+
}
51+
if (state === 'group-active') {
52+
return `${spacingPrefix}${prefix}${color.cyan(S_CHECKBOX_ACTIVE)} ${color.dim(label)}`;
53+
}
54+
if (state === 'group-active-selected') {
55+
return `${spacingPrefix}${prefix}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`;
56+
}
57+
if (state === 'selected') {
58+
const selectedCheckbox = isItem || selectableGroups ? color.green(S_CHECKBOX_SELECTED) : '';
59+
return `${spacingPrefix}${color.dim(prefix)}${selectedCheckbox} ${color.dim(label)} ${
60+
option.hint ? color.dim(`(${option.hint})`) : ''
61+
}`;
62+
}
63+
if (state === 'cancelled') {
64+
return `${color.strikethrough(color.dim(label))}`;
65+
}
66+
if (state === 'active-selected') {
67+
return `${spacingPrefix}${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${label} ${
68+
option.hint ? color.dim(`(${option.hint})`) : ''
69+
}`;
70+
}
71+
if (state === 'submitted') {
72+
return `${color.dim(label)}`;
73+
}
74+
const unselectedCheckbox = isItem || selectableGroups ? color.dim(S_CHECKBOX_INACTIVE) : '';
75+
return `${spacingPrefix}${color.dim(prefix)}${unselectedCheckbox} ${color.dim(label)}`;
76+
};
77+
78+
return new GroupMultiSelectPrompt({
79+
options: opts.options,
80+
input: opts.input,
81+
output: opts.output,
82+
initialValues: opts.initialValues,
83+
required: opts.required ?? true,
84+
cursorAt: opts.cursorAt,
85+
selectableGroups,
86+
validate(selected: Value[]) {
87+
if (this.required && selected.length === 0)
88+
return `Please select at least one option.\n${color.reset(
89+
color.dim(
90+
`Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray(
91+
color.bgWhite(color.inverse(' enter '))
92+
)} to submit`
93+
)
94+
)}`;
95+
},
96+
render() {
97+
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
98+
99+
switch (this.state) {
100+
case 'submit': {
101+
return `${title}${color.gray(S_BAR)} ${this.options
102+
.filter(({ value }) => this.value.includes(value))
103+
.map((option) => opt(option, 'submitted'))
104+
.join(color.dim(', '))}`;
105+
}
106+
case 'cancel': {
107+
const label = this.options
108+
.filter(({ value }) => this.value.includes(value))
109+
.map((option) => opt(option, 'cancelled'))
110+
.join(color.dim(', '));
111+
return `${title}${color.gray(S_BAR)} ${
112+
label.trim() ? `${label}\n${color.gray(S_BAR)}` : ''
113+
}`;
114+
}
115+
case 'error': {
116+
const footer = this.error
117+
.split('\n')
118+
.map((ln, i) =>
119+
i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}`
120+
)
121+
.join('\n');
122+
return `${title}${color.yellow(S_BAR)} ${this.options
123+
.map((option, i, options) => {
124+
const selected =
125+
this.value.includes(option.value) ||
126+
(option.group === true && this.isGroupSelected(`${option.value}`));
127+
const active = i === this.cursor;
128+
const groupActive =
129+
!active &&
130+
typeof option.group === 'string' &&
131+
this.options[this.cursor].value === option.group;
132+
if (groupActive) {
133+
return opt(option, selected ? 'group-active-selected' : 'group-active', options);
134+
}
135+
if (active && selected) {
136+
return opt(option, 'active-selected', options);
137+
}
138+
if (selected) {
139+
return opt(option, 'selected', options);
140+
}
141+
return opt(option, active ? 'active' : 'inactive', options);
142+
})
143+
.join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`;
144+
}
145+
default: {
146+
return `${title}${color.cyan(S_BAR)} ${this.options
147+
.map((option, i, options) => {
148+
const selected =
149+
this.value.includes(option.value) ||
150+
(option.group === true && this.isGroupSelected(`${option.value}`));
151+
const active = i === this.cursor;
152+
const groupActive =
153+
!active &&
154+
typeof option.group === 'string' &&
155+
this.options[this.cursor].value === option.group;
156+
if (groupActive) {
157+
return opt(option, selected ? 'group-active-selected' : 'group-active', options);
158+
}
159+
if (active && selected) {
160+
return opt(option, 'active-selected', options);
161+
}
162+
if (selected) {
163+
return opt(option, 'selected', options);
164+
}
165+
return opt(option, active ? 'active' : 'inactive', options);
166+
})
167+
.join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`;
168+
}
169+
}
170+
},
171+
}).prompt() as Promise<Value[] | symbol>;
172+
};

packages/prompts/src/group.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { isCancel } from '@clack/core';
2+
3+
type Prettify<T> = {
4+
[P in keyof T]: T[P];
5+
} & {};
6+
7+
export type PromptGroupAwaitedReturn<T> = {
8+
[P in keyof T]: Exclude<Awaited<T[P]>, symbol>;
9+
};
10+
11+
export interface PromptGroupOptions<T> {
12+
/**
13+
* Control how the group can be canceled
14+
* if one of the prompts is canceled.
15+
*/
16+
onCancel?: (opts: {
17+
results: Prettify<Partial<PromptGroupAwaitedReturn<T>>>;
18+
}) => void;
19+
}
20+
21+
export type PromptGroup<T> = {
22+
[P in keyof T]: (opts: {
23+
results: Prettify<Partial<PromptGroupAwaitedReturn<Omit<T, P>>>>;
24+
}) => undefined | Promise<T[P] | undefined>;
25+
};
26+
27+
/**
28+
* Define a group of prompts to be displayed
29+
* and return a results of objects within the group
30+
*/
31+
export const group = async <T>(
32+
prompts: PromptGroup<T>,
33+
opts?: PromptGroupOptions<T>
34+
): Promise<Prettify<PromptGroupAwaitedReturn<T>>> => {
35+
const results = {} as any;
36+
const promptNames = Object.keys(prompts);
37+
38+
for (const name of promptNames) {
39+
const prompt = prompts[name as keyof T];
40+
const result = await prompt({ results })?.catch((e) => {
41+
throw e;
42+
});
43+
44+
// Pass the results to the onCancel function
45+
// so the user can decide what to do with the results
46+
// TODO: Switch to callback within core to avoid isCancel Fn
47+
if (typeof opts?.onCancel === 'function' && isCancel(result)) {
48+
results[name] = 'canceled';
49+
opts.onCancel({ results });
50+
continue;
51+
}
52+
53+
results[name] = result;
54+
}
55+
56+
return results;
57+
};

0 commit comments

Comments
 (0)