diff --git a/.changeset/lucky-maps-beam.md b/.changeset/lucky-maps-beam.md new file mode 100644 index 00000000..2f50404f --- /dev/null +++ b/.changeset/lucky-maps-beam.md @@ -0,0 +1,38 @@ +--- +"@clack/core": minor +"@clack/prompts": minor +--- + +Adds a new `signal` option to support programmatic prompt cancellation with an [abort controller](https://kettanaito.com/blog/dont-sleep-on-abort-controller). + +One example use case is automatically cancelling a prompt after a timeout. + +```ts +const shouldContinue = await confirm({ + message: 'This message will self destruct in 5 seconds', + signal: AbortSignal.timeout(5000), +}); +``` + +Another use case is racing a long running task with a manual prompt. + +```ts +const abortController = new AbortController() + +const projectType = await Promise.race([ + detectProjectType({ + signal: abortController.signal + }), + select({ + message: 'Pick a project type.', + options: [ + { value: 'ts', label: 'TypeScript' }, + { value: 'js', label: 'JavaScript' }, + { value: 'coffee', label: 'CoffeeScript', hint: 'oh no'}, + ], + signal: abortController.signal, + }) +]) + +abortController.abort() +``` diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index f2c85771..01da83ba 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -17,13 +17,15 @@ export interface PromptOptions { input?: Readable; output?: Writable; debug?: boolean; + signal?: AbortSignal; } export default class Prompt { protected input: Readable; protected output: Writable; + private _abortSignal?: AbortSignal; - private rl!: ReadLine; + private rl: ReadLine | undefined; private opts: Omit, 'render' | 'input' | 'output'>; private _render: (context: Omit) => string | undefined; private _track = false; @@ -36,7 +38,7 @@ export default class Prompt { public value: any; constructor(options: PromptOptions, trackValue = true) { - const { input = stdin, output = stdout, render, ...opts } = options; + const { input = stdin, output = stdout, render, signal, ...opts } = options; this.opts = opts; this.onKeypress = this.onKeypress.bind(this); @@ -44,6 +46,7 @@ export default class Prompt { this.render = this.render.bind(this); this._render = render.bind(this); this._track = trackValue; + this._abortSignal = signal; this.input = input; this.output = output; @@ -111,11 +114,25 @@ export default class Prompt { public prompt() { return new Promise((resolve, reject) => { + if (this._abortSignal) { + if (this._abortSignal.aborted) { + this.state = 'cancel'; + + this.close(); + return resolve(CANCEL_SYMBOL); + } + + this._abortSignal.addEventListener('abort', () => { + this.state = 'cancel'; + this.close(); + }, { once: true }); + } + const sink = new WriteStream(0); sink._write = (chunk, encoding, done) => { if (this._track) { - this.value = this.rl.line.replace(/\t/g, ''); - this._cursor = this.rl.cursor; + this.value = this.rl?.line.replace(/\t/g, ''); + this._cursor = this.rl?.cursor ?? 0; this.emit('value', this.value); } done(); @@ -171,7 +188,7 @@ export default class Prompt { } if (char === '\t' && this.opts.placeholder) { if (!this.value) { - this.rl.write(this.opts.placeholder); + this.rl?.write(this.opts.placeholder); this.emit('value', this.opts.placeholder); } } @@ -185,7 +202,7 @@ export default class Prompt { if (problem) { this.error = problem; this.state = 'error'; - this.rl.write(this.value); + this.rl?.write(this.value); } } if (this.state !== 'error') { @@ -210,7 +227,8 @@ export default class Prompt { this.input.removeListener('keypress', this.onKeypress); this.output.write('\n'); setRawMode(this.input, false); - this.rl.close(); + this.rl?.close(); + this.rl = undefined; this.emit(`${this.state}`, this.value); this.unsubscribe(); } diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts index 0f976c34..44318c88 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/test/prompts/prompt.test.ts @@ -229,4 +229,33 @@ describe('Prompt', () => { expect(eventSpy).toBeCalledWith(key); } }); + + test('aborts on abort signal', () => { + const abortController = new AbortController(); + + const instance = new Prompt({ + input, + output, + render: () => 'foo', + signal: abortController.signal, + }); + + instance.prompt(); + + expect(instance.state).to.equal('active'); + + abortController.abort(); + + expect(instance.state).to.equal('cancel'); + }); + + test('returns immediately if signal is already aborted', () => { + const abortController = new AbortController(); + abortController.abort(); + + const instance = new Prompt({ input, output, render: () => 'foo', signal: abortController.signal }); + instance.prompt(); + + expect(instance.state).to.equal('cancel'); + }); });