Skip to content

Commit dfdee5a

Browse files
authored
fix(prompt): preserve buffered input between prompts (#118)
Co-authored-by: cmdr-chara <249489759+cmdr-chara@users.noreply.github.com>
1 parent 5e0f7c4 commit dfdee5a

2 files changed

Lines changed: 82 additions & 6 deletions

File tree

src/lib/prompt.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,31 @@ describe('promptText', () => {
3939
expect(await promptText('? ', { input, output })).toBe('ok');
4040
});
4141

42+
it('preserves buffered answers for sequential prompts on the same stream', async () => {
43+
const input = Readable.from(['first\nsecond\nthird\n']);
44+
const output = new CaptureStream();
45+
46+
await expect(promptText('One: ', { input, output })).resolves.toBe('first');
47+
await expect(promptText('Two: ', { input, output })).resolves.toBe('second');
48+
await expect(promptText('Three: ', { input, output })).resolves.toBe('third');
49+
});
50+
51+
it('uses buffered tail input at EOF for a following prompt', async () => {
52+
const input = Readable.from(['first\nsecond']);
53+
const output = new CaptureStream();
54+
55+
await expect(promptText('One: ', { input, output })).resolves.toBe('first');
56+
await expect(promptText('Two: ', { input, output })).resolves.toBe('second');
57+
});
58+
59+
it('preserves buffered CRLF answers for sequential prompts', async () => {
60+
const input = Readable.from(['first\r\nsecond\r\n']);
61+
const output = new CaptureStream();
62+
63+
await expect(promptText('One: ', { input, output })).resolves.toBe('first');
64+
await expect(promptText('Two: ', { input, output })).resolves.toBe('second');
65+
});
66+
4267
it('returns the buffered input on stream end without newline', async () => {
4368
const input = Readable.from(['eof-no-newline']);
4469
const output = new CaptureStream();
@@ -70,6 +95,16 @@ describe('promptSecret (non-TTY behavior)', () => {
7095
expect(await promptSecret('? ', { input, output })).toBe('abd');
7196
});
7297

98+
it('preserves buffered secret answers for sequential prompts', async () => {
99+
const input = Readable.from(['sk-one\nsk-two\n']);
100+
const output = new CaptureStream();
101+
102+
await expect(promptSecret('First key: ', { input, output })).resolves.toBe('sk-one');
103+
await expect(promptSecret('Second key: ', { input, output })).resolves.toBe('sk-two');
104+
expect(output.text()).not.toContain('sk-one');
105+
expect(output.text()).not.toContain('sk-two');
106+
});
107+
73108
it('rejects on Ctrl-C input', async () => {
74109
const ETX = String.fromCharCode(0x03);
75110
const input = Readable.from([`abc${ETX}`]);

src/lib/prompt.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ interface RawModeCapable {
1111
resume?: () => unknown;
1212
}
1313

14+
const pendingPromptInput = new WeakMap<NodeJS.ReadableStream, string>();
15+
1416
export async function promptText(question: string, streams: PromptStreams = {}): Promise<string> {
1517
const input = streams.input ?? process.stdin;
1618
const output = streams.output ?? process.stdout;
@@ -41,18 +43,29 @@ function readLine(
4143
return new Promise<string>((resolve, reject) => {
4244
let buffer = '';
4345
let resolved = false;
46+
let listening = false;
4447

4548
const onData = (chunk: Buffer | string): void => {
4649
const str = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
47-
for (const ch of str) {
48-
const code = ch.charCodeAt(0);
50+
processText(str);
51+
};
52+
53+
const processText = (str: string): void => {
54+
for (let i = 0; i < str.length; i += 1) {
55+
const code = str.charCodeAt(i);
4956
// Enter (CR or LF)
5057
if (code === 13 || code === 10) {
58+
let nextIndex = i + 1;
59+
if (code === 13 && str.charCodeAt(nextIndex) === 10) {
60+
nextIndex += 1;
61+
}
62+
savePendingInput(str.slice(nextIndex));
5163
finish();
5264
return;
5365
}
5466
// Ctrl-C
5567
if (code === 3) {
68+
savePendingInput('');
5669
cleanup();
5770
output.write('\n');
5871
if (!resolved) {
@@ -71,7 +84,7 @@ function readLine(
7184
}
7285
// Drop other control chars
7386
if (code < 32) continue;
74-
buffer += ch;
87+
buffer += str[i];
7588
if (mask) output.write('*');
7689
}
7790
};
@@ -89,9 +102,12 @@ function readLine(
89102
};
90103

91104
const cleanup = (): void => {
92-
input.off('data', onData);
93-
input.off('end', onEnd);
94-
input.off('error', onError);
105+
if (listening) {
106+
input.off('data', onData);
107+
input.off('end', onEnd);
108+
input.off('error', onError);
109+
listening = false;
110+
}
95111
const pausable = input as { pause?: () => unknown };
96112
if (typeof pausable.pause === 'function') pausable.pause();
97113
};
@@ -105,12 +121,37 @@ function readLine(
105121
}
106122
};
107123

124+
const savePendingInput = (text: string): void => {
125+
if (text.length > 0) {
126+
pendingPromptInput.set(input, text);
127+
} else {
128+
pendingPromptInput.delete(input);
129+
}
130+
};
131+
132+
const pending = pendingPromptInput.get(input);
133+
if (pending !== undefined) {
134+
pendingPromptInput.delete(input);
135+
processText(pending);
136+
if (resolved) return;
137+
if (isInputEnded(input)) {
138+
finish();
139+
return;
140+
}
141+
}
142+
108143
input.on('data', onData);
109144
input.on('end', onEnd);
110145
input.on('error', onError);
146+
listening = true;
111147
const resumable = input as { resume?: () => unknown };
112148
if (typeof resumable.resume === 'function') resumable.resume();
113149
});
114150
}
115151

152+
function isInputEnded(input: NodeJS.ReadableStream): boolean {
153+
const state = input as { readableEnded?: boolean; destroyed?: boolean; closed?: boolean };
154+
return state.readableEnded === true || state.destroyed === true || state.closed === true;
155+
}
156+
116157
export type { Writable };

0 commit comments

Comments
 (0)