From 1708e9dc7154de1872f14ca2c788e5423fb3f172 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 18 Sep 2022 22:04:15 +0200 Subject: [PATCH] Improve escapes handling (#30) --- run-pty.js | 63 ++++++++++++++++++++++++++++++-------------- test-clear-down.js | 15 ++++++++--- test-clear.js | 6 ++--- test/run-pty.test.js | 3 ++- 4 files changed, 59 insertions(+), 28 deletions(-) diff --git a/run-pty.js b/run-pty.js index 7f23720..53861a8 100755 --- a/run-pty.js +++ b/run-pty.js @@ -82,7 +82,6 @@ const CLEAR = "\x1B[2J\x1B[3J\x1B[H"; const CLEAR_LEFT = "\x1B[1K"; const CLEAR_RIGHT = "\x1B[K"; const CLEAR_DOWN = "\x1B[J"; -const CLEAR_DOWN_REGEX = /\x1B\[0?J$/; // These save/restore cursor position _and graphic renditions._ const SAVE_CURSOR = IS_TERMINAL_APP ? "\u001B7" : "\x1B[s"; const RESTORE_CURSOR = IS_TERMINAL_APP ? "\u001B8" : "\x1B[u"; @@ -115,7 +114,7 @@ const CLEAR_REGEX = (() => { [goToTopLeft, clearScrollback, clearDown], ].map((parts) => parts.map((part) => `\\x1B\\[${part.source}`).join("")); - return RegExp(`(?:${variants.join("|")})$`); + return RegExp(`(?:${variants.join("|")})`); })(); const waitingIndicator = NO_COLOR @@ -681,7 +680,9 @@ const statusText = ( // If a command moves the cursor to another line it’s not considered a “simple // log”. Then it’s not safe to print the keyboard shortcuts. // -// - A, B: Cursor up/down. Moving down should be safe. +// - A, B: Cursor up/down. Moving down should be safe. So is `\n1A` (move to +// start of new line, then up one line) – docker-compose does that +// to update the previous line. We always print on the next line so it’s safe. // - C, D: Cursor left/right. Should be safe! Parcel does this. // - E, F: Cursor down/up, and to the start of the line. Moving down should be safe. // - G: Cursor absolute position within line. Should be safe! Again, Parcel. @@ -696,8 +697,15 @@ const statusText = ( // - T: Scroll down. // - s: Save cursor position. // - u: Restore cursor position. -const NOT_SIMPLE_LOG_ESCAPE = - /\x1B\[(?:\d*[AFLMST]|[su]|(?!(?:[01](?:;[01])?)?[fH]\x1B\[[02]?J)(?:\d+(?:;\d+)?)?[fH])/; +// +// This includes the regexes for clearing the screen, since they also affect “is +// simple log”: They reset it to `true` sometimes. +const NOT_SIMPLE_LOG_ESCAPE_RAW = + /(\x1B\[0?J)|\x1B\[(?:\d*[FLMST]|[su]|(?!(?:[01](?:;[01])?)?[fH]\x1B\[[02]?J)(?:\d+(?:;\d+)?)?[fH])|(?!\n\x1B\[1?A)(?:^|[^])\x1B\[\d*A/; +const NOT_SIMPLE_LOG_ESCAPE = RegExp( + `(${CLEAR_REGEX.source})|${NOT_SIMPLE_LOG_ESCAPE_RAW.source}`, + "g" +); // These escapes should be printed when they first occur, but not when // re-printing history. They result in getting a response on stdin. The @@ -769,6 +777,10 @@ const respondToRequestFake = (request) => // error, and most of the time you’ll get color codes split in half. It prints // the next half the same millisecond. // +// It’s also needed because it is valid to print half an escape code for moving +// the cursor up, and then the other half. By buffering the escape code, we can +// pretend that escape codes always come in full in the rest of the code. +// // Note: The terminals I’ve tested with seem to wait forever for the end of // escape sequences – they don’t have a timeout or anything. const UNFINISHED_ESCAPE = /\x1B(?:\[[0-?]*[ -/]*)?$/; @@ -1148,6 +1160,7 @@ class Command { /** @type {Array<[RegExp, [string, string] | undefined]>} */ this.statusRules = statusRules; this.windowsConptyCursorMoveWorkaround = false; + this.unfinishedEscapeBuffer = ""; // When adding --auto-exit, I first tried to always set `this.history = ""` // and add `historyStart()` in `joinHistory`. However, that doesn’t work @@ -1206,7 +1219,14 @@ class Command { conptyInheritCursor: true, }); - const disposeOnData = terminal.onData((data) => { + const disposeOnData = terminal.onData((rawData) => { + const rawDataWithBuffer = this.unfinishedEscapeBuffer + rawData; + const match = UNFINISHED_ESCAPE.exec(rawDataWithBuffer); + const [data, unfinishedEscapeBuffer] = + match === null + ? [rawDataWithBuffer, ""] + : [rawDataWithBuffer.slice(0, match.index), match[0]]; + this.unfinishedEscapeBuffer = unfinishedEscapeBuffer; for (const [index, rawPart] of data.split(ESCAPES_REQUEST).entries()) { let part = rawPart; if ( @@ -1320,19 +1340,23 @@ class Command { } } else { this.history += part; - if (CLEAR_REGEX.test(this.history)) { - this.history = ""; - this.isSimpleLog = true; - } else { - if (CLEAR_DOWN_REGEX.test(this.history)) { + // Take one extra character so `NOT_SIMPLE_LOG_ESCAPE` can match the + // `\n${CURSOR_UP}` pattern. + const matches = this.history + .slice(-part.length - 1) + .matchAll(NOT_SIMPLE_LOG_ESCAPE); + for (const match of matches) { + const clearAll = match[1] !== undefined; + const clearDown = match[2] !== undefined; + if (clearAll) { + this.history = ""; this.isSimpleLog = true; + } else { + this.isSimpleLog = clearDown; } - if (this.history.length > MAX_HISTORY) { - this.history = this.history.slice(-MAX_HISTORY); - } - if (this.isSimpleLog && NOT_SIMPLE_LOG_ESCAPE.test(part)) { - this.isSimpleLog = false; - } + } + if (this.history.length > MAX_HISTORY) { + this.history = this.history.slice(-MAX_HISTORY); } } } @@ -1453,11 +1477,10 @@ const runInteractively = (commandDescriptions, autoExit) => { */ const helper = (extraText) => { const isBadWindows = IS_WINDOWS && !IS_WINDOWS_TERMINAL; - const lastLine = getLastLine(command.history); if ( command.isSimpleLog && - !UNFINISHED_ESCAPE.test(lastLine) && - (!isBadWindows || removeGraphicRenditions(lastLine) === "") + (!isBadWindows || + removeGraphicRenditions(getLastLine(command.history)) === "") ) { const numLines = extraText.split("\n").length; // `\x1BD` (IND) is like `\n` except the cursor column is preserved on diff --git a/test-clear-down.js b/test-clear-down.js index daa60dd..188d963 100644 --- a/test-clear-down.js +++ b/test-clear-down.js @@ -10,22 +10,29 @@ function delay(ms) { }); } +async function goUp() { + const CURSOR_UP = "\x1B[A"; + const split = 2; + process.stdout.write(CURSOR_UP.slice(0, split)); + await delay(1); + process.stdout.write(CURSOR_UP.slice(split)); +} + async function run() { const CLEAR = "\x1B[2J\x1B[3J\x1B[H"; const CLEAR_DOWN = "\x1B[0J"; const CLEAR_LINE = "\x1B[2K"; - const CURSOR_UP = "\x1B[A"; process.stdout.write("Apple: in progress\n"); await delay(100); process.stdout.write("Banana: in progress\n"); await delay(1000); - process.stdout.write(CURSOR_UP); - process.stdout.write(CURSOR_UP); + await goUp(); + await goUp(); process.stdout.write(`${CLEAR_LINE}Apple: done\n`); await delay(1000); process.stdout.write(`${CLEAR_LINE}Banana: done\n`); - process.stdout.write(CLEAR_DOWN); + process.stdout.write(`${CLEAR_DOWN}Success!`); await delay(2000); process.stdout.write(CLEAR); diff --git a/test-clear.js b/test-clear.js index b0218a4..42d5a8e 100644 --- a/test-clear.js +++ b/test-clear.js @@ -1,7 +1,5 @@ "use strict"; -const CURSOR_DOWN = "\x1B[B"; - /** * @template T * @param {Array} items @@ -38,8 +36,10 @@ const variants = [ const index = Number(process.argv[2]) || 0; -process.stdout.write(CURSOR_DOWN); +console.log("Number of variants:", variants.length); +console.log("Chosen variant (CLI arg 1):", index); console.log("Not a simple log"); +process.stdout.write("\x1B[2A"); setTimeout(() => { const f = variants[index]; diff --git a/test/run-pty.test.js b/test/run-pty.test.js index 57c2c0e..98b5e08 100644 --- a/test/run-pty.test.js +++ b/test/run-pty.test.js @@ -83,6 +83,8 @@ function fakeCommand(item, index = 0) { statusFromRules: item.statusFromRules, defaultStatus: undefined, statusRules: [], + windowsConptyCursorMoveWorkaround: false, + unfinishedEscapeBuffer: "", onData: () => notCalled("onData"), onRequest: () => notCalled("onRequest"), onExit: () => notCalled("onExit"), @@ -90,7 +92,6 @@ function fakeCommand(item, index = 0) { start: () => notCalled("start"), kill: () => notCalled("kill"), updateStatusFromRules: () => notCalled("updateStatusFromRules"), - windowsConptyCursorMoveWorkaround: false, }; }