prompt-sync-plus
Version:
Yet another synchronous prompt for Node.js
926 lines (916 loc) • 41.4 kB
JavaScript
import fs from 'fs';
import stripAnsi from 'strip-ansi';
import Joi from 'joi';
import { table, getBorderCharacters } from 'table';
var AutocompleteBehavior;
(function (AutocompleteBehavior) {
// completes the input by cycling through possible completions on each TAB hit
AutocompleteBehavior["CYCLE"] = "cycle";
// completes the input only up to the longest shared substring among all possible
// completions, and prints a suggestion table
AutocompleteBehavior["HYBRID"] = "hybrid";
// leaves input as-is and displays a table of suggestions
AutocompleteBehavior["SUGGEST"] = "suggest";
})(AutocompleteBehavior || (AutocompleteBehavior = {}));
var Key;
(function (Key) {
Key[Key["SIGINT"] = 3] = "SIGINT";
Key[Key["EOT"] = 4] = "EOT";
Key[Key["WIN_BACKSPACE"] = 8] = "WIN_BACKSPACE";
Key[Key["TAB"] = 9] = "TAB";
Key[Key["ENTER"] = 13] = "ENTER";
Key[Key["SHIFT"] = 16] = "SHIFT";
Key[Key["CTRL"] = 17] = "CTRL";
Key[Key["ALT"] = 18] = "ALT";
Key[Key["PAUSE_BREAK"] = 19] = "PAUSE_BREAK";
Key[Key["CAPS_LOCK"] = 20] = "CAPS_LOCK";
Key[Key["ESCAPE"] = 27] = "ESCAPE";
Key[Key["EXCLAMATION_POINT"] = 33] = "EXCLAMATION_POINT";
Key[Key["SPACE"] = 32] = "SPACE";
Key[Key["DOUBLE_QUOTE"] = 34] = "DOUBLE_QUOTE";
Key[Key["POUND"] = 35] = "POUND";
Key[Key["DOLLAR"] = 36] = "DOLLAR";
Key[Key["PERCENT"] = 37] = "PERCENT";
Key[Key["AMPERSAND"] = 38] = "AMPERSAND";
Key[Key["SINGLE_QUOTE"] = 39] = "SINGLE_QUOTE";
Key[Key["LEFT_PAREN"] = 40] = "LEFT_PAREN";
Key[Key["RIGHT_PAREN"] = 41] = "RIGHT_PAREN";
Key[Key["ASTERISK"] = 42] = "ASTERISK";
Key[Key["PLUS_SIGN"] = 43] = "PLUS_SIGN";
Key[Key["COMMA"] = 44] = "COMMA";
Key[Key["HYPHEN"] = 45] = "HYPHEN";
Key[Key["PERIOD"] = 46] = "PERIOD";
Key[Key["FORWARD_SLASH"] = 47] = "FORWARD_SLASH";
Key[Key["NUM_0"] = 48] = "NUM_0";
Key[Key["NUM_1"] = 49] = "NUM_1";
Key[Key["NUM_2"] = 50] = "NUM_2";
Key[Key["NUM_3"] = 51] = "NUM_3";
Key[Key["NUM_4"] = 52] = "NUM_4";
Key[Key["NUM_5"] = 53] = "NUM_5";
Key[Key["NUM_6"] = 54] = "NUM_6";
Key[Key["NUM_7"] = 55] = "NUM_7";
Key[Key["NUM_8"] = 56] = "NUM_8";
Key[Key["NUM_9"] = 57] = "NUM_9";
Key[Key["COLON"] = 58] = "COLON";
Key[Key["SEMI_COLON"] = 59] = "SEMI_COLON";
Key[Key["LEFT_CARET"] = 60] = "LEFT_CARET";
Key[Key["EQUAL_SIGN"] = 61] = "EQUAL_SIGN";
Key[Key["RIGHT_CARET"] = 62] = "RIGHT_CARET";
Key[Key["QUESTION_MARK"] = 63] = "QUESTION_MARK";
Key[Key["AT"] = 64] = "AT";
Key[Key["A"] = 65] = "A";
Key[Key["B"] = 66] = "B";
Key[Key["C"] = 67] = "C";
Key[Key["D"] = 68] = "D";
Key[Key["E"] = 69] = "E";
Key[Key["F"] = 70] = "F";
Key[Key["G"] = 71] = "G";
Key[Key["H"] = 72] = "H";
Key[Key["I"] = 73] = "I";
Key[Key["J"] = 74] = "J";
Key[Key["K"] = 75] = "K";
Key[Key["L"] = 76] = "L";
Key[Key["M"] = 77] = "M";
Key[Key["N"] = 78] = "N";
Key[Key["O"] = 79] = "O";
Key[Key["P"] = 80] = "P";
Key[Key["Q"] = 81] = "Q";
Key[Key["R"] = 82] = "R";
Key[Key["S"] = 83] = "S";
Key[Key["T"] = 84] = "T";
Key[Key["U"] = 85] = "U";
Key[Key["V"] = 86] = "V";
Key[Key["W"] = 87] = "W";
Key[Key["X"] = 88] = "X";
Key[Key["Y"] = 89] = "Y";
Key[Key["Z"] = 90] = "Z";
Key[Key["LEFT_BRACKET"] = 91] = "LEFT_BRACKET";
Key[Key["BACK_SLASH"] = 92] = "BACK_SLASH";
Key[Key["RIGHT_BRACKET"] = 93] = "RIGHT_BRACKET";
Key[Key["CIRCUMFLEX"] = 94] = "CIRCUMFLEX";
Key[Key["UNDERSCORE"] = 95] = "UNDERSCORE";
Key[Key["BACK_TICK"] = 96] = "BACK_TICK";
Key[Key["a"] = 97] = "a";
Key[Key["b"] = 98] = "b";
Key[Key["c"] = 99] = "c";
Key[Key["d"] = 100] = "d";
Key[Key["e"] = 101] = "e";
Key[Key["f"] = 102] = "f";
Key[Key["g"] = 103] = "g";
Key[Key["h"] = 104] = "h";
Key[Key["i"] = 105] = "i";
Key[Key["j"] = 106] = "j";
Key[Key["k"] = 107] = "k";
Key[Key["l"] = 108] = "l";
Key[Key["m"] = 109] = "m";
Key[Key["n"] = 110] = "n";
Key[Key["o"] = 111] = "o";
Key[Key["p"] = 112] = "p";
Key[Key["q"] = 113] = "q";
Key[Key["r"] = 114] = "r";
Key[Key["s"] = 115] = "s";
Key[Key["t"] = 116] = "t";
Key[Key["u"] = 117] = "u";
Key[Key["v"] = 118] = "v";
Key[Key["w"] = 119] = "w";
Key[Key["x"] = 120] = "x";
Key[Key["y"] = 121] = "y";
Key[Key["z"] = 122] = "z";
Key[Key["LEFT_BRACE"] = 123] = "LEFT_BRACE";
Key[Key["VERTICAL_BAR"] = 124] = "VERTICAL_BAR";
Key[Key["RIGHT_BRACE"] = 125] = "RIGHT_BRACE";
Key[Key["TILDE"] = 126] = "TILDE";
Key[Key["BACKSPACE"] = 127] = "BACKSPACE";
})(Key || (Key = {}));
var ExitCode;
(function (ExitCode) {
ExitCode[ExitCode["SUCCESS"] = 0] = "SUCCESS";
ExitCode[ExitCode["SIGINT"] = 130] = "SIGINT";
})(ExitCode || (ExitCode = {}));
const TermEscapeSequence = "\u001b";
// https://man7.org/linux/man-pages/man4/console_codes.4.html
var TermInputSequence;
(function (TermInputSequence) {
TermInputSequence["ARROW_UP"] = "A";
TermInputSequence["ARROW_DOWN"] = "B";
TermInputSequence["ARROW_LEFT"] = "D";
TermInputSequence["ARROW_RIGHT"] = "C";
TermInputSequence["COLOR"] = "m";
TermInputSequence["DELETE_CHARACTER"] = "P";
TermInputSequence["END"] = "F";
TermInputSequence["ERASE_CHARACTER"] = "X";
TermInputSequence["ERASE_LINE"] = "K";
TermInputSequence["GET_CURSOR_POSITION"] = "6n";
TermInputSequence["HOME"] = "H";
TermInputSequence["MOVE_CURSOR_TO_COLUMN"] = "G";
TermInputSequence["MOVE_CURSOR_TO_ROW"] = "d";
TermInputSequence["MOVE_CURSOR_TO_ROW_COLUMN"] = "H";
TermInputSequence["RESTORE_CURSOR"] = "u";
TermInputSequence["SAVE_CURSOR"] = "s";
})(TermInputSequence || (TermInputSequence = {}));
var Direction;
(function (Direction) {
Direction[Direction["LEFT"] = 0] = "LEFT";
Direction[Direction["UP"] = 1] = "UP";
Direction[Direction["RIGHT"] = 2] = "RIGHT";
Direction[Direction["DOWN"] = 3] = "DOWN";
})(Direction || (Direction = {}));
var LineErasureMethod;
(function (LineErasureMethod) {
LineErasureMethod["CURSOR_TO_END"] = "";
LineErasureMethod["BEGINNING_TO_CURSOR"] = "1";
LineErasureMethod["ENTIRE"] = "2";
})(LineErasureMethod || (LineErasureMethod = {}));
const ConfigSchema = Joi.object({
autocomplete: Joi.object({
behavior: Joi.string()
.allow(...Object.values(AutocompleteBehavior))
.insensitive(),
fill: Joi.boolean(),
searchFn: Joi.function().arity(1),
sticky: Joi.boolean(),
suggestColCount: Joi.number().min(1),
triggerKey: Joi.number().allow(...Object.values(Key)),
}),
defaultResponse: Joi.string(),
echo: Joi.string(),
eot: Joi.boolean(),
history: Joi.object({
atStart: Joi.function().arity(0),
atPenultimate: Joi.function().arity(0),
pastEnd: Joi.function().arity(0),
atEnd: Joi.function().arity(0),
prev: Joi.function().arity(0),
next: Joi.function().arity(0),
reset: Joi.function().arity(0),
push: Joi.function().arity(1),
save: Joi.function(),
}),
sigint: Joi.boolean(),
});
const DEFAULT_CONFIG = {
autocomplete: {
behavior: AutocompleteBehavior.CYCLE,
fill: false,
searchFn: (_) => [],
sticky: false,
suggestColCount: 3,
triggerKey: Key.TAB,
},
defaultResponse: "",
echo: undefined,
eot: false,
history: undefined,
sigint: false,
};
const EMPTY_CONFIG = {
autocomplete: {
behavior: undefined,
fill: undefined,
searchFn: undefined,
sticky: undefined,
suggestColCount: undefined,
triggerKey: undefined,
},
defaultResponse: undefined,
echo: undefined,
eot: undefined,
history: undefined,
sigint: undefined,
};
// returns a merged object with the left-hand side as the basis
// only overwrites left-hand values if undefined
const mergeLeft = (a, b) => {
return a !== undefined && a !== null
? b !== undefined && b !== null
? Object.keys(a)
.map((key) => {
var _a;
return ({
[key]: typeof a[key] === "object" || typeof b[key] === "object"
? mergeLeft(a[key], b[key])
: (_a = a[key]) !== null && _a !== void 0 ? _a : b[key],
});
})
.reduce((accumulator, value, _) => (Object.assign(Object.assign({}, accumulator), value)))
: a
: b;
};
/* sigh - I wish I knew of the ansi-escapes package sooner - oh well, in too deep */
const escape = (str) => `${TermEscapeSequence}${str}`;
const generateSequenceResponseObject = (seq) => ({
sequence: {
raw: seq,
escaped: escape(seq),
},
exec: () => {
process.stdout.write(escape(seq));
},
});
// todo - refactor this to be chainable e.g.
// move.down(n).left(m).exec()
// generates a cursor movement object that can either return its own
// escape sequence
const move = (n) => {
const moveCount = typeof n === "number" && n > 1 ? `${n}` : "";
const seqStart = `[${moveCount}`;
return {
down: generateSequenceResponseObject(`${seqStart}${TermInputSequence.ARROW_DOWN}`),
left: generateSequenceResponseObject(`${seqStart}${TermInputSequence.ARROW_LEFT}`),
up: generateSequenceResponseObject(`${seqStart}${TermInputSequence.ARROW_UP}`),
right: generateSequenceResponseObject(`${seqStart}${TermInputSequence.ARROW_RIGHT}`),
};
};
const saveCursorPosition = () => generateSequenceResponseObject(`[${TermInputSequence.SAVE_CURSOR}`);
const restoreCursorPosition = () => generateSequenceResponseObject(`[${TermInputSequence.RESTORE_CURSOR}`);
const eraseLine = (method = LineErasureMethod.CURSOR_TO_END) => generateSequenceResponseObject(`[${method}${TermInputSequence.ERASE_LINE}`);
const eraseCharacter = (n = 1) => generateSequenceResponseObject(`[${n}${TermInputSequence.ERASE_CHARACTER}`);
const moveCursorToColumn = (n) => generateSequenceResponseObject(`[${n}${TermInputSequence.MOVE_CURSOR_TO_COLUMN}`);
const moveCursorTo = (row, column) => generateSequenceResponseObject(`[${row};${column}${TermInputSequence.MOVE_CURSOR_TO_ROW_COLUMN}`);
const concat = (...args) => args
.map((arg) => (typeof arg === "string" ? arg : arg.sequence.escaped))
.reduce((accum, value) => `${accum}${value}`);
// takes a list of auto-complete matches and converts them into an [n x 3] table
// of strings
const tablify = (autocompleteMatches, colCount) => {
const result = [];
const currentRow = [];
if (autocompleteMatches.length == 0)
return { output: "", rowCount: 0 };
autocompleteMatches.forEach((str) => {
currentRow.push(str);
if (currentRow.length === colCount) {
result.push(currentRow.concat());
currentRow.length = 0;
}
});
if (currentRow.length) {
// fill in any missing cells - table requires consistent cell counts per row
for (let emptyCells = colCount - currentRow.length; emptyCells > 0; --emptyCells)
currentRow.push("");
result.push(currentRow.concat());
}
return {
output: table(result, {
border: getBorderCharacters("void"),
columnDefault: {
paddingLeft: 2,
paddingRight: 2,
},
drawHorizontalLine: () => false,
}),
rowCount: result.length,
};
};
// credit to kennebec, et. al.
// https://stackoverflow.com/a/1917041/3578493
const getCommonStartingSubstring = (list) => {
if (list.length === 0)
return null;
if (list.length === 1)
return list[0];
const sortedMatches = list.concat().sort();
const first = sortedMatches[0];
const last = sortedMatches.slice(-1)[0];
const minLength = Math.min(first.length, last.length);
const result = [];
for (let i = 0; i < minLength; ++i) {
if (first[i] === last[i])
result.push(first[i]);
else
return result.length ? result.join("") : null;
}
return result.join("");
};
// truncates a list of autocomplete suggestions so that the list always fits in the current remaining number of rows
// given the current cursor position and column spread of suggestions
const truncateSuggestions = (suggestions, numColumns, currentCursorRow, termMaxRows) => {
// subtract an extra 1 since that represents the line of input the
// cursor is on, and suggestions begin output from the next line
const rowsRemaining = termMaxRows - currentCursorRow - 1;
// subtract an extra 1 since we include a notice the list was truncated
const truncatedListLength = rowsRemaining * numColumns - 1;
// any fractional part must by definition take up a extra row
const numRowsNeeded = Math.ceil(suggestions.length / numColumns);
return numRowsNeeded > rowsRemaining
? [
...suggestions.slice(0, truncatedListLength),
`${suggestions.length - truncatedListLength} more...`,
]
: suggestions;
};
/* sinon can't stub modules, so we need to export this function as a property of an object to
avoid running this code in our unit tests. This code tends to break the order in which
the tests expect to see fs.readSync calls, and tends to output escape sequences to the terminal
where the test output should be.
Todo - at some point I might want to just export all of the utilies like this for consistency
*/
var utils = {
getCursorPosition: (fileDescriptor) => {
process.stdout.write(escape(`[${TermInputSequence.GET_CURSOR_POSITION}`));
const buf = Buffer.alloc(10);
fs.readSync(fileDescriptor, buf);
const asString = buf.toString(); // "\u001b[<row>;<col>R"
const coordSections = asString.substring(asString.indexOf("[")).split(";");
const pos = {
row: parseInt(coordSections[0].substring(1)),
col: parseInt(coordSections[1]),
};
return pos;
},
};
var _a, _b;
// for testing purposes only - allows me to break out of possible infinite loops that arise during development
const MAX_PROMPT_LOOPS = Number.POSITIVE_INFINITY;
let USER_ASK = "";
// default to 80 columns if we can't figure out the terminal column width for some reason
// this tends to happen if you run prompt-sync-plus from another script (e.g. /build/scripts/test.js)
const TERM_COLS = (_a = process.stdout.columns) !== null && _a !== void 0 ? _a : 80;
// 55 is arbitrary - just happens to be how many rows my terminal can fit when maximized
const TERM_ROWS = (_b = process.stdout.rows) !== null && _b !== void 0 ? _b : 55;
// top left of terminal window - weird it's not 0,0
let INITIAL_CURSOR_POSITION = { row: 1, col: 1 };
// Keeps track of the current cursor position without using getCursorPosition below.
// The write to/read from the terminal in getCursorPosition breaks our tests and are unintended side effects
let internalCursorPosition = { row: 1, col: 1 };
// keeps track of where the end of user input is on screen relative to the terminal window
let inputEndPosition = { row: 1, col: 1 };
// keeps track of the user input insert position relative to the raw string itself, and not the terminal coordinates
let currentInsertPosition = 0;
// keep track of size so we can calculate cursor positioning and output clearing properly
// todo - this doesn't seem to work since we're waiting on input synchronously and blocking the thread...
// right now I don't see another solution for this
// process.stdout.on("resize", () => {
// TERM_COLS = process.stdout.columns;
// console.log(`resizing, new cols: ${TERM_COLS}`);
// });
// internal function, moves the cursor once in a given direction
// returns true if the cursor moved, false otherwise
const _moveInternalCursor = (direction) => {
const prevPosition = Object.assign({}, internalCursorPosition);
switch (direction) {
case Direction.LEFT:
if (internalCursorPosition.col === 1) {
if (internalCursorPosition.row > INITIAL_CURSOR_POSITION.row) {
internalCursorPosition.col = TERM_COLS;
internalCursorPosition.row--;
}
}
else {
if (internalCursorPosition.row === INITIAL_CURSOR_POSITION.row) {
if (internalCursorPosition.col > INITIAL_CURSOR_POSITION.col) {
internalCursorPosition.col--;
}
}
else {
internalCursorPosition.col--;
}
}
break;
case Direction.RIGHT:
if (internalCursorPosition.row === inputEndPosition.row) {
if (internalCursorPosition.col < inputEndPosition.col)
internalCursorPosition.col++;
}
else if (internalCursorPosition.col === TERM_COLS) {
internalCursorPosition.col = 1;
internalCursorPosition.row++;
}
else {
internalCursorPosition.col++;
}
break;
case Direction.UP:
// todo - consider moving to beginning of line
if (internalCursorPosition.row > INITIAL_CURSOR_POSITION.row)
internalCursorPosition.row--;
break;
case Direction.DOWN:
// todo - consider moving to end-of-line; sometimes terminals will do that
if (internalCursorPosition.row < inputEndPosition.row)
internalCursorPosition.row++;
break;
}
return (prevPosition.col !== internalCursorPosition.col ||
prevPosition.row !== internalCursorPosition.row);
};
/**
* Moves the internal cursor position n times in a given direction, then syncs the system
* cursor to the internal cursor so the user sees the updated position
* @returns true if the cursor moved at all, false otherwise
*/
const moveInternalCursor = (direction, n = 1) => {
let moved = false;
for (let i = 0; i < n; ++i) {
// check if the cursor moved, if not, we probably hit a limit and can stop early
if (!_moveInternalCursor(direction))
break;
moved = true;
}
// we are strongly coupling the cursors here - this will need to change if we discover a scenario
// where we'll want our cursor and the external cursor positions to be different
if (moved)
syncCursors();
return moved;
};
/**
* Moves the current internal cursor position to the desired position, then syncs the system
* cursor. If the intended cursor position is out of bounds (prior to the initial prompt's row,
* to the left of the starting column index, or after the end of input), the movement is
* curtailed to the limits
*/
const moveInternalCursorTo = ({ row, col }) => {
// another option here would be to just throw an error - if the program ever
// attempts to move the cursor out of bounds, it's likely unintneded... but eh
if (row > inputEndPosition.row)
row = inputEndPosition.row;
else if (row < INITIAL_CURSOR_POSITION.row)
row = INITIAL_CURSOR_POSITION.row;
if (col > TERM_COLS)
col = TERM_COLS;
else if (col < 1)
col = 1;
internalCursorPosition = { row, col };
syncCursors();
};
// converts the cursor (X, Y) coordinates into a string index representing the new insert position
// for future user input updates. Useful in cases like UP/DOWN arrow behavior, possibly others
const syncInsertPostion = () => {
const lengthFromRows = (internalCursorPosition.row - INITIAL_CURSOR_POSITION.row) * TERM_COLS;
// minus one because cursor position is always one to the right of the end of the string
const lengthFromCols = internalCursorPosition.col - USER_ASK.length - 1;
currentInsertPosition = lengthFromRows + lengthFromCols;
};
/*
This is a very important concept: we track the current cursor position internally so we don't need to rely on
getCursorPosition(), which breaks our tests by introducing the side effect of executing its own write + read to the terminal.
The move functions in utils.ts move the external/visible cursor the user sees, the moveInternalCursor function updates the
cursor we track positions with. This is necessary because the move functions in utils.ts aren't aware of the current cursor position,
and aren't capable of repositioning in special cases (like moving right at the end of the terminal row, or left at the beginning of the terminal row)
This is a bit janky, but seems to be the most efficient way to maintain cursor awareness without introducing side effects and
breaking all of our tests.
This function moves the external/visible cursor to the position of our internal cursor.
*/
const syncCursors = () => {
moveCursorTo(internalCursorPosition.row, internalCursorPosition.col).exec();
};
const eraseUserInput = () => {
moveInternalCursorTo(inputEndPosition);
for (let row = internalCursorPosition.row; row > INITIAL_CURSOR_POSITION.row; --row) {
eraseLine(LineErasureMethod.ENTIRE).exec();
moveInternalCursor(Direction.UP);
}
// at this point we're on the same line as the initial prompt
eraseLine(LineErasureMethod.ENTIRE).exec();
moveInternalCursorTo({ row: INITIAL_CURSOR_POSITION.row, col: 1 });
};
// in an ideal world it'd be best to avoid global state altogether
// alas, this function exists to reset different variables to allow users to run prompt
// multiple times in their programs without issue
const reset = () => {
USER_ASK = "";
currentInsertPosition = 0;
internalCursorPosition = { row: 1, col: 1 };
inputEndPosition = { row: 1, col: 1 };
};
function PromptSyncPlus(config) {
const globalConfig = config
? mergeLeft(mergeLeft(EMPTY_CONFIG, config), DEFAULT_CONFIG)
: DEFAULT_CONFIG;
ConfigSchema.validate(globalConfig);
const prompt = ((ask, value, configOverride) => {
var _a, _b, _c, _d;
const promptConfig = (value
? typeof value === "object"
? mergeLeft(mergeLeft(EMPTY_CONFIG, value), globalConfig)
: configOverride
? mergeLeft(mergeLeft(EMPTY_CONFIG, configOverride), globalConfig)
: globalConfig
: configOverride
? mergeLeft(mergeLeft(EMPTY_CONFIG, configOverride), globalConfig)
: globalConfig);
if (promptConfig.history !== undefined)
prompt.history = promptConfig.history;
USER_ASK = ask || "";
const defaultValue = value && typeof value === "string" ? value : promptConfig.defaultResponse;
const { history } = promptConfig;
ConfigSchema.validate(promptConfig);
// insert position stored during history up/down arrow actions
let savedInsertPosition = 0;
// number of autocomplete suggestion rows generated during the last autocomplete execution
let numRowsToClear = 0;
// a temporary storage buffer for entered input; used to determine the type of input
// e.g. whether an escape sequence was entered
let buf = Buffer.alloc(3);
let userInput = "";
let changedPortionOfInput = "";
let firstCharOfInput;
// a temporary buffer to store the user's current input during history scroll
let savedUserInput = "";
let cycleSearchTerm = "";
const fileDescriptor = process.platform === "win32"
? process.stdin.fd
: fs.openSync("/dev/tty", "rs");
const wasRaw = process.stdin.isRaw;
if (!wasRaw) {
process.stdin.setRawMode && process.stdin.setRawMode(true);
}
if (USER_ASK) {
process.stdout.write(USER_ASK);
// support for multi-line asks
// todo - see if this is still necessary
USER_ASK = USER_ASK.split(/\r?\n/).pop();
}
let autocompleteCycleIndex = 0;
function updateInputEndPosition() {
// take into account the fact that echo can be a multi-character string
const outputLength = USER_ASK.length +
(promptConfig.echo === undefined
? userInput.length
: promptConfig.echo.length * userInput.length) +
1; // +1 because cursor is always just to the right of the last input
inputEndPosition.row =
INITIAL_CURSOR_POSITION.row + (Math.ceil(outputLength / TERM_COLS) - 1);
const modResult = outputLength % TERM_COLS;
inputEndPosition.col = modResult === 0 ? TERM_COLS : modResult;
}
function storeInput(newChar) {
const inputStart = userInput.slice(0, currentInsertPosition);
changedPortionOfInput = `${String.fromCharCode(newChar)}${userInput.slice(currentInsertPosition)}`;
userInput = `${inputStart}${changedPortionOfInput}`;
currentInsertPosition++;
updateInputEndPosition();
}
function autocompleteCycle() {
// first TAB hit, save off original input
if (cycleSearchTerm.length === 0)
cycleSearchTerm = userInput;
const searchResults = promptConfig.autocomplete.searchFn(cycleSearchTerm);
if (searchResults.length === 0)
return;
const currentResult = searchResults[autocompleteCycleIndex];
if (++autocompleteCycleIndex === searchResults.length)
autocompleteCycleIndex = 0;
process.stdout.write(concat("\r", eraseLine(), USER_ASK, currentResult));
userInput = currentResult;
currentInsertPosition = userInput.length;
updateInputEndPosition();
moveInternalCursorTo(inputEndPosition);
}
function autocompleteSuggest(isBackspace) {
const searchResults = promptConfig.autocomplete.searchFn(userInput);
if (searchResults.length === 0) {
if (promptConfig.autocomplete.sticky) {
process.stdout.write(concat("\r", eraseLine(), USER_ASK, userInput));
}
return 0;
}
else if (searchResults.length === 1 && !isBackspace) {
userInput = searchResults[0];
currentInsertPosition = userInput.length;
updateInputEndPosition();
process.stdout.write(concat("\r", eraseLine(), USER_ASK, userInput));
moveInternalCursorTo(inputEndPosition);
return 0;
}
const truncated = truncateSuggestions(searchResults, promptConfig.autocomplete.suggestColCount, internalCursorPosition.row, TERM_ROWS);
const wasTruncated = truncated.length !== searchResults.length;
if (!isBackspace && promptConfig.autocomplete.fill) {
// don't consider the "x more..." content we fill in
const commonSubstring = getCommonStartingSubstring(wasTruncated ? truncated.slice(0, -1) : truncated);
if (commonSubstring && commonSubstring !== userInput) {
userInput = commonSubstring;
updateInputEndPosition();
moveInternalCursorTo(inputEndPosition);
}
}
currentInsertPosition = userInput.length;
const tableData = tablify(truncated, promptConfig.autocomplete.suggestColCount);
saveCursorPosition().exec();
process.stdout.write(concat("\r", eraseLine(), USER_ASK, userInput, "\n", tableData.output));
restoreCursorPosition().exec();
return tableData.rowCount;
}
function autocompleteHybrid() {
// first TAB hit, save off original input
if (cycleSearchTerm.length === 0)
cycleSearchTerm = userInput;
const searchResults = promptConfig.autocomplete.searchFn(cycleSearchTerm);
if (!searchResults.length)
return 0;
if (searchResults.length === 1) {
userInput = searchResults[0];
currentInsertPosition = userInput.length;
updateInputEndPosition();
process.stdout.write(concat("\r", eraseLine(), USER_ASK, userInput));
moveInternalCursorTo(inputEndPosition);
return 0;
}
const truncated = truncateSuggestions(searchResults, promptConfig.autocomplete.suggestColCount, internalCursorPosition.row, TERM_ROWS);
const wasTruncated = truncated.length !== searchResults.length;
const currentResult = (wasTruncated ? truncated.slice(0, -1) : truncated)[autocompleteCycleIndex];
if (++autocompleteCycleIndex ===
truncated.length - (wasTruncated ? 1 : 0))
autocompleteCycleIndex = 0;
const tableData = tablify(truncated, promptConfig.autocomplete.suggestColCount);
userInput = currentResult;
currentInsertPosition = userInput.length;
updateInputEndPosition();
saveCursorPosition().exec();
process.stdout.write(concat("\r", eraseLine(), USER_ASK, userInput, "\n", tableData.output));
restoreCursorPosition().exec();
moveInternalCursorTo(inputEndPosition);
return tableData.rowCount;
}
function clearSuggestTable(countRows) {
if (countRows < 1)
return;
for (let moveCount = 0; moveCount < countRows; ++moveCount) {
// moves the system cursor only
move().down.exec();
eraseLine(LineErasureMethod.ENTIRE).exec();
}
// restore original cursor position
syncCursors();
}
/**
* @param direction Whether we are moving UP or DOWN in history
*/
function scrollHistory(direction) {
if (direction === Direction.UP) {
if (history.atStart())
return;
if (history.atEnd()) {
savedUserInput = userInput;
savedInsertPosition = currentInsertPosition;
}
userInput = history.prev();
currentInsertPosition = userInput.length;
}
else if (direction === Direction.DOWN) {
if (history.pastEnd())
return;
if (history.atPenultimate()) {
userInput = savedUserInput;
currentInsertPosition = savedInsertPosition;
history.next();
}
else {
userInput = history.next();
currentInsertPosition = userInput.length;
}
}
else {
// should never happen, but good to check
throw new Error(`Unexpected scroll direction; code ${direction}`);
}
eraseUserInput();
process.stdout.write(`${USER_ASK}${userInput}`);
updateInputEndPosition();
moveInternalCursorTo(inputEndPosition);
return;
}
function handleMultiByteSequence() {
// received a control sequence
const sequence = buf.toString();
const charSize = promptConfig.echo !== undefined ? promptConfig.echo.length : 1;
switch (sequence) {
case move().up.sequence.escaped:
if (promptConfig.echo !== undefined)
break;
if (history) {
scrollHistory(Direction.UP);
}
else {
if (moveInternalCursor(Direction.UP))
syncInsertPostion();
}
break;
case move().down.sequence.escaped:
if (promptConfig.echo !== undefined)
break;
if (history) {
scrollHistory(Direction.DOWN);
}
else {
if (moveInternalCursor(Direction.DOWN))
syncInsertPostion();
}
break;
case move().left.sequence.escaped:
if (moveInternalCursor(Direction.LEFT, charSize))
syncInsertPostion();
break;
case move().right.sequence.escaped:
if (moveInternalCursor(Direction.RIGHT, charSize))
syncInsertPostion();
break;
default:
// todo - determine what would actually trigger this logic? Could it be
// multi-byte characters? Chinese symbols? Emojis?
if (buf.toString()) {
userInput = userInput + stripAnsi(buf.toString());
userInput = userInput.replace(/\0/g, "");
currentInsertPosition = userInput.length;
promptPrint(changedPortionOfInput, false, promptConfig.echo);
moveCursorToColumn(currentInsertPosition + USER_ASK.length + 1).exec();
buf = Buffer.alloc(3);
}
}
}
let loopCount = 0;
INITIAL_CURSOR_POSITION = utils.getCursorPosition(fileDescriptor);
internalCursorPosition = Object.assign({}, INITIAL_CURSOR_POSITION);
inputEndPosition = Object.assign({}, INITIAL_CURSOR_POSITION);
while (true) {
const countBytesRead = fs.readSync(fileDescriptor, buf, 0, 3, null);
if (countBytesRead > 1) {
handleMultiByteSequence();
continue;
}
// if it is not a control character seq, assume only one character is read
firstCharOfInput = buf[0];
const isAutocompleteTrigger = firstCharOfInput === ((_a = promptConfig.autocomplete) === null || _a === void 0 ? void 0 : _a.triggerKey);
const isStickyOnly = !isAutocompleteTrigger && promptConfig.autocomplete.sticky;
const isUnsupportedOrUnknownInput = firstCharOfInput != Key.TAB &&
(firstCharOfInput < Key.SPACE || firstCharOfInput > Key.BACKSPACE);
const isBackspace = firstCharOfInput === Key.BACKSPACE ||
(process.platform === "win32" &&
firstCharOfInput === Key.WIN_BACKSPACE);
const autocompleteBehavior = (_c = (_b = promptConfig.autocomplete) === null || _b === void 0 ? void 0 : _b.behavior) === null || _c === void 0 ? void 0 : _c.toLowerCase();
// ^C
if (firstCharOfInput === Key.SIGINT) {
// in case we're canceling a prompt that had suggestions, clear them out
clearSuggestTable(numRowsToClear);
moveInternalCursorTo(inputEndPosition);
process.stdout.write("^C\n");
fs.closeSync(fileDescriptor);
if (promptConfig.sigint)
process.exit(ExitCode.SIGINT);
process.stdin.setRawMode && process.stdin.setRawMode(wasRaw);
return null;
}
// ^D
if (firstCharOfInput === Key.EOT) {
if (userInput.length === 0 && promptConfig.eot) {
// in case we're canceling a prompt that had suggestions, clear them out
clearSuggestTable(numRowsToClear);
moveInternalCursorTo(inputEndPosition);
process.stdout.write("exit\n");
process.exit(ExitCode.SUCCESS);
}
}
// catch the terminating character
if (firstCharOfInput === Key.ENTER) {
clearSuggestTable(numRowsToClear);
fs.closeSync(fileDescriptor);
if (!history)
break;
if (promptConfig.echo === undefined &&
(userInput.length || defaultValue.length))
history.push(userInput || defaultValue);
history.reset();
break;
}
if (isBackspace) {
// todo - possibly move secondary backspace handling logic out of promptPrint() and into here
// it's confusing having backspace logic in more than one place
if (currentInsertPosition === 0)
continue;
userInput =
userInput.slice(0, currentInsertPosition - 1) +
userInput.slice(currentInsertPosition);
currentInsertPosition--;
updateInputEndPosition();
}
if (((_d = promptConfig.autocomplete) === null || _d === void 0 ? void 0 : _d.searchFn) &&
(isAutocompleteTrigger ||
(promptConfig.autocomplete.sticky &&
autocompleteBehavior === AutocompleteBehavior.SUGGEST))) {
const currentUserInput = userInput;
const prevRowsToClear = numRowsToClear;
if (isStickyOnly) {
if (!isBackspace) {
// need to store off current input before we process
storeInput(firstCharOfInput);
moveInternalCursor(Direction.RIGHT);
}
else {
moveInternalCursor(Direction.LEFT);
eraseCharacter().exec();
}
clearSuggestTable(numRowsToClear);
}
if (userInput.length === 0)
continue;
switch (autocompleteBehavior) {
case AutocompleteBehavior.CYCLE:
autocompleteCycle();
break;
case AutocompleteBehavior.SUGGEST:
numRowsToClear = autocompleteSuggest(isBackspace);
if (numRowsToClear === 0 && currentUserInput !== userInput)
clearSuggestTable(prevRowsToClear);
break;
case AutocompleteBehavior.HYBRID:
numRowsToClear = autocompleteHybrid();
if (numRowsToClear === 0 && currentUserInput !== userInput)
clearSuggestTable(prevRowsToClear);
break;
}
continue;
}
// in case the user picked a wierd key to trigger auto-complete, allow it
if (isUnsupportedOrUnknownInput && !isAutocompleteTrigger)
continue;
cycleSearchTerm = "";
autocompleteCycleIndex = 0;
clearSuggestTable(numRowsToClear);
numRowsToClear = 0;
if (!isBackspace)
storeInput(firstCharOfInput);
promptPrint(changedPortionOfInput, isBackspace, promptConfig.echo);
// this is used to help debug cases where there is an infinite loop
// by default this has no impact on the logic
loopCount++;
if (loopCount === MAX_PROMPT_LOOPS) {
loopCount = 0;
moveInternalCursorTo(inputEndPosition);
return userInput || defaultValue || "";
}
}
// move cursor to the end just before submission - necessary in cases where the user was
// editing input somewhere in the middle of a multi-line entry
moveInternalCursorTo(inputEndPosition);
reset();
process.stdout.write("\n");
process.stdin.setRawMode && process.stdin.setRawMode(wasRaw);
loopCount = 0;
// todo - write a function to reset global state
return userInput || defaultValue || "";
});
prompt.hide = (USER_ASK) => prompt(USER_ASK, mergeLeft({ echo: "" }, EMPTY_CONFIG));
function promptPrint(changedPortionOfInput, isBackspace, echo) {
const masked = echo !== undefined;
const moveCount = masked ? echo.length : 1;
if (masked && moveCount === 0)
return;
if (isBackspace) {
// echo can be set to a string of length > 1
for (let i = 0; i < moveCount; ++i) {
moveInternalCursor(Direction.LEFT);
eraseCharacter().exec();
}
}
else {
const output = masked
? echo.repeat(changedPortionOfInput.length)
: changedPortionOfInput;
process.stdout.write(output);
// we type one character a time, but if masked, one character may be represented by multiple
moveInternalCursor(Direction.RIGHT, moveCount);
}
}
return prompt;
}
export { AutocompleteBehavior, Key, PromptSyncPlus as default };