Skip to content

Commit 7bdc066

Browse files
committed
feat(inquirerer): add engine-based prompt implementations
- Modify UIEngine to accept existing TerminalKeypress instance - Add keypress, ownsKeypress, clearScreenOnStart options - Properly handle shared keypress lifecycle (pause vs destroy) - Let TerminalKeypress handle CTRL+C exit to avoid double-exit - Create engine-based prompt implementations in ui/prompts.ts - listPromptEngine: Simple list selection with UP/DOWN/ENTER - autocompletePromptEngine: Filtering with space as input character - checkboxPromptEngine: Filtering with space to toggle selection - All implementations match existing prompt behavior - Add demo script (dev:prompts) to test all three prompts sequentially - Verifies keypress lifecycle works correctly across prompts - Tests list, autocomplete, and checkbox in sequence This provides the foundation for refactoring existing prompts to use the UIEngine internally while maintaining backward compatibility.
1 parent 90b2c1b commit 7bdc066

5 files changed

Lines changed: 680 additions & 19 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* Demo: Engine-based Prompts
3+
*
4+
* This demo tests all three engine-based prompt implementations (list, autocomplete, checkbox)
5+
* running sequentially to verify keypress lifecycle and cleanup works correctly.
6+
*
7+
* Run with: pnpm dev:prompts
8+
*/
9+
10+
import { Inquirerer } from '../src';
11+
import {
12+
listPromptEngine,
13+
autocompletePromptEngine,
14+
checkboxPromptEngine,
15+
ListPromptConfig,
16+
AutocompletePromptConfig,
17+
CheckboxPromptConfig
18+
} from '../src/ui';
19+
import { TerminalKeypress } from '../src/keypress';
20+
import { white, green, cyan, yellow, dim } from 'yanse';
21+
22+
const COLORS = [
23+
{ name: 'Red', value: 'red' },
24+
{ name: 'Green', value: 'green' },
25+
{ name: 'Blue', value: 'blue' },
26+
{ name: 'Yellow', value: 'yellow' },
27+
{ name: 'Purple', value: 'purple' },
28+
{ name: 'Orange', value: 'orange' },
29+
{ name: 'Pink', value: 'pink' },
30+
{ name: 'Cyan', value: 'cyan' },
31+
];
32+
33+
const FRAMEWORKS = [
34+
{ name: 'React', value: 'react' },
35+
{ name: 'Vue', value: 'vue' },
36+
{ name: 'Angular', value: 'angular' },
37+
{ name: 'Svelte', value: 'svelte' },
38+
{ name: 'Next.js', value: 'nextjs' },
39+
{ name: 'Nuxt', value: 'nuxt' },
40+
{ name: 'Remix', value: 'remix' },
41+
{ name: 'Astro', value: 'astro' },
42+
{ name: 'SolidJS', value: 'solidjs' },
43+
{ name: 'Qwik', value: 'qwik' },
44+
];
45+
46+
const FEATURES = [
47+
{ name: 'TypeScript', value: 'typescript' },
48+
{ name: 'ESLint', value: 'eslint' },
49+
{ name: 'Prettier', value: 'prettier' },
50+
{ name: 'Testing', value: 'testing' },
51+
{ name: 'CI/CD', value: 'cicd' },
52+
{ name: 'Docker', value: 'docker' },
53+
{ name: 'Storybook', value: 'storybook' },
54+
{ name: 'PWA', value: 'pwa' },
55+
];
56+
57+
async function main() {
58+
console.log(white('\n=== Engine-based Prompts Demo ===\n'));
59+
console.log(dim('This demo tests all three prompt types using the new UIEngine.'));
60+
console.log(dim('Each prompt runs sequentially to verify keypress lifecycle.\n'));
61+
62+
// Create a shared keypress instance (simulating how Inquirerer works)
63+
const keypress = new TerminalKeypress(false, process.stdin);
64+
65+
try {
66+
// =========================================================================
67+
// Test 1: List Prompt (simplest - no filtering)
68+
// =========================================================================
69+
console.log(cyan('\n--- Test 1: List Prompt ---'));
70+
console.log(dim('Use UP/DOWN arrows to navigate, ENTER to select\n'));
71+
72+
const listConfig: ListPromptConfig = {
73+
options: COLORS,
74+
promptMessage: white('Pick your favorite color: '),
75+
maxLines: 5,
76+
keypress,
77+
input: process.stdin,
78+
output: process.stdout,
79+
noTty: false,
80+
};
81+
82+
const colorResult = await listPromptEngine(listConfig);
83+
console.log(green(`\nYou selected: ${colorResult}\n`));
84+
85+
// =========================================================================
86+
// Test 2: Autocomplete Prompt (filtering + space as input)
87+
// =========================================================================
88+
console.log(cyan('\n--- Test 2: Autocomplete Prompt ---'));
89+
console.log(dim('Type to filter, UP/DOWN to navigate, ENTER to select\n'));
90+
91+
const autocompleteConfig: AutocompletePromptConfig = {
92+
options: FRAMEWORKS,
93+
promptMessage: white('Choose a framework: '),
94+
maxLines: 6,
95+
keypress,
96+
input: process.stdin,
97+
output: process.stdout,
98+
noTty: false,
99+
};
100+
101+
const frameworkResult = await autocompletePromptEngine(autocompleteConfig);
102+
console.log(green(`\nYou selected: ${frameworkResult}\n`));
103+
104+
// =========================================================================
105+
// Test 3: Checkbox Prompt (filtering + space to toggle)
106+
// =========================================================================
107+
console.log(cyan('\n--- Test 3: Checkbox Prompt ---'));
108+
console.log(dim('Type to filter, UP/DOWN to navigate, SPACE to toggle, ENTER to confirm\n'));
109+
110+
const checkboxConfig: CheckboxPromptConfig = {
111+
options: FEATURES,
112+
defaultSelections: [true, true, false, false, false, false, false, false], // TypeScript and ESLint pre-selected
113+
promptMessage: white('Select features to include: '),
114+
maxLines: 6,
115+
returnFullResults: false,
116+
keypress,
117+
input: process.stdin,
118+
output: process.stdout,
119+
noTty: false,
120+
};
121+
122+
const featuresResult = await checkboxPromptEngine(checkboxConfig);
123+
console.log(green(`\nYou selected: ${featuresResult.map(f => f.name).join(', ')}\n`));
124+
125+
// =========================================================================
126+
// Summary
127+
// =========================================================================
128+
console.log(white('\n=== Summary ==='));
129+
console.log(`Color: ${yellow(colorResult)}`);
130+
console.log(`Framework: ${yellow(frameworkResult)}`);
131+
console.log(`Features: ${yellow(featuresResult.map(f => f.name).join(', '))}`);
132+
console.log(green('\nAll prompts completed successfully!'));
133+
console.log(dim('Keypress lifecycle worked correctly across all three prompts.\n'));
134+
135+
} finally {
136+
// Cleanup
137+
keypress.destroy();
138+
}
139+
140+
process.exit(0);
141+
}
142+
143+
main().catch(err => {
144+
console.error('Error:', err);
145+
process.exit(1);
146+
});

packages/inquirerer/package.json

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,19 @@
1919
"bugs": {
2020
"url": "https://github.com/constructive-io/dev-utils/issues"
2121
},
22-
"scripts": {
23-
"copy": "makage assets",
24-
"clean": "makage clean",
25-
"prepublishOnly": "npm run build",
26-
"build": "makage build",
27-
"dev": "ts-node dev/index",
28-
"dev:spinner": "ts-node dev/demo-spinner",
29-
"dev:chat": "ts-node dev/demo-chat",
30-
"dev:upgrade": "ts-node dev/demo-upgrade",
31-
"test": "jest",
32-
"test:watch": "jest --watch"
33-
},
22+
"scripts": {
23+
"copy": "makage assets",
24+
"clean": "makage clean",
25+
"prepublishOnly": "npm run build",
26+
"build": "makage build",
27+
"dev": "ts-node dev/index",
28+
"dev:spinner": "ts-node dev/demo-spinner",
29+
"dev:chat": "ts-node dev/demo-chat",
30+
"dev:upgrade": "ts-node dev/demo-upgrade",
31+
"dev:prompts": "ts-node dev/demo-prompts-engine",
32+
"test": "jest",
33+
"test:watch": "jest --watch"
34+
},
3435
"dependencies": {
3536
"deepmerge": "^4.3.1",
3637
"find-and-require-package-json": "workspace:*",

packages/inquirerer/src/ui/engine.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,31 @@ export interface UIEngineOptions {
4646
input?: Readable;
4747
output?: Writable;
4848
noTty?: boolean;
49+
/** Existing keypress instance to reuse (avoids multiple listeners on stdin) */
50+
keypress?: TerminalKeypress;
51+
/** If true, engine owns the keypress and will destroy it on cleanup. Default: true if keypress not provided */
52+
ownsKeypress?: boolean;
53+
/** If true, clear the entire screen on start instead of just overwriting lines */
54+
clearScreenOnStart?: boolean;
4955
}
5056

5157
export class UIEngine {
5258
private input: Readable;
5359
private output: Writable;
5460
private noTty: boolean;
5561
private keypress: TerminalKeypress | null = null;
62+
private ownsKeypress: boolean;
63+
private clearScreenOnStart: boolean;
5664
private tickTimer: NodeJS.Timeout | null = null;
5765
private lastLineCount: number = 0;
5866

5967
constructor(options: UIEngineOptions = {}) {
6068
this.input = options.input ?? process.stdin;
6169
this.output = options.output ?? process.stdout;
6270
this.noTty = options.noTty ?? false;
71+
this.keypress = options.keypress ?? null;
72+
this.ownsKeypress = options.ownsKeypress ?? (options.keypress === undefined);
73+
this.clearScreenOnStart = options.clearScreenOnStart ?? false;
6374
}
6475

6576
/**
@@ -130,11 +141,19 @@ export class UIEngine {
130141
let state = config.initialState;
131142
let resolved = false;
132143
let result: V | undefined;
144+
const createdKeypress = !this.keypress;
133145

134-
// Setup keypress handler
135-
this.keypress = new TerminalKeypress(this.noTty, this.input);
146+
// Setup keypress handler - reuse existing or create new
147+
if (!this.keypress) {
148+
this.keypress = new TerminalKeypress(this.noTty, this.input);
149+
}
136150
this.keypress.resume();
137151

152+
// Clear screen on start if requested (matches legacy prompt behavior)
153+
if (this.clearScreenOnStart) {
154+
this.clearScreen();
155+
}
156+
138157
// Hide cursor if requested
139158
if (config.hideCursor) {
140159
this.hideCursor();
@@ -186,9 +205,14 @@ export class UIEngine {
186205
this.tickTimer = null;
187206
}
188207

189-
// Cleanup keypress
208+
// Cleanup keypress - pause clears handlers, which is what we want
209+
// Only destroy if we created it and own it
190210
if (this.keypress) {
191211
this.keypress.pause();
212+
if (createdKeypress && this.ownsKeypress) {
213+
this.keypress.destroy();
214+
this.keypress = null;
215+
}
192216
}
193217

194218
// Show cursor
@@ -201,19 +225,20 @@ export class UIEngine {
201225
};
202226

203227
// Register key handlers
204-
// Handle special keys
228+
// Handle special keys - but let TerminalKeypress handle CTRL+C exit
205229
Object.entries(KEY_MAP).forEach(([code, key]) => {
206230
this.keypress!.on(code, () => {
231+
// For CTRL+C, just cleanup - TerminalKeypress will call process.exit
207232
if (key === Key.CTRL_C) {
208233
cleanup();
209-
process.exit(0);
234+
return;
210235
}
211236
handleEvent({ type: 'key', key });
212237
});
213238
});
214239

215-
// Handle alphanumeric characters
216-
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.split('').forEach(char => {
240+
// Handle alphanumeric characters (lowercase only to match legacy behavior)
241+
'abcdefghijklmnopqrstuvwxyz0123456789'.split('').forEach(char => {
217242
this.keypress!.on(char, () => {
218243
handleEvent({ type: 'char', char });
219244
});

packages/inquirerer/src/ui/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,15 @@ export { Spinner, createSpinner, SPINNER_STYLES } from './spinner';
2525
export { ProgressBar, createProgress } from './progress';
2626
export { StreamingText, createStream } from './stream';
2727
export { interactiveUpgrade, upgradePrompt } from './upgrade';
28+
29+
// Engine-based prompt implementations (internal use)
30+
export {
31+
listPromptEngine,
32+
autocompletePromptEngine,
33+
checkboxPromptEngine,
34+
filterOptions,
35+
renderPromptHeader,
36+
ListPromptConfig,
37+
AutocompletePromptConfig,
38+
CheckboxPromptConfig,
39+
} from './prompts';

0 commit comments

Comments
 (0)