Skip to content

Commit

Permalink
feature: add abort controller (#216)
Browse files Browse the repository at this point in the history
Co-authored-by: jacobparis <[email protected]>
Co-authored-by: Nate Moore <[email protected]>
Co-authored-by: Nate Moore <[email protected]>
  • Loading branch information
4 people authored Dec 19, 2024
1 parent 4c98bd2 commit 801246b
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 7 deletions.
38 changes: 38 additions & 0 deletions .changeset/lucky-maps-beam.md
Original file line number Diff line number Diff line change
@@ -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()
```
32 changes: 25 additions & 7 deletions packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ export interface PromptOptions<Self extends Prompt> {
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<PromptOptions<Prompt>, 'render' | 'input' | 'output'>;
private _render: (context: Omit<Prompt, 'prompt'>) => string | undefined;
private _track = false;
Expand All @@ -36,14 +38,15 @@ export default class Prompt {
public value: any;

constructor(options: PromptOptions<Prompt>, 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);
this.close = this.close.bind(this);
this.render = this.render.bind(this);
this._render = render.bind(this);
this._track = trackValue;
this._abortSignal = signal;

this.input = input;
this.output = output;
Expand Down Expand Up @@ -111,11 +114,25 @@ export default class Prompt {

public prompt() {
return new Promise<string | symbol>((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();
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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') {
Expand All @@ -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();
}
Expand Down
29 changes: 29 additions & 0 deletions packages/core/test/prompts/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

0 comments on commit 801246b

Please sign in to comment.