diff --git a/cli/unstable_spinner.ts b/cli/unstable_spinner.ts index 6e9bbd498eaf..625abc3069b0 100644 --- a/cli/unstable_spinner.ts +++ b/cli/unstable_spinner.ts @@ -4,6 +4,15 @@ const encoder = new TextEncoder(); const LINE_CLEAR = encoder.encode("\r\u001b[K"); // From cli/prompt_secret.ts const COLOR_RESET = "\u001b[0m"; +// DECAWM (Auto-Wrap Mode) toggles. With DECAWM off, the terminal silently +// truncates anything past the right edge instead of wrapping to the next +// line. We disable it before each frame and re-enable it after, so the +// spinner never overflows and "\r\u001b[K" keeps clearing the same row +// even when the message is longer than the terminal width (#6975). +// Letting the terminal truncate avoids guessing display widths for emoji, +// ZWJ sequences, combining marks, etc. +const DECAWM_OFF = encoder.encode("\u001b[?7l"); +const DECAWM_ON = encoder.encode("\u001b[?7h"); const DEFAULT_INTERVAL = 75; const DEFAULT_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -222,18 +231,34 @@ export class Spinner { let i = 0; const noColor = Deno.noColor; + // Cache the TTY check so we don't probe the stream every frame. + const isTty = this.#isTerminal(); + // Updates the spinner after the given interval. const updateFrame = () => { const color = this.#color ?? ""; + const spinnerChar = this.#spinner[i] ?? ""; const frame = encoder.encode( noColor - ? this.#spinner[i] + " " + this.message - : color + this.#spinner[i] + COLOR_RESET + " " + this.message, + ? spinnerChar + " " + this.message + : color + spinnerChar + COLOR_RESET + " " + this.message, ); - // call writeSync once to reduce flickering - const writeData = new Uint8Array(LINE_CLEAR.length + frame.length); - writeData.set(LINE_CLEAR); - writeData.set(frame, LINE_CLEAR.length); + // On a TTY, bracket the frame with DECAWM off/on so any overflow is + // truncated by the terminal instead of wrapping to the next line + // (#6975). On non-TTY streams the literal escape bytes would just + // clutter the output, so skip them. + const parts: Uint8Array[] = isTty + ? [LINE_CLEAR, DECAWM_OFF, frame, DECAWM_ON] + : [LINE_CLEAR, frame]; + let total = 0; + for (const part of parts) total += part.length; + const writeData = new Uint8Array(total); + let off = 0; + for (const part of parts) { + writeData.set(part, off); + off += part.length; + } + // single writeSync to reduce flickering this.#output.writeSync(writeData); i = (i + 1) % this.#spinner.length; }; @@ -242,6 +267,20 @@ export class Spinner { updateFrame(); } + /** + * Returns whether the spinner is writing to an interactive terminal. + * Decides whether to emit DECAWM toggles around each frame. + */ + #isTerminal(): boolean { + try { + return this.#output.isTerminal(); + } catch { + // `isTerminal()` can throw if the stream is closed or unsupported; + // treat as "not a TTY" and skip the wrap toggles. + return false; + } + } + /** * Stops the spinner. * diff --git a/cli/unstable_spinner_test.ts b/cli/unstable_spinner_test.ts index b8c6575d2be9..819901859d38 100644 --- a/cli/unstable_spinner_test.ts +++ b/cli/unstable_spinner_test.ts @@ -623,6 +623,95 @@ Deno.test("Spinner.message can be updated", async () => { } }); +Deno.test( + "Spinner brackets each TTY frame with DECAWM off/on so the terminal truncates overflow (#6975)", + async () => { + try { + // The message is no longer truncated in user space; we emit \x1b[?7l + // before the frame and \x1b[?7h after, and the terminal silently drops + // anything past the right edge instead of wrapping. + const message = "x".repeat(50); + const expectedOutput = [ + `\r\x1b[K\x1b[?7l⠋\x1b[0m ${message}\x1b[?7h`, + `\r\x1b[K\x1b[?7l⠙\x1b[0m ${message}\x1b[?7h`, + `\r\x1b[K\x1b[?7l⠹\x1b[0m ${message}\x1b[?7h`, + "\r\x1b[K", + ]; + + const actualOutput: string[] = []; + + let resolvePromise: (value: void | PromiseLike) => void; + const promise = new Promise((resolve) => resolvePromise = resolve); + + stub(Deno.stdout, "isTerminal", () => true); + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + if (actualOutput.length === expectedOutput.length - 1) { + resolvePromise(); + } + return data.length; + }, + ); + + const spinner = new Spinner({ message }); + spinner.start(); + await promise; + spinner.stop(); + assertEquals(actualOutput, expectedOutput); + } finally { + restore(); + } + }, +); + +Deno.test( + "Spinner does not emit DECAWM toggles when output is not a TTY (#6975)", + async () => { + try { + // On a piped/redirected stream the literal escape bytes would just + // clutter the output, so isTerminal()=false → skip the toggles and + // write the message as-is. + const message = "x".repeat(50); + const expectedOutput = [ + `\r\x1b[K⠋\x1b[0m ${message}`, + `\r\x1b[K⠙\x1b[0m ${message}`, + "\r\x1b[K", + ]; + + const actualOutput: string[] = []; + + let resolvePromise: (value: void | PromiseLike) => void; + const promise = new Promise((resolve) => resolvePromise = resolve); + + stub(Deno.stdout, "isTerminal", () => false); + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + if (actualOutput.length === expectedOutput.length - 1) { + resolvePromise(); + } + return data.length; + }, + ); + + const spinner = new Spinner({ message }); + spinner.start(); + await promise; + spinner.stop(); + assertEquals(actualOutput, expectedOutput); + } finally { + restore(); + } + }, +); + Deno.test("Spinner handles multiple start() calls", () => { const spinner = new Spinner();