diff --git a/.changeset/lucky-pandas-listen.md b/.changeset/lucky-pandas-listen.md new file mode 100644 index 00000000..1860b984 --- /dev/null +++ b/.changeset/lucky-pandas-listen.md @@ -0,0 +1,5 @@ +--- +"@clack/core": patch +--- + +Settle a prompt as cancelled when its input ends, instead of leaving the promise pending with the cursor hidden. diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index d1c61961..fa7871e9 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -177,6 +177,11 @@ export default class Prompt { } this.input.on('keypress', this.onKeypress); + // An input stream that ends (closed stdin, a pipe that ran out, an agent + // shell) leaves nothing to read, so the prompt has to settle the way Ctrl-C + // does rather than keep the promise pending with the cursor hidden. + this.input.on('end', this.onInputEnd); + this.input.on('close', this.onInputEnd); setRawMode(this.input, true); this.output.on('resize', this.render); @@ -194,6 +199,11 @@ export default class Prompt { setRawMode(this.input, false); resolve(CANCEL_SYMBOL); }); + + // An input that ended before the prompt started has no event left to fire. + if (this.input.readableEnded || this.input.destroyed) { + this.onInputEnd(); + } }); } @@ -224,6 +234,16 @@ export default class Prompt { this._setUserInput(''); } + private onInputEnd = (): void => { + // `close()` clears the interface; a second end event (the stream is shared + // between prompts) must not reopen a settled prompt. + if (this.rl === undefined) { + return; + } + this.state = 'cancel'; + this.close(); + }; + private async onKeypress(char: string | undefined, key: Key) { if (this.state === 'validating') { return; @@ -263,6 +283,12 @@ export default class Prompt { this.state = 'validating'; this.render(); problem = await problemResult; + // The prompt can close while an async validator runs (the abort + // signal, or the input ending): a closed prompt must not then emit + // a submit for a value nobody read. + if (this.rl === undefined) { + return; + } } else { problem = problemResult; } @@ -293,6 +319,8 @@ export default class Prompt { protected close() { this.input.unpipe(); this.input.removeListener('keypress', this.onKeypress); + this.input.removeListener('end', this.onInputEnd); + this.input.removeListener('close', this.onInputEnd); this.output.write('\n'); setRawMode(this.input, false); this.rl?.close(); diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts index 50c1ccda..c792f2c7 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/test/prompts/prompt.test.ts @@ -1,4 +1,5 @@ import { type } from 'arktype'; +import { Readable } from 'node:stream'; import { cursor } from 'sisteransi'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { default as Prompt } from '../../src/prompts/prompt.js'; @@ -475,3 +476,49 @@ describe('Prompt', () => { expect(instance.error).to.equal(''); }); }); + +describe('Prompt with an input that ends', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + test('cancels when the input ends while it is waiting', async () => { + const instance = new Prompt({ input, output, render: () => 'foo' }); + const resultPromise = instance.prompt(); + input.emit('end'); + const result = await resultPromise; + expect(isCancel(result)).to.equal(true); + expect(instance.state).to.equal('cancel'); + expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); + }); + + test('cancels when the input already ended before it starts', async () => { + // A stream that has finished emits `end` once; a prompt attached afterwards + // never sees it, which is the "stdin ended before the prompt started" case. + const ended = new Readable({ read() {} }); + const endEvent = new Promise((resolve) => ended.once('end', () => resolve())); + ended.push(null); + ended.resume(); + await endEvent; + expect(ended.readableEnded).to.equal(true); + + const instance = new Prompt({ input: ended, output, render: () => 'foo' }); + const result = await instance.prompt(); + expect(isCancel(result)).to.equal(true); + expect(instance.state).to.equal('cancel'); + }); + + test('keeps the submitted value when the input ends after settling', async () => { + const instance = new Prompt({ input, output, render: () => 'foo' }); + const resultPromise = instance.prompt(); + input.emit('keypress', '', { name: 'return' }); + const result = await resultPromise; + expect(isCancel(result)).to.equal(false); + input.emit('end'); + expect(instance.state).to.equal('submit'); + }); +});