@pokit/terminal 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Clack Prompter Implementation
3
+ *
4
+ * Implements the Prompter interface using @clack/prompts.
5
+ *
6
+ * Dynamic selects no longer spawn their own spinners: loading is routed through
7
+ * the shared terminal Screen, keeping a single screen owner. Filtering is
8
+ * handled client-side by the autocomplete prompt once options are loaded.
9
+ */
10
+
11
+ import * as p from '@clack/prompts';
12
+ import type {
13
+ Prompter,
14
+ SelectOptions,
15
+ SelectOption,
16
+ DynamicSelectOptions,
17
+ MultiselectOptions,
18
+ ConfirmOptions,
19
+ TextOptions,
20
+ AutocompleteOptions,
21
+ } from '@pokit/core';
22
+ import { isDynamicOptions, CancelError } from '@pokit/core';
23
+ import { patchedAutocomplete } from './autocomplete-prompt.js';
24
+ import type { Screen } from '../screen.js';
25
+
26
+ // =============================================================================
27
+ // Error Recovery
28
+ // =============================================================================
29
+
30
+ type ErrorAction = 'retry' | 'cancel';
31
+
32
+ /**
33
+ * Show error recovery prompt. Returns 'retry' or 'cancel'.
34
+ */
35
+ async function showErrorRecovery(errorMessage: string): Promise<ErrorAction> {
36
+ const result = await p.select({
37
+ message: `Error: ${errorMessage}`,
38
+ options: [
39
+ { value: 'retry' as const, label: 'Retry', hint: 'Try loading again' },
40
+ { value: 'cancel' as const, label: 'Cancel', hint: 'Abort selection' },
41
+ ],
42
+ });
43
+
44
+ if (p.isCancel(result)) {
45
+ return 'cancel';
46
+ }
47
+
48
+ return result as ErrorAction;
49
+ }
50
+
51
+ // =============================================================================
52
+ // Dynamic Select Implementation
53
+ // =============================================================================
54
+
55
+ /**
56
+ * Handle a dynamic select: load the option set via the provider (loading shown
57
+ * through the shared screen), then present them with type-ahead filtering.
58
+ */
59
+ async function handleDynamicSelect<T>(
60
+ dynamicOptions: DynamicSelectOptions<T>,
61
+ screen: Screen
62
+ ): Promise<T> {
63
+ const loadingMessage = dynamicOptions.loadingMessage ?? 'Loading...';
64
+
65
+ let options: SelectOption<T>[];
66
+ try {
67
+ options = await screen.withLoading(loadingMessage, (signal) =>
68
+ dynamicOptions.provider(undefined, signal)
69
+ );
70
+ } catch (error) {
71
+ const errorMessage =
72
+ dynamicOptions.errorMessage ??
73
+ (error instanceof Error ? error.message : 'Failed to load options');
74
+
75
+ const action = await showErrorRecovery(errorMessage);
76
+ if (action === 'cancel') {
77
+ p.cancel('Cancelled');
78
+ throw new CancelError('Cancelled');
79
+ }
80
+ // Retry
81
+ return handleDynamicSelect(dynamicOptions, screen);
82
+ }
83
+
84
+ if (options.length === 0) {
85
+ p.cancel('No options available');
86
+ throw new CancelError('No options available');
87
+ }
88
+
89
+ const result = await patchedAutocomplete({
90
+ message: dynamicOptions.message,
91
+ options: options.map((opt) => ({
92
+ value: opt.value,
93
+ label: opt.label,
94
+ hint: opt.hint,
95
+ })),
96
+ });
97
+
98
+ if (p.isCancel(result)) {
99
+ throw new CancelError('Cancelled');
100
+ }
101
+
102
+ return result as T;
103
+ }
104
+
105
+ // =============================================================================
106
+ // Grouped Options Helpers
107
+ // =============================================================================
108
+
109
+ /**
110
+ * Organize flat options into a group → options record for clack's groupMultiselect.
111
+ * Options without a group are placed under an empty-string key.
112
+ */
113
+ function toGroupedRecord<T>(
114
+ options: { value: T; label: string; hint?: string; group?: string }[]
115
+ ): Record<string, { value: T; label: string; hint?: string }[]> {
116
+ const groups: Record<string, { value: T; label: string; hint?: string }[]> = {};
117
+ for (const opt of options) {
118
+ const key = opt.group ?? '';
119
+ if (!groups[key]) groups[key] = [];
120
+ groups[key]!.push({ value: opt.value, label: opt.label, hint: opt.hint });
121
+ }
122
+ return groups;
123
+ }
124
+
125
+ /**
126
+ * Sort options by group order (preserving insertion order of first occurrence)
127
+ * and add group name as label prefix for visual separation in a flat select.
128
+ */
129
+ function flattenGroupedForSelect<T>(
130
+ options: { value: T; label: string; hint?: string; group?: string }[]
131
+ ): { value: T; label: string; hint?: string }[] {
132
+ const groupOrder: string[] = [];
133
+ for (const opt of options) {
134
+ const key = opt.group ?? '';
135
+ if (!groupOrder.includes(key)) groupOrder.push(key);
136
+ }
137
+
138
+ const result: { value: T; label: string; hint?: string }[] = [];
139
+ for (const group of groupOrder) {
140
+ const groupOpts = options.filter((opt) => (opt.group ?? '') === group);
141
+ for (const opt of groupOpts) {
142
+ result.push({
143
+ value: opt.value,
144
+ label: group ? `${group} › ${opt.label}` : opt.label,
145
+ hint: opt.hint,
146
+ });
147
+ }
148
+ }
149
+ return result;
150
+ }
151
+
152
+ // =============================================================================
153
+ // Main Prompter Factory
154
+ // =============================================================================
155
+
156
+ /**
157
+ * Create a Prompter using @clack/prompts, sharing the terminal Screen.
158
+ */
159
+ export function createPrompter(screen: Screen): Prompter {
160
+ return {
161
+ async select<T>(options: SelectOptions<T>): Promise<T> {
162
+ // Handle dynamic options via the shared screen
163
+ if (isDynamicOptions(options)) {
164
+ return handleDynamicSelect(options, screen);
165
+ }
166
+
167
+ // Static options - handle grouped or flat
168
+ const opts = options.options;
169
+ const clackOptions = opts.some((o) => o.group)
170
+ ? flattenGroupedForSelect(opts)
171
+ : opts.map((opt) => ({ value: opt.value, label: opt.label, hint: opt.hint }));
172
+
173
+ const result = await p.select({
174
+ message: options.message,
175
+ options: clackOptions as Parameters<typeof p.select<T>>[0]['options'],
176
+ initialValue: options.initialValue,
177
+ });
178
+
179
+ if (p.isCancel(result)) {
180
+ throw new CancelError('Cancelled');
181
+ }
182
+
183
+ return result as T;
184
+ },
185
+
186
+ async multiselect<T>(options: MultiselectOptions<T>): Promise<T[]> {
187
+ // Use groupMultiselect when options have groups
188
+ const msOpts = options.options;
189
+ if (msOpts.some((o) => o.group)) {
190
+ const grouped = toGroupedRecord(msOpts);
191
+ const result = await p.groupMultiselect({
192
+ message: options.message,
193
+ options: grouped as any,
194
+ required: options.required,
195
+ });
196
+
197
+ if (p.isCancel(result)) {
198
+ throw new CancelError('Cancelled');
199
+ }
200
+
201
+ return result as T[];
202
+ }
203
+
204
+ const result = await p.multiselect({
205
+ message: options.message,
206
+ options: options.options as Parameters<typeof p.multiselect<T>>[0]['options'],
207
+ initialValues: options.initialValues,
208
+ required: options.required,
209
+ });
210
+
211
+ if (p.isCancel(result)) {
212
+ throw new CancelError('Cancelled');
213
+ }
214
+
215
+ return result as T[];
216
+ },
217
+
218
+ async confirm(options: ConfirmOptions): Promise<boolean> {
219
+ const result = await p.confirm({
220
+ message: options.message,
221
+ initialValue: options.initialValue,
222
+ });
223
+
224
+ if (p.isCancel(result)) {
225
+ throw new CancelError('Cancelled');
226
+ }
227
+
228
+ return result;
229
+ },
230
+
231
+ async text(options: TextOptions): Promise<string> {
232
+ const result = await p.text({
233
+ message: options.message,
234
+ placeholder: options.placeholder,
235
+ initialValue: options.initialValue,
236
+ validate: options.validate
237
+ ? (value: string | undefined) => options.validate!(value ?? '')
238
+ : undefined,
239
+ });
240
+
241
+ if (p.isCancel(result)) {
242
+ throw new CancelError('Cancelled');
243
+ }
244
+
245
+ return result;
246
+ },
247
+
248
+ async autocomplete<T>(options: AutocompleteOptions<T>): Promise<T> {
249
+ const result = await patchedAutocomplete({
250
+ message: options.message,
251
+ options: options.options.map((opt) => ({
252
+ value: opt.value,
253
+ label: opt.label,
254
+ hint: opt.hint,
255
+ })),
256
+ placeholder: options.placeholder,
257
+ maxItems: options.maxItems,
258
+ });
259
+
260
+ if (p.isCancel(result)) {
261
+ throw new CancelError('Cancelled');
262
+ }
263
+
264
+ return result as T;
265
+ },
266
+ };
267
+ }