@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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol Sets for CLI Output
|
|
3
|
+
*
|
|
4
|
+
* Provides Unicode and ASCII symbol sets for terminal output.
|
|
5
|
+
* The appropriate set is selected based on OutputConfig.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Unicode symbols for rich terminal output
|
|
9
|
+
* Used when unicode support is detected
|
|
10
|
+
*/
|
|
11
|
+
export const UNICODE_SYMBOLS = {
|
|
12
|
+
success: '\u25C7', // ◇
|
|
13
|
+
error: '\u25A0', // ■
|
|
14
|
+
warning: '\u25B2', // ▲
|
|
15
|
+
info: '\u25CF', // ●
|
|
16
|
+
step: '\u25C7', // ◇
|
|
17
|
+
groupStart: '\u250C', // ┌
|
|
18
|
+
groupEnd: '\u2514', // └
|
|
19
|
+
groupLine: '\u2502', // │
|
|
20
|
+
done: '\u2714', // ✔
|
|
21
|
+
failed: '\u2718', // ✘
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* ASCII symbols for plain text output
|
|
25
|
+
* Used in CI environments or when unicode is not supported
|
|
26
|
+
*/
|
|
27
|
+
export const ASCII_SYMBOLS = {
|
|
28
|
+
success: '[OK]',
|
|
29
|
+
error: '[ERR]',
|
|
30
|
+
warning: '[WARN]',
|
|
31
|
+
info: '[INFO]',
|
|
32
|
+
step: '-',
|
|
33
|
+
groupStart: '[',
|
|
34
|
+
groupEnd: ']',
|
|
35
|
+
groupLine: '|',
|
|
36
|
+
done: 'Done',
|
|
37
|
+
failed: 'Failed',
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Get the appropriate symbol set based on output configuration
|
|
41
|
+
*
|
|
42
|
+
* @param config - Output configuration
|
|
43
|
+
* @returns Symbol set to use
|
|
44
|
+
*/
|
|
45
|
+
export function getSymbols(config) {
|
|
46
|
+
return config.unicode ? UNICODE_SYMBOLS : ASCII_SYMBOLS;
|
|
47
|
+
}
|
package/dist/screen.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared terminal screen state.
|
|
3
|
+
*
|
|
4
|
+
* The terminal UI is a single screen owner. Loading indicators (spinners) are
|
|
5
|
+
* created here rather than ad-hoc inside individual prompts, so the prompter
|
|
6
|
+
* does not spawn its own competing clack spinners for dynamic selects. The
|
|
7
|
+
* reporter adapter and the prompter both belong to the same terminal UI and
|
|
8
|
+
* share this screen.
|
|
9
|
+
*/
|
|
10
|
+
import type { OutputConfig } from '@pokit/core';
|
|
11
|
+
export interface Screen {
|
|
12
|
+
/**
|
|
13
|
+
* Run `fn` while showing a loading indicator. The indicator is the single
|
|
14
|
+
* screen-owned spinner; the prompter never creates its own.
|
|
15
|
+
*/
|
|
16
|
+
withLoading<T>(message: string, fn: (signal: AbortSignal) => Promise<T>): Promise<T>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create the shared screen for a terminal UI instance.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createScreen(outputConfig: OutputConfig): Screen;
|
|
22
|
+
//# sourceMappingURL=screen.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"screen.d.ts","sourceRoot":"","sources":["../src/screen.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,MAAM,WAAW,MAAM;IACrB;;;OAGG;IACH,WAAW,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CACtF;AAMD;;GAEG;AACH,wBAAgB,YAAY,CAAC,YAAY,EAAE,YAAY,GAAG,MAAM,CAwB/D"}
|
package/dist/screen.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared terminal screen state.
|
|
3
|
+
*
|
|
4
|
+
* The terminal UI is a single screen owner. Loading indicators (spinners) are
|
|
5
|
+
* created here rather than ad-hoc inside individual prompts, so the prompter
|
|
6
|
+
* does not spawn its own competing clack spinners for dynamic selects. The
|
|
7
|
+
* reporter adapter and the prompter both belong to the same terminal UI and
|
|
8
|
+
* share this screen.
|
|
9
|
+
*/
|
|
10
|
+
import * as p from '@clack/prompts';
|
|
11
|
+
function canUseInteractiveUI(outputConfig) {
|
|
12
|
+
return outputConfig.unicode && outputConfig.interactive;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Create the shared screen for a terminal UI instance.
|
|
16
|
+
*/
|
|
17
|
+
export function createScreen(outputConfig) {
|
|
18
|
+
const interactive = canUseInteractiveUI(outputConfig);
|
|
19
|
+
return {
|
|
20
|
+
async withLoading(message, fn) {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
if (!interactive) {
|
|
23
|
+
return fn(controller.signal);
|
|
24
|
+
}
|
|
25
|
+
const spinner = p.spinner();
|
|
26
|
+
spinner.start(message);
|
|
27
|
+
try {
|
|
28
|
+
const result = await fn(controller.signal);
|
|
29
|
+
spinner.stop(message);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
controller.abort();
|
|
34
|
+
spinner.stop('Failed to load');
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pokit/terminal",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Terminal UI (reporter + prompter + navigator) for pok CLI applications, built on clack",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"command-line",
|
|
8
|
+
"typescript",
|
|
9
|
+
"bun",
|
|
10
|
+
"terminal",
|
|
11
|
+
"pok",
|
|
12
|
+
"pokjs",
|
|
13
|
+
"clack",
|
|
14
|
+
"reporter",
|
|
15
|
+
"prompter",
|
|
16
|
+
"interactive"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/djgrant/pok.git",
|
|
23
|
+
"directory": "packages/terminal"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/djgrant/pok#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/djgrant/pok/issues"
|
|
28
|
+
},
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"bun": "./src/index.ts",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"import": "./dist/index.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"src"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@clack/core": "^1.0.0",
|
|
49
|
+
"@clack/prompts": "^1.0.0",
|
|
50
|
+
"picocolors": "^1.1.1"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/bun": "latest",
|
|
54
|
+
"@pokit/core": "0.2.0",
|
|
55
|
+
"@pokit/test-utils": "0.0.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@pokit/core": "0.2.0"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"bun": ">=1.0.0"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
|
|
10
|
+
import { detectOutputConfig, createMenuNavigator } from '@pokit/core';
|
|
11
|
+
import type {
|
|
12
|
+
OutputConfig,
|
|
13
|
+
ReporterAdapter,
|
|
14
|
+
Prompter,
|
|
15
|
+
Navigator,
|
|
16
|
+
} from '@pokit/core';
|
|
17
|
+
import { createReporterAdapter } from './reporter/adapter.js';
|
|
18
|
+
import { createPrompter } from './prompter/prompter.js';
|
|
19
|
+
import { createScreen } from './screen.js';
|
|
20
|
+
|
|
21
|
+
export type { OutputConfig, ReporterAdapter, Prompter, Navigator } from '@pokit/core';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Options for creating the terminal UI.
|
|
25
|
+
*/
|
|
26
|
+
export type TerminalUIOptions = {
|
|
27
|
+
/** When true, logs are displayed immediately instead of being buffered during spinners */
|
|
28
|
+
verbose?: boolean;
|
|
29
|
+
/** Output configuration (color, unicode, interactive). Detected from args/env when omitted. */
|
|
30
|
+
output?: OutputConfig;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The bundled terminal UI surfaces.
|
|
35
|
+
*/
|
|
36
|
+
export type TerminalUI = {
|
|
37
|
+
reporter: ReporterAdapter;
|
|
38
|
+
prompter: Prompter;
|
|
39
|
+
navigator: Navigator;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create the default terminal UI.
|
|
44
|
+
*
|
|
45
|
+
* Returns the reporter adapter, prompter, and navigator, all sharing a single
|
|
46
|
+
* screen so loading indicators and rendering are owned in one place.
|
|
47
|
+
*/
|
|
48
|
+
export function createTerminalUI(options?: TerminalUIOptions): TerminalUI {
|
|
49
|
+
const outputConfig: OutputConfig = options?.output ?? detectOutputConfig(process.argv.slice(2));
|
|
50
|
+
if (outputConfig.interactive === undefined) {
|
|
51
|
+
outputConfig.interactive = true;
|
|
52
|
+
}
|
|
53
|
+
if (options?.verbose !== undefined) {
|
|
54
|
+
outputConfig.verbose = options.verbose;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const screen = createScreen(outputConfig);
|
|
58
|
+
const reporter = createReporterAdapter({ output: outputConfig });
|
|
59
|
+
const prompter = createPrompter(screen);
|
|
60
|
+
const navigator = createMenuNavigator(prompter);
|
|
61
|
+
|
|
62
|
+
return { reporter, prompter, navigator };
|
|
63
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
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
|
+
|
|
11
|
+
import type { Key } from 'node:readline';
|
|
12
|
+
import { Prompt, type PromptOptions, settings } from '@clack/core';
|
|
13
|
+
import {
|
|
14
|
+
isCancel,
|
|
15
|
+
limitOptions,
|
|
16
|
+
symbol,
|
|
17
|
+
S_BAR,
|
|
18
|
+
S_BAR_END,
|
|
19
|
+
S_RADIO_ACTIVE,
|
|
20
|
+
S_RADIO_INACTIVE,
|
|
21
|
+
} from '@clack/prompts';
|
|
22
|
+
import type { Writable } from 'node:stream';
|
|
23
|
+
import color from 'picocolors';
|
|
24
|
+
|
|
25
|
+
interface OptionLike {
|
|
26
|
+
value: unknown;
|
|
27
|
+
label?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type FilterFunction<T extends OptionLike> = (search: string, opt: T) => boolean;
|
|
31
|
+
|
|
32
|
+
function getCursorForValue<T extends OptionLike>(
|
|
33
|
+
selected: T['value'] | undefined,
|
|
34
|
+
items: T[]
|
|
35
|
+
): number {
|
|
36
|
+
if (selected === undefined || items.length === 0) {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
const index = items.findIndex((item) => item.value === selected);
|
|
40
|
+
return index !== -1 ? index : 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultFilter<T extends OptionLike>(input: string, option: T): boolean {
|
|
44
|
+
const label = option.label ?? String(option.value);
|
|
45
|
+
return label.toLowerCase().includes(input.toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface AutocompletePromptOptions<T extends OptionLike> extends PromptOptions<
|
|
49
|
+
T['value'] | T['value'][],
|
|
50
|
+
PatchedAutocompletePrompt<T>
|
|
51
|
+
> {
|
|
52
|
+
options: T[] | ((this: PatchedAutocompletePrompt<T>) => T[]);
|
|
53
|
+
filter?: FilterFunction<T>;
|
|
54
|
+
multiple?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class PatchedAutocompletePrompt<T extends OptionLike> extends Prompt<T['value'] | T['value'][]> {
|
|
58
|
+
filteredOptions: T[];
|
|
59
|
+
multiple: boolean;
|
|
60
|
+
isNavigating = false;
|
|
61
|
+
selectedValues: Array<T['value']> = [];
|
|
62
|
+
focusedValue: T['value'] | undefined;
|
|
63
|
+
#cursor = 0;
|
|
64
|
+
#lastUserInput = '';
|
|
65
|
+
#filterFn: FilterFunction<T>;
|
|
66
|
+
#options: T[] | (() => T[]);
|
|
67
|
+
|
|
68
|
+
get cursor(): number {
|
|
69
|
+
return this.#cursor;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get userInputWithCursor() {
|
|
73
|
+
if (!this.userInput) {
|
|
74
|
+
return color.inverse(color.hidden('_'));
|
|
75
|
+
}
|
|
76
|
+
if (this._cursor >= this.userInput.length) {
|
|
77
|
+
return `${this.userInput}█`;
|
|
78
|
+
}
|
|
79
|
+
const s1 = this.userInput.slice(0, this._cursor);
|
|
80
|
+
const [s2, ...s3] = this.userInput.slice(this._cursor);
|
|
81
|
+
return `${s1}${color.inverse(s2)}${s3.join('')}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get options(): T[] {
|
|
85
|
+
if (typeof this.#options === 'function') {
|
|
86
|
+
return this.#options();
|
|
87
|
+
}
|
|
88
|
+
return this.#options;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
constructor(opts: AutocompletePromptOptions<T>) {
|
|
92
|
+
super(opts);
|
|
93
|
+
this.#options = opts.options;
|
|
94
|
+
const options = this.options;
|
|
95
|
+
this.filteredOptions = [...options];
|
|
96
|
+
this.multiple = opts.multiple === true;
|
|
97
|
+
this.#filterFn = opts.filter ?? defaultFilter;
|
|
98
|
+
|
|
99
|
+
let initialValues: unknown[] | undefined;
|
|
100
|
+
if (opts.initialValue && Array.isArray(opts.initialValue)) {
|
|
101
|
+
initialValues = this.multiple ? opts.initialValue : opts.initialValue.slice(0, 1);
|
|
102
|
+
} else if (!this.multiple && this.options.length > 0) {
|
|
103
|
+
initialValues = [this.options[0].value];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (initialValues) {
|
|
107
|
+
for (const selectedValue of initialValues) {
|
|
108
|
+
const selectedIndex = options.findIndex((opt) => opt.value === selectedValue);
|
|
109
|
+
if (selectedIndex !== -1) {
|
|
110
|
+
this.toggleSelected(selectedValue);
|
|
111
|
+
this.#cursor = selectedIndex;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.focusedValue = this.options[this.#cursor]?.value;
|
|
117
|
+
this.on('key', (char, key) => this.#onKey(char, key));
|
|
118
|
+
this.on('userInput', (value) => this.#onUserInputChanged(value));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
protected override _isActionKey(char: string | undefined, key: Key): boolean {
|
|
122
|
+
return (
|
|
123
|
+
char === '\t' ||
|
|
124
|
+
(this.multiple &&
|
|
125
|
+
this.isNavigating &&
|
|
126
|
+
key.name === 'space' &&
|
|
127
|
+
char !== undefined &&
|
|
128
|
+
char !== '')
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#onKey(_char: string | undefined, key: Key): void {
|
|
133
|
+
const isUpKey = key.name === 'up';
|
|
134
|
+
const isDownKey = key.name === 'down';
|
|
135
|
+
const isReturnKey = key.name === 'return';
|
|
136
|
+
|
|
137
|
+
if (isUpKey || isDownKey) {
|
|
138
|
+
const length = this.filteredOptions.length;
|
|
139
|
+
if (length > 0) {
|
|
140
|
+
this.#cursor = (((this.#cursor + (isUpKey ? -1 : 1)) % length) + length) % length;
|
|
141
|
+
}
|
|
142
|
+
this.focusedValue = this.filteredOptions[this.#cursor]?.value;
|
|
143
|
+
if (!this.multiple) {
|
|
144
|
+
this.selectedValues = [this.focusedValue];
|
|
145
|
+
}
|
|
146
|
+
this.isNavigating = true;
|
|
147
|
+
} else if (isReturnKey) {
|
|
148
|
+
this.value = this.multiple ? this.selectedValues : this.selectedValues[0];
|
|
149
|
+
} else {
|
|
150
|
+
if (this.multiple) {
|
|
151
|
+
if (
|
|
152
|
+
this.focusedValue !== undefined &&
|
|
153
|
+
(key.name === 'tab' || (this.isNavigating && key.name === 'space'))
|
|
154
|
+
) {
|
|
155
|
+
this.toggleSelected(this.focusedValue);
|
|
156
|
+
} else {
|
|
157
|
+
this.isNavigating = false;
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
if (this.focusedValue) {
|
|
161
|
+
this.selectedValues = [this.focusedValue];
|
|
162
|
+
}
|
|
163
|
+
this.isNavigating = false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
deselectAll() {
|
|
169
|
+
this.selectedValues = [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
toggleSelected(value: T['value']) {
|
|
173
|
+
if (this.filteredOptions.length === 0) return;
|
|
174
|
+
if (this.multiple) {
|
|
175
|
+
if (this.selectedValues.includes(value)) {
|
|
176
|
+
this.selectedValues = this.selectedValues.filter((v) => v !== value);
|
|
177
|
+
} else {
|
|
178
|
+
this.selectedValues = [...this.selectedValues, value];
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
this.selectedValues = [value];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#onUserInputChanged(value: string): void {
|
|
186
|
+
if (value !== this.#lastUserInput) {
|
|
187
|
+
this.#lastUserInput = value;
|
|
188
|
+
const options = this.options;
|
|
189
|
+
this.filteredOptions = value
|
|
190
|
+
? options.filter((opt) => this.#filterFn(value, opt))
|
|
191
|
+
: [...options];
|
|
192
|
+
this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions);
|
|
193
|
+
this.focusedValue = this.filteredOptions[this.#cursor]?.value;
|
|
194
|
+
if (!this.multiple) {
|
|
195
|
+
if (this.focusedValue !== undefined) {
|
|
196
|
+
this.toggleSelected(this.focusedValue);
|
|
197
|
+
} else {
|
|
198
|
+
this.deselectAll();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface Option<Value> {
|
|
206
|
+
value: Value;
|
|
207
|
+
label?: string;
|
|
208
|
+
hint?: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getLabel<T>(option: Option<T>) {
|
|
212
|
+
return option.label ?? String(option.value ?? '');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function getFilteredOption<T>(searchText: string, option: Option<T>): boolean {
|
|
216
|
+
if (!searchText) return true;
|
|
217
|
+
const label = (option.label ?? String(option.value ?? '')).toLowerCase();
|
|
218
|
+
const hint = (option.hint ?? '').toLowerCase();
|
|
219
|
+
const value = String(option.value).toLowerCase();
|
|
220
|
+
const term = searchText.toLowerCase();
|
|
221
|
+
return label.includes(term) || hint.includes(term) || value.includes(term);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getSelectedOptions<T>(values: T[], options: Option<T>[]): Option<T>[] {
|
|
225
|
+
const results: Option<T>[] = [];
|
|
226
|
+
for (const option of options) {
|
|
227
|
+
if (values.includes(option.value)) {
|
|
228
|
+
results.push(option);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return results;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface AutocompleteOpts<Value> {
|
|
235
|
+
message: string;
|
|
236
|
+
options: Option<Value>[];
|
|
237
|
+
maxItems?: number;
|
|
238
|
+
placeholder?: string;
|
|
239
|
+
initialValue?: Value;
|
|
240
|
+
initialUserInput?: string;
|
|
241
|
+
validate?: (value: Value | Value[] | undefined) => string | Error | undefined;
|
|
242
|
+
filter?: (search: string, option: Option<Value>) => boolean;
|
|
243
|
+
signal?: AbortSignal;
|
|
244
|
+
input?: import('node:stream').Readable;
|
|
245
|
+
output?: Writable;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const patchedAutocomplete = <Value>(opts: AutocompleteOpts<Value>) => {
|
|
249
|
+
const prompt = new PatchedAutocompletePrompt({
|
|
250
|
+
options: opts.options,
|
|
251
|
+
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
|
|
252
|
+
initialUserInput: opts.initialUserInput,
|
|
253
|
+
filter: opts.filter ?? ((search: string, opt: Option<Value>) => getFilteredOption(search, opt)),
|
|
254
|
+
signal: opts.signal,
|
|
255
|
+
input: opts.input,
|
|
256
|
+
output: opts.output,
|
|
257
|
+
validate: opts.validate,
|
|
258
|
+
render() {
|
|
259
|
+
const headings = [`${color.gray(S_BAR)}`, `${symbol(this.state)} ${opts.message}`];
|
|
260
|
+
const userInput = this.userInput;
|
|
261
|
+
const options = this.options;
|
|
262
|
+
const placeholder = opts.placeholder;
|
|
263
|
+
const showPlaceholder = userInput === '' && placeholder !== undefined;
|
|
264
|
+
|
|
265
|
+
switch (this.state) {
|
|
266
|
+
case 'submit': {
|
|
267
|
+
const selected = getSelectedOptions(this.selectedValues, options);
|
|
268
|
+
const label =
|
|
269
|
+
selected.length > 0 ? ` ${color.dim(selected.map(getLabel).join(', '))}` : '';
|
|
270
|
+
return `${headings.join('\n')}\n${color.gray(S_BAR)}${label}`;
|
|
271
|
+
}
|
|
272
|
+
case 'cancel': {
|
|
273
|
+
const userInputText = userInput ? ` ${color.strikethrough(color.dim(userInput))}` : '';
|
|
274
|
+
return `${headings.join('\n')}\n${color.gray(S_BAR)}${userInputText}`;
|
|
275
|
+
}
|
|
276
|
+
default: {
|
|
277
|
+
const barColor = this.state === 'error' ? color.yellow : color.cyan;
|
|
278
|
+
const guidePrefix = `${barColor(S_BAR)} `;
|
|
279
|
+
const guidePrefixEnd = barColor(S_BAR_END);
|
|
280
|
+
|
|
281
|
+
let searchText = '';
|
|
282
|
+
if (this.isNavigating || showPlaceholder) {
|
|
283
|
+
const searchTextValue = showPlaceholder ? placeholder : userInput;
|
|
284
|
+
searchText = searchTextValue !== '' ? ` ${color.dim(searchTextValue)}` : '';
|
|
285
|
+
} else {
|
|
286
|
+
searchText = ` ${this.userInputWithCursor}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const matches =
|
|
290
|
+
this.filteredOptions.length !== options.length
|
|
291
|
+
? color.dim(
|
|
292
|
+
` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`
|
|
293
|
+
)
|
|
294
|
+
: '';
|
|
295
|
+
|
|
296
|
+
const noResults =
|
|
297
|
+
this.filteredOptions.length === 0 && userInput
|
|
298
|
+
? [`${guidePrefix}${color.yellow('No matches found')}`]
|
|
299
|
+
: [];
|
|
300
|
+
|
|
301
|
+
const validationError =
|
|
302
|
+
this.state === 'error' ? [`${guidePrefix}${color.yellow(this.error)}`] : [];
|
|
303
|
+
|
|
304
|
+
headings.push(`${guidePrefix.trimEnd()}`);
|
|
305
|
+
headings.push(
|
|
306
|
+
`${guidePrefix}${color.dim('Search:')}${searchText}${matches}`,
|
|
307
|
+
...noResults,
|
|
308
|
+
...validationError
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const instructions = [
|
|
312
|
+
`${color.dim('↑/↓')} to select`,
|
|
313
|
+
`${color.dim('Enter:')} confirm`,
|
|
314
|
+
`${color.dim('Type:')} to search`,
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
const footers = [`${guidePrefix}${instructions.join(' • ')}`, guidePrefixEnd];
|
|
318
|
+
|
|
319
|
+
const displayOptions =
|
|
320
|
+
this.filteredOptions.length === 0
|
|
321
|
+
? []
|
|
322
|
+
: limitOptions({
|
|
323
|
+
cursor: this.cursor,
|
|
324
|
+
options: this.filteredOptions,
|
|
325
|
+
columnPadding: 3,
|
|
326
|
+
rowPadding: headings.length + footers.length,
|
|
327
|
+
style: (option: Option<Value>, active: boolean) => {
|
|
328
|
+
const label = getLabel(option);
|
|
329
|
+
const hint =
|
|
330
|
+
option.hint && option.value === this.focusedValue
|
|
331
|
+
? color.dim(` (${option.hint})`)
|
|
332
|
+
: '';
|
|
333
|
+
return active
|
|
334
|
+
? `${color.green(S_RADIO_ACTIVE)} ${label}${hint}`
|
|
335
|
+
: `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}${hint}`;
|
|
336
|
+
},
|
|
337
|
+
maxItems: opts.maxItems,
|
|
338
|
+
output: opts.output,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return [
|
|
342
|
+
...headings,
|
|
343
|
+
...displayOptions.map((option: string) => `${guidePrefix}${option}`),
|
|
344
|
+
...footers,
|
|
345
|
+
].join('\n');
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return prompt.prompt() as Promise<Value | symbol>;
|
|
352
|
+
};
|