@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 +21 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/prompter/autocomplete-prompt.d.ts +31 -0
- package/dist/prompter/autocomplete-prompt.d.ts.map +1 -0
- package/dist/prompter/autocomplete-prompt.js +266 -0
- package/dist/prompter/prompter.d.ts +16 -0
- package/dist/prompter/prompter.d.ts.map +1 -0
- package/dist/prompter/prompter.js +206 -0
- package/dist/reporter/adapter.d.ts +45 -0
- package/dist/reporter/adapter.d.ts.map +1 -0
- package/dist/reporter/adapter.js +599 -0
- package/dist/reporter/symbols.d.ts +50 -0
- package/dist/reporter/symbols.d.ts.map +1 -0
- package/dist/reporter/symbols.js +47 -0
- package/dist/screen.d.ts +22 -0
- package/dist/screen.d.ts.map +1 -0
- package/dist/screen.js +39 -0
- package/package.json +63 -0
- package/src/index.ts +63 -0
- package/src/prompter/autocomplete-prompt.ts +352 -0
- package/src/prompter/prompter.ts +267 -0
- package/src/reporter/adapter.ts +748 -0
- package/src/reporter/symbols.ts +78 -0
- package/src/screen.ts +53 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|