Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/inquirerer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,34 @@ close(): void
exit(): void
```

#### Managing Multiple Instances

When working with multiple `Inquirerer` instances that share the same input stream (typically `process.stdin`), only one instance should be actively prompting at a time. Each instance attaches its own keyboard listener, so having multiple active instances will cause duplicate or unexpected keypress behavior.

**Best practices:**

1. **Reuse a single instance** - Create one `Inquirerer` instance and reuse it for all prompts:
```typescript
const prompter = new Inquirerer();

// Use the same instance for multiple prompt sessions
const answers1 = await prompter.prompt({}, questions1);
const answers2 = await prompter.prompt({}, questions2);

prompter.close(); // Clean up when done
```

2. **Close before creating another** - If you need separate instances, close the first before using the second:
```typescript
const prompter1 = new Inquirerer();
const answers1 = await prompter1.prompt({}, questions1);
prompter1.close(); // Important: close before creating another

const prompter2 = new Inquirerer();
const answers2 = await prompter2.prompt({}, questions2);
prompter2.close();
```

### Question Types

#### Text Question
Expand Down
166 changes: 34 additions & 132 deletions packages/inquirerer/src/keypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,79 +22,24 @@ export const KEY_CODES = {
BACKSPACE_LEGACY: '\x08' // For compatibility with some systems
};

interface SharedInputState {
dataHandler: (key: string) => void;
instances: Set<TerminalKeypress>;
activeStack: TerminalKeypress[]; // Stack of active instances, top is current owner
rawModeSet: boolean;
}

const sharedInputStates = new WeakMap<Readable, SharedInputState>();

function getOrCreateSharedState(input: Readable): SharedInputState {
let state = sharedInputStates.get(input);
if (!state) {
const dataHandler = (key: string) => {
const currentState = sharedInputStates.get(input);
if (!currentState) return;

// Only dispatch to the top of the active stack (current owner)
const owner = currentState.activeStack[currentState.activeStack.length - 1];
if (owner) {
owner.handleKey(key);

// Handle Ctrl+C via the current owner's process wrapper
if (key === KEY_CODES.CTRL_C) {
owner.exitProcess(0);
}
}
};

state = {
dataHandler,
instances: new Set(),
activeStack: [],
rawModeSet: false
};

sharedInputStates.set(input, state);
input.on('data', dataHandler);
}
return state;
}

function removeFromSharedState(input: Readable, instance: TerminalKeypress): void {
const state = sharedInputStates.get(input);
if (!state) return;

state.instances.delete(instance);

// Remove from active stack as well
const stackIndex = state.activeStack.indexOf(instance);
if (stackIndex !== -1) {
state.activeStack.splice(stackIndex, 1);
}

// If stack is now empty, disable raw mode
if (state.activeStack.length === 0 && state.rawModeSet) {
if (typeof (input as any).setRawMode === 'function') {
(input as any).setRawMode(false);
}
state.rawModeSet = false;
}

if (state.instances.size === 0) {
input.removeListener('data', state.dataHandler);
sharedInputStates.delete(input);
}
}

/**
* Handles keyboard input for interactive prompts.
*
* **Important**: Only one TerminalKeypress instance should be actively listening
* on a given input stream at a time. If you need multiple Inquirerer instances,
* call `close()` on the first instance before using the second, or reuse a single
* instance for all prompts.
*
* Multiple instances sharing the same input stream (e.g., process.stdin) will
* each receive all keypresses, which can cause duplicate or unexpected behavior.
*/
export class TerminalKeypress {
private listeners: Record<string, KeyHandler[]> = {};
private active: boolean = true;
private noTty: boolean;
private input: Readable;
private proc: ProcessWrapper;
private destroyed: boolean = false;
private dataHandler: ((key: string) => void) | null = null;

constructor(
noTty: boolean = false,
Expand All @@ -109,34 +54,23 @@ export class TerminalKeypress {
this.input.resume();
this.input.setEncoding('utf8');
}
this.registerWithSharedState();
}

private registerWithSharedState(): void {
const state = getOrCreateSharedState(this.input);
state.instances.add(this);
this.setupListeners();
}

isTTY() {
return !this.noTty;
}

isActive(): boolean {
if (this.destroyed) return false;
const state = sharedInputStates.get(this.input);
if (!state) return false;
// Active only if this instance is the current owner (top of stack)
return state.activeStack[state.activeStack.length - 1] === this;
}

exitProcess(code?: number): void {
this.proc.exit(code);
}

handleKey(key: string): void {
if (this.destroyed) return;
const handlers = this.listeners[key];
handlers?.forEach(handler => handler());
private setupListeners(): void {
this.dataHandler = (key: string) => {
if (!this.active) return;
const handlers = this.listeners[key];
handlers?.forEach(handler => handler());
if (key === KEY_CODES.CTRL_C) {
this.proc.exit(0);
}
};
this.input.on('data', this.dataHandler);
}

on(key: string, callback: KeyHandler): void {
Expand All @@ -160,57 +94,25 @@ export class TerminalKeypress {
}

pause(): void {
const state = sharedInputStates.get(this.input);
if (!state) return;

// Remove from active stack (from anywhere, not just top)
const stackIndex = state.activeStack.indexOf(this);
if (stackIndex !== -1) {
state.activeStack.splice(stackIndex, 1);
}

// If stack is now empty, disable raw mode
if (state.activeStack.length === 0 && state.rawModeSet) {
if (this.isTTY() && typeof (this.input as any).setRawMode === 'function') {
(this.input as any).setRawMode(false);
}
state.rawModeSet = false;
}
this.active = false;
this.clearHandlers();
}

resume(): void {
if (this.destroyed) return;

const state = sharedInputStates.get(this.input);
if (!state) return;

// Move-to-top semantics: remove from anywhere in stack, then push to top
const existingIndex = state.activeStack.indexOf(this);
if (existingIndex !== -1) {
state.activeStack.splice(existingIndex, 1);
}
state.activeStack.push(this);

// Enable raw mode if TTY
this.active = true;
if (this.isTTY() && typeof (this.input as any).setRawMode === 'function') {
(this.input as any).setRawMode(true);
state.rawModeSet = true;
}
}

destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.clearHandlers();

removeFromSharedState(this.input, this);

const state = sharedInputStates.get(this.input);
if (!state || state.instances.size === 0) {
if (typeof (this.input as any).setRawMode === 'function') {
(this.input as any).setRawMode(false);
}
this.input.pause();
if (typeof (this.input as any).setRawMode === 'function') {
(this.input as any).setRawMode(false);
}
this.input.pause();
if (this.dataHandler) {
this.input.removeListener('data', this.dataHandler);
this.dataHandler = null;
}
}
}