UNPKG

prompt-sync-plus

Version:

Yet another synchronous prompt for Node.js

926 lines (916 loc) 41.4 kB
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 };