@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Daniel Grant
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @pokit/terminal
3
+ *
4
+ * The default terminal UI for pok CLI applications, built on clack. Bundles the
5
+ * three UI surfaces — reporter (event rendering), prompter (interactive input),
6
+ * and navigator (menu presentation policy) — behind a single factory so an app
7
+ * wires them in with one call.
8
+ */
9
+ import type { OutputConfig, ReporterAdapter, Prompter, Navigator } from '@pokit/core';
10
+ export type { OutputConfig, ReporterAdapter, Prompter, Navigator } from '@pokit/core';
11
+ /**
12
+ * Options for creating the terminal UI.
13
+ */
14
+ export type TerminalUIOptions = {
15
+ /** When true, logs are displayed immediately instead of being buffered during spinners */
16
+ verbose?: boolean;
17
+ /** Output configuration (color, unicode, interactive). Detected from args/env when omitted. */
18
+ output?: OutputConfig;
19
+ };
20
+ /**
21
+ * The bundled terminal UI surfaces.
22
+ */
23
+ export type TerminalUI = {
24
+ reporter: ReporterAdapter;
25
+ prompter: Prompter;
26
+ navigator: Navigator;
27
+ };
28
+ /**
29
+ * Create the default terminal UI.
30
+ *
31
+ * Returns the reporter adapter, prompter, and navigator, all sharing a single
32
+ * screen so loading indicators and rendering are owned in one place.
33
+ */
34
+ export declare function createTerminalUI(options?: TerminalUIOptions): TerminalUI;
35
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EACV,YAAY,EACZ,eAAe,EACf,QAAQ,EACR,SAAS,EACV,MAAM,aAAa,CAAC;AAKrB,YAAY,EAAE,YAAY,EAAE,eAAe,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAEtF;;GAEG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,0FAA0F;IAC1F,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,+FAA+F;IAC/F,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,EAAE,eAAe,CAAC;IAC1B,QAAQ,EAAE,QAAQ,CAAC;IACnB,SAAS,EAAE,SAAS,CAAC;CACtB,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,UAAU,CAexE"}
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @pokit/terminal
3
+ *
4
+ * The default terminal UI for pok CLI applications, built on clack. Bundles the
5
+ * three UI surfaces — reporter (event rendering), prompter (interactive input),
6
+ * and navigator (menu presentation policy) — behind a single factory so an app
7
+ * wires them in with one call.
8
+ */
9
+ import { detectOutputConfig, createMenuNavigator } from '@pokit/core';
10
+ import { createReporterAdapter } from './reporter/adapter.js';
11
+ import { createPrompter } from './prompter/prompter.js';
12
+ import { createScreen } from './screen.js';
13
+ /**
14
+ * Create the default terminal UI.
15
+ *
16
+ * Returns the reporter adapter, prompter, and navigator, all sharing a single
17
+ * screen so loading indicators and rendering are owned in one place.
18
+ */
19
+ export function createTerminalUI(options) {
20
+ const outputConfig = options?.output ?? detectOutputConfig(process.argv.slice(2));
21
+ if (outputConfig.interactive === undefined) {
22
+ outputConfig.interactive = true;
23
+ }
24
+ if (options?.verbose !== undefined) {
25
+ outputConfig.verbose = options.verbose;
26
+ }
27
+ const screen = createScreen(outputConfig);
28
+ const reporter = createReporterAdapter({ output: outputConfig });
29
+ const prompter = createPrompter(screen);
30
+ const navigator = createMenuNavigator(prompter);
31
+ return { reporter, prompter, navigator };
32
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Patched autocomplete function
3
+ *
4
+ * Copies the autocomplete function from @clack/prompts but uses a patched
5
+ * AutocompletePrompt that wraps the cursor instead of clamping it.
6
+ *
7
+ * Upstream tracking: https://github.com/bombshell-dev/clack/issues
8
+ * TODO: Remove this file once cursor wrapping is available upstream.
9
+ */
10
+ import type { Writable } from 'node:stream';
11
+ interface Option<Value> {
12
+ value: Value;
13
+ label?: string;
14
+ hint?: string;
15
+ }
16
+ export interface AutocompleteOpts<Value> {
17
+ message: string;
18
+ options: Option<Value>[];
19
+ maxItems?: number;
20
+ placeholder?: string;
21
+ initialValue?: Value;
22
+ initialUserInput?: string;
23
+ validate?: (value: Value | Value[] | undefined) => string | Error | undefined;
24
+ filter?: (search: string, option: Option<Value>) => boolean;
25
+ signal?: AbortSignal;
26
+ input?: import('node:stream').Readable;
27
+ output?: Writable;
28
+ }
29
+ export declare const patchedAutocomplete: <Value>(opts: AutocompleteOpts<Value>) => Promise<Value | symbol>;
30
+ export {};
31
+ //# sourceMappingURL=autocomplete-prompt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"autocomplete-prompt.d.ts","sourceRoot":"","sources":["../../src/prompter/autocomplete-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAaH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAuL5C,UAAU,MAAM,CAAC,KAAK;IACpB,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAyBD,MAAM,WAAW,gBAAgB,CAAC,KAAK;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,KAAK,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,GAAG,KAAK,EAAE,GAAG,SAAS,KAAK,MAAM,GAAG,KAAK,GAAG,SAAS,CAAC;IAC9E,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,KAAK,OAAO,CAAC;IAC5D,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,KAAK,CAAC,EAAE,OAAO,aAAa,EAAE,QAAQ,CAAC;IACvC,MAAM,CAAC,EAAE,QAAQ,CAAC;CACnB;AAED,eAAO,MAAM,mBAAmB,GAAI,KAAK,EAAE,MAAM,gBAAgB,CAAC,KAAK,CAAC,KAuG5C,OAAO,CAAC,KAAK,GAAG,MAAM,CACjD,CAAC"}
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Patched autocomplete function
3
+ *
4
+ * Copies the autocomplete function from @clack/prompts but uses a patched
5
+ * AutocompletePrompt that wraps the cursor instead of clamping it.
6
+ *
7
+ * Upstream tracking: https://github.com/bombshell-dev/clack/issues
8
+ * TODO: Remove this file once cursor wrapping is available upstream.
9
+ */
10
+ import { Prompt } from '@clack/core';
11
+ import { limitOptions, symbol, S_BAR, S_BAR_END, S_RADIO_ACTIVE, S_RADIO_INACTIVE, } from '@clack/prompts';
12
+ import color from 'picocolors';
13
+ function getCursorForValue(selected, items) {
14
+ if (selected === undefined || items.length === 0) {
15
+ return 0;
16
+ }
17
+ const index = items.findIndex((item) => item.value === selected);
18
+ return index !== -1 ? index : 0;
19
+ }
20
+ function defaultFilter(input, option) {
21
+ const label = option.label ?? String(option.value);
22
+ return label.toLowerCase().includes(input.toLowerCase());
23
+ }
24
+ class PatchedAutocompletePrompt extends Prompt {
25
+ filteredOptions;
26
+ multiple;
27
+ isNavigating = false;
28
+ selectedValues = [];
29
+ focusedValue;
30
+ #cursor = 0;
31
+ #lastUserInput = '';
32
+ #filterFn;
33
+ #options;
34
+ get cursor() {
35
+ return this.#cursor;
36
+ }
37
+ get userInputWithCursor() {
38
+ if (!this.userInput) {
39
+ return color.inverse(color.hidden('_'));
40
+ }
41
+ if (this._cursor >= this.userInput.length) {
42
+ return `${this.userInput}█`;
43
+ }
44
+ const s1 = this.userInput.slice(0, this._cursor);
45
+ const [s2, ...s3] = this.userInput.slice(this._cursor);
46
+ return `${s1}${color.inverse(s2)}${s3.join('')}`;
47
+ }
48
+ get options() {
49
+ if (typeof this.#options === 'function') {
50
+ return this.#options();
51
+ }
52
+ return this.#options;
53
+ }
54
+ constructor(opts) {
55
+ super(opts);
56
+ this.#options = opts.options;
57
+ const options = this.options;
58
+ this.filteredOptions = [...options];
59
+ this.multiple = opts.multiple === true;
60
+ this.#filterFn = opts.filter ?? defaultFilter;
61
+ let initialValues;
62
+ if (opts.initialValue && Array.isArray(opts.initialValue)) {
63
+ initialValues = this.multiple ? opts.initialValue : opts.initialValue.slice(0, 1);
64
+ }
65
+ else if (!this.multiple && this.options.length > 0) {
66
+ initialValues = [this.options[0].value];
67
+ }
68
+ if (initialValues) {
69
+ for (const selectedValue of initialValues) {
70
+ const selectedIndex = options.findIndex((opt) => opt.value === selectedValue);
71
+ if (selectedIndex !== -1) {
72
+ this.toggleSelected(selectedValue);
73
+ this.#cursor = selectedIndex;
74
+ }
75
+ }
76
+ }
77
+ this.focusedValue = this.options[this.#cursor]?.value;
78
+ this.on('key', (char, key) => this.#onKey(char, key));
79
+ this.on('userInput', (value) => this.#onUserInputChanged(value));
80
+ }
81
+ _isActionKey(char, key) {
82
+ return (char === '\t' ||
83
+ (this.multiple &&
84
+ this.isNavigating &&
85
+ key.name === 'space' &&
86
+ char !== undefined &&
87
+ char !== ''));
88
+ }
89
+ #onKey(_char, key) {
90
+ const isUpKey = key.name === 'up';
91
+ const isDownKey = key.name === 'down';
92
+ const isReturnKey = key.name === 'return';
93
+ if (isUpKey || isDownKey) {
94
+ const length = this.filteredOptions.length;
95
+ if (length > 0) {
96
+ this.#cursor = (((this.#cursor + (isUpKey ? -1 : 1)) % length) + length) % length;
97
+ }
98
+ this.focusedValue = this.filteredOptions[this.#cursor]?.value;
99
+ if (!this.multiple) {
100
+ this.selectedValues = [this.focusedValue];
101
+ }
102
+ this.isNavigating = true;
103
+ }
104
+ else if (isReturnKey) {
105
+ this.value = this.multiple ? this.selectedValues : this.selectedValues[0];
106
+ }
107
+ else {
108
+ if (this.multiple) {
109
+ if (this.focusedValue !== undefined &&
110
+ (key.name === 'tab' || (this.isNavigating && key.name === 'space'))) {
111
+ this.toggleSelected(this.focusedValue);
112
+ }
113
+ else {
114
+ this.isNavigating = false;
115
+ }
116
+ }
117
+ else {
118
+ if (this.focusedValue) {
119
+ this.selectedValues = [this.focusedValue];
120
+ }
121
+ this.isNavigating = false;
122
+ }
123
+ }
124
+ }
125
+ deselectAll() {
126
+ this.selectedValues = [];
127
+ }
128
+ toggleSelected(value) {
129
+ if (this.filteredOptions.length === 0)
130
+ return;
131
+ if (this.multiple) {
132
+ if (this.selectedValues.includes(value)) {
133
+ this.selectedValues = this.selectedValues.filter((v) => v !== value);
134
+ }
135
+ else {
136
+ this.selectedValues = [...this.selectedValues, value];
137
+ }
138
+ }
139
+ else {
140
+ this.selectedValues = [value];
141
+ }
142
+ }
143
+ #onUserInputChanged(value) {
144
+ if (value !== this.#lastUserInput) {
145
+ this.#lastUserInput = value;
146
+ const options = this.options;
147
+ this.filteredOptions = value
148
+ ? options.filter((opt) => this.#filterFn(value, opt))
149
+ : [...options];
150
+ this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions);
151
+ this.focusedValue = this.filteredOptions[this.#cursor]?.value;
152
+ if (!this.multiple) {
153
+ if (this.focusedValue !== undefined) {
154
+ this.toggleSelected(this.focusedValue);
155
+ }
156
+ else {
157
+ this.deselectAll();
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+ function getLabel(option) {
164
+ return option.label ?? String(option.value ?? '');
165
+ }
166
+ function getFilteredOption(searchText, option) {
167
+ if (!searchText)
168
+ return true;
169
+ const label = (option.label ?? String(option.value ?? '')).toLowerCase();
170
+ const hint = (option.hint ?? '').toLowerCase();
171
+ const value = String(option.value).toLowerCase();
172
+ const term = searchText.toLowerCase();
173
+ return label.includes(term) || hint.includes(term) || value.includes(term);
174
+ }
175
+ function getSelectedOptions(values, options) {
176
+ const results = [];
177
+ for (const option of options) {
178
+ if (values.includes(option.value)) {
179
+ results.push(option);
180
+ }
181
+ }
182
+ return results;
183
+ }
184
+ export const patchedAutocomplete = (opts) => {
185
+ const prompt = new PatchedAutocompletePrompt({
186
+ options: opts.options,
187
+ initialValue: opts.initialValue ? [opts.initialValue] : undefined,
188
+ initialUserInput: opts.initialUserInput,
189
+ filter: opts.filter ?? ((search, opt) => getFilteredOption(search, opt)),
190
+ signal: opts.signal,
191
+ input: opts.input,
192
+ output: opts.output,
193
+ validate: opts.validate,
194
+ render() {
195
+ const headings = [`${color.gray(S_BAR)}`, `${symbol(this.state)} ${opts.message}`];
196
+ const userInput = this.userInput;
197
+ const options = this.options;
198
+ const placeholder = opts.placeholder;
199
+ const showPlaceholder = userInput === '' && placeholder !== undefined;
200
+ switch (this.state) {
201
+ case 'submit': {
202
+ const selected = getSelectedOptions(this.selectedValues, options);
203
+ const label = selected.length > 0 ? ` ${color.dim(selected.map(getLabel).join(', '))}` : '';
204
+ return `${headings.join('\n')}\n${color.gray(S_BAR)}${label}`;
205
+ }
206
+ case 'cancel': {
207
+ const userInputText = userInput ? ` ${color.strikethrough(color.dim(userInput))}` : '';
208
+ return `${headings.join('\n')}\n${color.gray(S_BAR)}${userInputText}`;
209
+ }
210
+ default: {
211
+ const barColor = this.state === 'error' ? color.yellow : color.cyan;
212
+ const guidePrefix = `${barColor(S_BAR)} `;
213
+ const guidePrefixEnd = barColor(S_BAR_END);
214
+ let searchText = '';
215
+ if (this.isNavigating || showPlaceholder) {
216
+ const searchTextValue = showPlaceholder ? placeholder : userInput;
217
+ searchText = searchTextValue !== '' ? ` ${color.dim(searchTextValue)}` : '';
218
+ }
219
+ else {
220
+ searchText = ` ${this.userInputWithCursor}`;
221
+ }
222
+ const matches = this.filteredOptions.length !== options.length
223
+ ? color.dim(` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`)
224
+ : '';
225
+ const noResults = this.filteredOptions.length === 0 && userInput
226
+ ? [`${guidePrefix}${color.yellow('No matches found')}`]
227
+ : [];
228
+ const validationError = this.state === 'error' ? [`${guidePrefix}${color.yellow(this.error)}`] : [];
229
+ headings.push(`${guidePrefix.trimEnd()}`);
230
+ headings.push(`${guidePrefix}${color.dim('Search:')}${searchText}${matches}`, ...noResults, ...validationError);
231
+ const instructions = [
232
+ `${color.dim('↑/↓')} to select`,
233
+ `${color.dim('Enter:')} confirm`,
234
+ `${color.dim('Type:')} to search`,
235
+ ];
236
+ const footers = [`${guidePrefix}${instructions.join(' • ')}`, guidePrefixEnd];
237
+ const displayOptions = this.filteredOptions.length === 0
238
+ ? []
239
+ : limitOptions({
240
+ cursor: this.cursor,
241
+ options: this.filteredOptions,
242
+ columnPadding: 3,
243
+ rowPadding: headings.length + footers.length,
244
+ style: (option, active) => {
245
+ const label = getLabel(option);
246
+ const hint = option.hint && option.value === this.focusedValue
247
+ ? color.dim(` (${option.hint})`)
248
+ : '';
249
+ return active
250
+ ? `${color.green(S_RADIO_ACTIVE)} ${label}${hint}`
251
+ : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}${hint}`;
252
+ },
253
+ maxItems: opts.maxItems,
254
+ output: opts.output,
255
+ });
256
+ return [
257
+ ...headings,
258
+ ...displayOptions.map((option) => `${guidePrefix}${option}`),
259
+ ...footers,
260
+ ].join('\n');
261
+ }
262
+ }
263
+ },
264
+ });
265
+ return prompt.prompt();
266
+ };
@@ -0,0 +1,16 @@
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
+ import type { Prompter } from '@pokit/core';
11
+ import type { Screen } from '../screen.js';
12
+ /**
13
+ * Create a Prompter using @clack/prompts, sharing the terminal Screen.
14
+ */
15
+ export declare function createPrompter(screen: Screen): Prompter;
16
+ //# sourceMappingURL=prompter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prompter.d.ts","sourceRoot":"","sources":["../../src/prompter/prompter.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EACV,QAAQ,EAQT,MAAM,aAAa,CAAC;AAGrB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAoI3C;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,CA4GvD"}
@@ -0,0 +1,206 @@
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
+ import * as p from '@clack/prompts';
11
+ import { isDynamicOptions, CancelError } from '@pokit/core';
12
+ import { patchedAutocomplete } from './autocomplete-prompt.js';
13
+ /**
14
+ * Show error recovery prompt. Returns 'retry' or 'cancel'.
15
+ */
16
+ async function showErrorRecovery(errorMessage) {
17
+ const result = await p.select({
18
+ message: `Error: ${errorMessage}`,
19
+ options: [
20
+ { value: 'retry', label: 'Retry', hint: 'Try loading again' },
21
+ { value: 'cancel', label: 'Cancel', hint: 'Abort selection' },
22
+ ],
23
+ });
24
+ if (p.isCancel(result)) {
25
+ return 'cancel';
26
+ }
27
+ return result;
28
+ }
29
+ // =============================================================================
30
+ // Dynamic Select Implementation
31
+ // =============================================================================
32
+ /**
33
+ * Handle a dynamic select: load the option set via the provider (loading shown
34
+ * through the shared screen), then present them with type-ahead filtering.
35
+ */
36
+ async function handleDynamicSelect(dynamicOptions, screen) {
37
+ const loadingMessage = dynamicOptions.loadingMessage ?? 'Loading...';
38
+ let options;
39
+ try {
40
+ options = await screen.withLoading(loadingMessage, (signal) => dynamicOptions.provider(undefined, signal));
41
+ }
42
+ catch (error) {
43
+ const errorMessage = dynamicOptions.errorMessage ??
44
+ (error instanceof Error ? error.message : 'Failed to load options');
45
+ const action = await showErrorRecovery(errorMessage);
46
+ if (action === 'cancel') {
47
+ p.cancel('Cancelled');
48
+ throw new CancelError('Cancelled');
49
+ }
50
+ // Retry
51
+ return handleDynamicSelect(dynamicOptions, screen);
52
+ }
53
+ if (options.length === 0) {
54
+ p.cancel('No options available');
55
+ throw new CancelError('No options available');
56
+ }
57
+ const result = await patchedAutocomplete({
58
+ message: dynamicOptions.message,
59
+ options: options.map((opt) => ({
60
+ value: opt.value,
61
+ label: opt.label,
62
+ hint: opt.hint,
63
+ })),
64
+ });
65
+ if (p.isCancel(result)) {
66
+ throw new CancelError('Cancelled');
67
+ }
68
+ return result;
69
+ }
70
+ // =============================================================================
71
+ // Grouped Options Helpers
72
+ // =============================================================================
73
+ /**
74
+ * Organize flat options into a group → options record for clack's groupMultiselect.
75
+ * Options without a group are placed under an empty-string key.
76
+ */
77
+ function toGroupedRecord(options) {
78
+ const groups = {};
79
+ for (const opt of options) {
80
+ const key = opt.group ?? '';
81
+ if (!groups[key])
82
+ groups[key] = [];
83
+ groups[key].push({ value: opt.value, label: opt.label, hint: opt.hint });
84
+ }
85
+ return groups;
86
+ }
87
+ /**
88
+ * Sort options by group order (preserving insertion order of first occurrence)
89
+ * and add group name as label prefix for visual separation in a flat select.
90
+ */
91
+ function flattenGroupedForSelect(options) {
92
+ const groupOrder = [];
93
+ for (const opt of options) {
94
+ const key = opt.group ?? '';
95
+ if (!groupOrder.includes(key))
96
+ groupOrder.push(key);
97
+ }
98
+ const result = [];
99
+ for (const group of groupOrder) {
100
+ const groupOpts = options.filter((opt) => (opt.group ?? '') === group);
101
+ for (const opt of groupOpts) {
102
+ result.push({
103
+ value: opt.value,
104
+ label: group ? `${group} › ${opt.label}` : opt.label,
105
+ hint: opt.hint,
106
+ });
107
+ }
108
+ }
109
+ return result;
110
+ }
111
+ // =============================================================================
112
+ // Main Prompter Factory
113
+ // =============================================================================
114
+ /**
115
+ * Create a Prompter using @clack/prompts, sharing the terminal Screen.
116
+ */
117
+ export function createPrompter(screen) {
118
+ return {
119
+ async select(options) {
120
+ // Handle dynamic options via the shared screen
121
+ if (isDynamicOptions(options)) {
122
+ return handleDynamicSelect(options, screen);
123
+ }
124
+ // Static options - handle grouped or flat
125
+ const opts = options.options;
126
+ const clackOptions = opts.some((o) => o.group)
127
+ ? flattenGroupedForSelect(opts)
128
+ : opts.map((opt) => ({ value: opt.value, label: opt.label, hint: opt.hint }));
129
+ const result = await p.select({
130
+ message: options.message,
131
+ options: clackOptions,
132
+ initialValue: options.initialValue,
133
+ });
134
+ if (p.isCancel(result)) {
135
+ throw new CancelError('Cancelled');
136
+ }
137
+ return result;
138
+ },
139
+ async multiselect(options) {
140
+ // Use groupMultiselect when options have groups
141
+ const msOpts = options.options;
142
+ if (msOpts.some((o) => o.group)) {
143
+ const grouped = toGroupedRecord(msOpts);
144
+ const result = await p.groupMultiselect({
145
+ message: options.message,
146
+ options: grouped,
147
+ required: options.required,
148
+ });
149
+ if (p.isCancel(result)) {
150
+ throw new CancelError('Cancelled');
151
+ }
152
+ return result;
153
+ }
154
+ const result = await p.multiselect({
155
+ message: options.message,
156
+ options: options.options,
157
+ initialValues: options.initialValues,
158
+ required: options.required,
159
+ });
160
+ if (p.isCancel(result)) {
161
+ throw new CancelError('Cancelled');
162
+ }
163
+ return result;
164
+ },
165
+ async confirm(options) {
166
+ const result = await p.confirm({
167
+ message: options.message,
168
+ initialValue: options.initialValue,
169
+ });
170
+ if (p.isCancel(result)) {
171
+ throw new CancelError('Cancelled');
172
+ }
173
+ return result;
174
+ },
175
+ async text(options) {
176
+ const result = await p.text({
177
+ message: options.message,
178
+ placeholder: options.placeholder,
179
+ initialValue: options.initialValue,
180
+ validate: options.validate
181
+ ? (value) => options.validate(value ?? '')
182
+ : undefined,
183
+ });
184
+ if (p.isCancel(result)) {
185
+ throw new CancelError('Cancelled');
186
+ }
187
+ return result;
188
+ },
189
+ async autocomplete(options) {
190
+ const result = await patchedAutocomplete({
191
+ message: options.message,
192
+ options: options.options.map((opt) => ({
193
+ value: opt.value,
194
+ label: opt.label,
195
+ hint: opt.hint,
196
+ })),
197
+ placeholder: options.placeholder,
198
+ maxItems: options.maxItems,
199
+ });
200
+ if (p.isCancel(result)) {
201
+ throw new CancelError('Cancelled');
202
+ }
203
+ return result;
204
+ },
205
+ };
206
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Clack Reporter Adapter
3
+ *
4
+ * Implements the ReporterAdapter interface using @clack/prompts.
5
+ * Consumes CLI events from the EventBus and renders them to the terminal.
6
+ *
7
+ * Design principles:
8
+ * - Groups are visual containers with a bold title (intro) and completion indicator (outro)
9
+ * - Activities within sequential groups show as spinner items that complete with checkmarks
10
+ * - Parallel groups use a single spinner that tracks progress, showing completions as they finish
11
+ * - Logs during an active spinner will pause the spinner, show the log, then resume
12
+ * - Process output is never interleaved with spinners
13
+ *
14
+ * Rendering strategy:
15
+ * - group:start -> p.intro() with bold label (or line-based output)
16
+ * - group:end -> p.outro() with success indicator
17
+ * - activity:start (sequential) -> spinner.start() (or line-based output)
18
+ * - activity:start (parallel) -> track activity, update combined spinner
19
+ * - activity:success -> spinner.stop() with checkmark (code 0) or update combined spinner
20
+ * - activity:failure -> spinner.stop() with X (code 1)
21
+ * - activity:update -> spinner.message()
22
+ * - log -> pause spinner if active, p.log.*, resume spinner
23
+ *
24
+ * Non-interactive output (--no-tty/NO_TTY/CI):
25
+ * - Uses line-based output without spinners or clack decorative UI
26
+ * - Unicode symbols are controlled separately with --no-unicode/NO_UNICODE
27
+ * - Color is controlled separately with --no-color/NO_COLOR
28
+ */
29
+ import type { ReporterAdapter, OutputConfig } from '@pokit/core';
30
+ /**
31
+ * Options for the reporter adapter
32
+ */
33
+ export type ReporterAdapterOptions = {
34
+ /** When true, logs are displayed immediately instead of being buffered during spinners */
35
+ verbose?: boolean;
36
+ /** Output configuration (color, unicode, verbose settings) */
37
+ output?: OutputConfig;
38
+ };
39
+ /**
40
+ * Create a Clack-based ReporterAdapter
41
+ *
42
+ * @param options - Optional configuration for the adapter
43
+ */
44
+ export declare function createReporterAdapter(options?: ReporterAdapterOptions): ReporterAdapter;
45
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../src/reporter/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH,OAAO,KAAK,EACV,eAAe,EAQf,YAAY,EACb,MAAM,aAAa,CAAC;AAmNrB;;GAEG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,8DAA8D;IAC9D,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,CAAC,EAAE,sBAAsB,GAAG,eAAe,CAgevF"}