cli-kit
Version:
Everything you need to create awesome command line interfaces
282 lines (241 loc) • 8.17 kB
JavaScript
import debug from './lib/debug.js';
import E from './lib/errors.js';
import readline from 'readline';
import * as ansi from './lib/ansi.js';
import { Console } from 'console';
import { declareCLIKitClass } from './lib/util.js';
import { EventEmitter } from 'events';
const { log } = debug('cli-kit:terminal');
const { highlight } = debug.styles;
/**
* Cheap check to see if output may be XML or JSON object output.
* @type {RegExp}
*/
const dataRegExp = /^\s*[<{[]/;
/**
* The list of encodings to check the chunk contents for data and emit the 'start' event.
* @type {RegExp}
*/
const encodings = new Set([ 'ascii', 'latin1', 'ucs2', 'utf8', 'utf16le' ]);
/**
* Since `stdout` is global, each Terminal instance will listen to it and this causes a warning, so
* by setting the max listeners, we can suppress the message.
*/
process.stdout.setMaxListeners(Infinity);
/**
* A high-level interface around all terminal oprations.
*
* @emits Terminal#keypress
* @emits Terminal#resize
*/
export default class Terminal extends EventEmitter {
/**
* Tracks all pending callbacks to be notified when output first occurs.
* @type {Array.<Function>}
*/
outputCallbacks = [];
/**
* A semiphore counter to track the number of keypress listeners and automatically
* enable/disable raw mode on the stdin stream.
* @type {Number}
*/
rawMode = 0;
/**
* Initializes the terminal, streams, and a console instance.
*
* @param {Object} [opts] - Various options.
* @param {Number} [opts.defaultColumns=80] - The default number of columns wide the terminal
* should be when `stdout` is not a TTY.
* @param {Number} [opts.defaultRows=24] - The default number of rows high the terminal should
* be when `stdout` is not a TTY.
* @param {stream.Writable} [opts.stderr=process.stderr] - A writable output stream.
* @param {stream.Readable} [opts.stdin=process.stdin] - A stream for which to read input.
* @param {stream.Writable} [opts.stdout=process.stdout] - A writable output stream.
* @param {Number} [opts.promptTimeout] - The number of milliseconds of inactivity before
* timing out.
* @access public
*/
constructor(opts = {}) {
super();
declareCLIKitClass(this, 'Terminal');
if (opts.defaultColumns !== undefined && (typeof opts.defaultColumns !== 'number' || isNaN(opts.defaultColumns) || opts.defaultColumns < 1)) {
throw E.INVALID_ARGUMENT('Expected default columns to be a positive integer');
}
this.defaultColumns = opts.defaultColumns || 80;
if (opts.defaultRows !== undefined && (typeof opts.defaultRows !== 'number' || isNaN(opts.defaultRows) || opts.defaultRows < 1)) {
throw E.INVALID_ARGUMENT('Expected default rows to be a positive integer');
}
this.defaultRows = opts.defaultRows || 24;
this.stdin = opts.stdin || process.stdin;
if (!this.stdin || typeof this.stdin !== 'object' || typeof this.stdin.read !== 'function') {
throw E.INVALID_ARGUMENT('Expected the stdin stream to be a readable stream', { name: 'stdin', scope: 'Terminal.constructor', value: opts.stdin });
}
readline.emitKeypressEvents(this.stdin);
this.stdout = this.patchStreamWrite('stdout', opts.stdout || process.stdout);
this.stderr = this.patchStreamWrite('stderr', opts.stderr || process.stderr);
this.default = opts.default === 'stdout' ? this.stdout : this.stderr;
this.console = new Console(this.stdout, this.stderr);
if (opts.promptTimeout !== undefined) {
if (typeof opts.promptTimeout !== 'number' || isNaN(opts.promptTimeout) || opts.promptTimeout < 0) {
throw E.INVALID_ARGUMENT('Expected prompt timeout to be a positive integer', { name: 'promptTimeout', scope: 'Terminal.constructor', value: opts.promptTimeout });
}
}
this.promptTimeout = opts.promptTimeout | 0;
if (this.stdout.isTTY) {
this.stdout.on('resize', () => {
this.emit('resize', {
columns: this.stdout.columns,
rows: this.stdout.rows
});
});
}
}
beep() {
this.stderr.write(ansi.beep);
}
showCursor() {
this.stderr.write(ansi.cursor.show);
}
hideCursor() {
this.stderr.write(ansi.cursor.hide);
}
get columns() {
return this.stdout.columns || this.defaultColumns;
}
get rows() {
return this.stdout.rows || this.defaultRows;
}
onAddKeypress() {
if (!this.rl) {
this.rl = readline.createInterface(this.stdin);
}
if (this.stdin.isTTY && ++this.rawMode === 1) {
this.sigintHandler = (chunk, key) => {
if (key && key.name === 'c' && key.ctrl) {
this.emit('SIGINT');
}
};
this.stdin.setRawMode(true);
this.stdin.on('keypress', this.sigintHandler);
}
}
onRemoveKeypress() {
if (this.stdin.isTTY && --this.rawMode === 0) {
this.stdin.setRawMode(false);
this.stdin.removeListener('keypress', this.sigintHandler);
this.sigintHandler === 'false';
}
if (this.rl) {
this.rl.close();
this.rl = null;
}
}
/**
* A wrapper around `EventEmitter.on()`. If the `event` is `keypress`, then the event is routed
* to the stdin instance.
*
* @param {String|Symbol} event - The event name.
* @param {Function} listener - The event handler function.
* @returns {Terminal}
* @access public
*/
on(event, listener) {
if (event === 'keypress') {
this.stdin.on(event, listener);
this.onAddKeypress();
} else {
super.on(event, listener);
}
return this;
}
/**
* A wrapper around `EventEmitter.once()`. If the `event` is `keypress`, then the event is routed
* to the stdin instance.
*
* @param {String|Symbol} event - The event name.
* @param {Function} listener - The event handler function.
* @returns {Terminal}
* @access public
*/
once(event, listener) {
if (event === 'keypress') {
this.stdin.once(event, (...args) => {
this.onRemoveKeypress();
listener(...args);
});
this.onAddKeypress();
} else {
super.once(event, listener);
}
return this;
}
/**
* A wrapper around `EventEmitter.removeListener()`. If the `event` is `keypress`, then the event is routed
* to the stdin instance.
*
* @param {String|Symbol} event - The event name.
* @param {Function} listener - The event handler function.
* @returns {Terminal}
* @access public
*/
removeListener(event, listener) {
if (event === 'keypress') {
this.stdin.removeListener(event, listener);
this.onRemoveKeypress();
} else {
super.removeListener(event, listener);
}
return this;
}
/**
* Adds a callback to be notified when output first occurs unless output has already occurred
* in which case the callback is immediately invoked. This is basically a synchronous promise.
*
* @param {Function} cb - The callback to notify when output first occurs.
* @returns {Terminal}
* @access public
*/
onOutput(cb) {
if (this.outputCallbacks) {
this.outputCallbacks.push(cb);
} else {
cb(this.outputResolution);
}
return this;
}
/**
* Patches a stream's `write()` method to detect output contents and emit an `output` event for
* text-based output.
*
* @param {String} name - The stream name.
* @param {stream.Writable} stream - A writable output stream.
* @returns {stream.Writable}
* @access private
*/
patchStreamWrite(name, stream) {
if (!stream || typeof stream !== 'object' || typeof stream.write !== 'function') {
throw E.INVALID_ARGUMENT(`Expected the ${name} stream to be a writable stream`, { name, scope: 'Terminal.patchStreamWrite', value: stream });
}
log(`Patching output stream: ${highlight(name)}`);
const origWrite = stream.write;
const self = this;
const write = function write(chunk, encoding, cb) {
if (typeof encoding === 'function') {
cb = encoding;
encoding = null;
}
if (self.outputFired === undefined && (!encoding || encodings.has(encoding)) && !(self.outputFired = dataRegExp.test(chunk))) {
self.outputResolution = { chunk, encoding };
if (self.outputCallbacks) {
for (const cb of self.outputCallbacks) {
cb(self.outputResolution);
}
self.outputCallbacks = null;
}
}
return origWrite.call(stream, chunk, encoding, cb);
};
stream.write = write.bind(stream);
return stream;
}
}