From e795f6ff4693aae01367bec63e40bd5f85434ac7 Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Tue, 26 May 2026 14:10:24 -0700 Subject: [PATCH 1/2] fix(cli): truncate Spinner message to terminal width on TTY output (#6975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the spinner message is wider than the terminal, `\r[K` only clears the line the cursor was on. The next frame then prints a wrapped block again, so each tick adds another stack of stale lines instead of overwriting the previous one. The repro from the issue (`new Spinner({ message: "x".repeat(1000) }).start()`) scrolls the terminal indefinitely. Truncate the rendered message so the visual width of the prefix (`spinner glyph + space`) plus the message stays within `Deno.consoleSize().columns`. Width is measured with the existing `unicodeWidth` helper so wide CJK / emoji characters that would straddle the boundary are dropped rather than half-rendered. Truncation only happens when the output is a TTY. Piped or redirected output (where wrapping is not visually catastrophic) keeps the full message so logs are still grep-able. This matches the approach BlackAsLight and tomas-zijdemans agreed on in the issue thread — simpler than save/restore cursor and self-contained within `@std/cli/unstable-spinner`. Adds three tests in `cli/unstable_spinner_test.ts`: ASCII truncation to a stubbed 20-column terminal, emoji truncation to 10 columns (4 of the 10 input dinos fit), and non-TTY no-truncation. All 14 spinner tests pass; lint and typecheck clean. --- cli/unstable_spinner.ts | 57 +++++++++++++- cli/unstable_spinner_test.ts | 139 +++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 2 deletions(-) diff --git a/cli/unstable_spinner.ts b/cli/unstable_spinner.ts index 6e9bbd498eaf..dd90435fea8f 100644 --- a/cli/unstable_spinner.ts +++ b/cli/unstable_spinner.ts @@ -1,5 +1,7 @@ // Copyright 2018-2026 the Deno authors. MIT license. +import { unicodeWidth } from "./unicode_width.ts"; + const encoder = new TextEncoder(); const LINE_CLEAR = encoder.encode("\r\u001b[K"); // From cli/prompt_secret.ts @@ -35,6 +37,24 @@ export type Color = | "gray" | Ansi; +/** + * Truncate `str` so its `unicodeWidth` is at most `maxWidth`. Iterates by + * code point so surrogate pairs are kept intact; wide CJK characters that + * would straddle the limit are dropped rather than half-rendered. + */ +function truncateToWidth(str: string, maxWidth: number): string { + if (maxWidth <= 0) return ""; + let acc = ""; + let accWidth = 0; + for (const ch of str) { + const w = unicodeWidth(ch); + if (accWidth + w > maxWidth) break; + acc += ch; + accWidth += w; + } + return acc; +} + const COLORS: Record = { black: "\u001b[30m", red: "\u001b[31m", @@ -225,10 +245,26 @@ export class Spinner { // Updates the spinner after the given interval. const updateFrame = () => { const color = this.#color ?? ""; + const spinnerChar = this.#spinner[i] ?? ""; + // #6975: when the spinner + message visual width exceeds the + // terminal width, the terminal wraps to a new line and `\r` + // no longer clears the previous frame, leaving stale output. Truncate + // the message to the available columns so each frame stays on one + // line. ANSI color/reset escapes have no visible width, so only the + // glyph + space prefix is subtracted. + let displayMessage = this.message; + const columns = this.#terminalColumns(); + if (columns !== undefined) { + const prefixWidth = unicodeWidth(spinnerChar) + 1; // glyph + space + const available = Math.max(0, columns - prefixWidth); + if (unicodeWidth(displayMessage) > available) { + displayMessage = truncateToWidth(displayMessage, available); + } + } const frame = encoder.encode( noColor - ? this.#spinner[i] + " " + this.message - : color + this.#spinner[i] + COLOR_RESET + " " + this.message, + ? spinnerChar + " " + displayMessage + : color + spinnerChar + COLOR_RESET + " " + displayMessage, ); // call writeSync once to reduce flickering const writeData = new Uint8Array(LINE_CLEAR.length + frame.length); @@ -242,6 +278,23 @@ export class Spinner { updateFrame(); } + /** + * Returns the terminal column count if the spinner is writing to a TTY, + * otherwise undefined (so the message is not truncated when output is + * piped or redirected). + */ + #terminalColumns(): number | undefined { + try { + if (!this.#output.isTerminal()) return undefined; + const { columns } = Deno.consoleSize(); + return columns > 0 ? columns : undefined; + } catch { + // `Deno.consoleSize()` and `isTerminal()` can throw if the stream is + // closed or unsupported; treat as "unknown size" and skip truncation. + return undefined; + } + } + /** * Stops the spinner. * diff --git a/cli/unstable_spinner_test.ts b/cli/unstable_spinner_test.ts index b8c6575d2be9..dfa717426f01 100644 --- a/cli/unstable_spinner_test.ts +++ b/cli/unstable_spinner_test.ts @@ -623,6 +623,145 @@ Deno.test("Spinner.message can be updated", async () => { } }); +Deno.test( + "Spinner truncates message to terminal width when output is a TTY (#6975)", + async () => { + try { + // 20 columns, prefix is 2 (spinner glyph + space), so 18 visible + // characters of the message should make it through. + const columns = 20; + const message = "x".repeat(50); + const truncatedMessage = "x".repeat(columns - 2); // 18 x's + + const expectedOutput = [ + `\r\x1b[K⠋\x1b[0m ${truncatedMessage}`, + `\r\x1b[K⠙\x1b[0m ${truncatedMessage}`, + `\r\x1b[K⠹\x1b[0m ${truncatedMessage}`, + "\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, "consoleSize", () => ({ columns, rows: 24 })); + 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 truncate when output is not a TTY (#6975)", + async () => { + try { + // No isTerminal stub → real test env reports false → truncation off. + // Even with consoleSize stubbed narrow, the full message is rendered + // so piped/redirected output stays complete. + 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, "consoleSize", () => ({ columns: 10, rows: 24 })); + 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 truncates respecting wide-character (emoji) width (#6975)", + async () => { + try { + // Each 🦕 reports unicodeWidth 2. With 10 columns and 2-col prefix, + // available = 8 → exactly 4 dinos fit, the rest drop. + const columns = 10; + const message = "🦕".repeat(10); + const truncatedMessage = "🦕".repeat(4); + + const expectedOutput = [ + `\r\x1b[K⠋\x1b[0m ${truncatedMessage}`, + "\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, "consoleSize", () => ({ columns, rows: 24 })); + 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(); From 1fe7d55068a88723ca3b53bc261c000df9a56321 Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Fri, 29 May 2026 16:45:18 -0700 Subject: [PATCH 2/2] Address review: use DECAWM toggle instead of unicodeWidth truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @BlackAsLight's review on #7157: drop the unicode_width dependency and let the terminal do the truncation by toggling DECAWM (\\u001b[?7l off / \\u001b[?7h on) around each frame. The terminal silently drops anything past the right edge so \\r\\u001b[K keeps clearing the same row. Avoids guessing display widths for emoji, ZWJ sequences, combining marks, and unusual terminal Unicode handling — the terminal already knows. Drops the truncateToWidth + #terminalColumns helpers and the unicodeWidth import. The TTY-vs-not check is preserved: on non-TTY output we don't emit the toggles, so piped/redirected output stays clean of escape bytes. Tests updated: - The TTY case now asserts the full frame is bracketed by DECAWM toggles (no user-space truncation expected). - The non-TTY case unchanged in spirit (full message, no toggles). - The wide-character/emoji truncation test is removed: width is no longer computed in JS. --- cli/unstable_spinner.ts | 88 +++++++++++++++--------------------- cli/unstable_spinner_test.ts | 72 +++++------------------------ 2 files changed, 48 insertions(+), 112 deletions(-) diff --git a/cli/unstable_spinner.ts b/cli/unstable_spinner.ts index dd90435fea8f..625abc3069b0 100644 --- a/cli/unstable_spinner.ts +++ b/cli/unstable_spinner.ts @@ -1,11 +1,18 @@ // Copyright 2018-2026 the Deno authors. MIT license. -import { unicodeWidth } from "./unicode_width.ts"; - 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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -37,24 +44,6 @@ export type Color = | "gray" | Ansi; -/** - * Truncate `str` so its `unicodeWidth` is at most `maxWidth`. Iterates by - * code point so surrogate pairs are kept intact; wide CJK characters that - * would straddle the limit are dropped rather than half-rendered. - */ -function truncateToWidth(str: string, maxWidth: number): string { - if (maxWidth <= 0) return ""; - let acc = ""; - let accWidth = 0; - for (const ch of str) { - const w = unicodeWidth(ch); - if (accWidth + w > maxWidth) break; - acc += ch; - accWidth += w; - } - return acc; -} - const COLORS: Record = { black: "\u001b[30m", red: "\u001b[31m", @@ -242,34 +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] ?? ""; - // #6975: when the spinner + message visual width exceeds the - // terminal width, the terminal wraps to a new line and `\r` - // no longer clears the previous frame, leaving stale output. Truncate - // the message to the available columns so each frame stays on one - // line. ANSI color/reset escapes have no visible width, so only the - // glyph + space prefix is subtracted. - let displayMessage = this.message; - const columns = this.#terminalColumns(); - if (columns !== undefined) { - const prefixWidth = unicodeWidth(spinnerChar) + 1; // glyph + space - const available = Math.max(0, columns - prefixWidth); - if (unicodeWidth(displayMessage) > available) { - displayMessage = truncateToWidth(displayMessage, available); - } - } const frame = encoder.encode( noColor - ? spinnerChar + " " + displayMessage - : color + spinnerChar + COLOR_RESET + " " + displayMessage, + ? 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; }; @@ -279,19 +268,16 @@ export class Spinner { } /** - * Returns the terminal column count if the spinner is writing to a TTY, - * otherwise undefined (so the message is not truncated when output is - * piped or redirected). + * Returns whether the spinner is writing to an interactive terminal. + * Decides whether to emit DECAWM toggles around each frame. */ - #terminalColumns(): number | undefined { + #isTerminal(): boolean { try { - if (!this.#output.isTerminal()) return undefined; - const { columns } = Deno.consoleSize(); - return columns > 0 ? columns : undefined; + return this.#output.isTerminal(); } catch { - // `Deno.consoleSize()` and `isTerminal()` can throw if the stream is - // closed or unsupported; treat as "unknown size" and skip truncation. - return undefined; + // `isTerminal()` can throw if the stream is closed or unsupported; + // treat as "not a TTY" and skip the wrap toggles. + return false; } } diff --git a/cli/unstable_spinner_test.ts b/cli/unstable_spinner_test.ts index dfa717426f01..819901859d38 100644 --- a/cli/unstable_spinner_test.ts +++ b/cli/unstable_spinner_test.ts @@ -624,19 +624,17 @@ Deno.test("Spinner.message can be updated", async () => { }); Deno.test( - "Spinner truncates message to terminal width when output is a TTY (#6975)", + "Spinner brackets each TTY frame with DECAWM off/on so the terminal truncates overflow (#6975)", async () => { try { - // 20 columns, prefix is 2 (spinner glyph + space), so 18 visible - // characters of the message should make it through. - const columns = 20; + // 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 truncatedMessage = "x".repeat(columns - 2); // 18 x's - const expectedOutput = [ - `\r\x1b[K⠋\x1b[0m ${truncatedMessage}`, - `\r\x1b[K⠙\x1b[0m ${truncatedMessage}`, - `\r\x1b[K⠹\x1b[0m ${truncatedMessage}`, + `\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", ]; @@ -646,7 +644,6 @@ Deno.test( const promise = new Promise((resolve) => resolvePromise = resolve); stub(Deno.stdout, "isTerminal", () => true); - stub(Deno, "consoleSize", () => ({ columns, rows: 24 })); stub( Deno.stdout, "writeSync", @@ -672,12 +669,12 @@ Deno.test( ); Deno.test( - "Spinner does not truncate when output is not a TTY (#6975)", + "Spinner does not emit DECAWM toggles when output is not a TTY (#6975)", async () => { try { - // No isTerminal stub → real test env reports false → truncation off. - // Even with consoleSize stubbed narrow, the full message is rendered - // so piped/redirected output stays complete. + // 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}`, @@ -691,53 +688,6 @@ Deno.test( const promise = new Promise((resolve) => resolvePromise = resolve); stub(Deno.stdout, "isTerminal", () => false); - stub(Deno, "consoleSize", () => ({ columns: 10, rows: 24 })); - 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 truncates respecting wide-character (emoji) width (#6975)", - async () => { - try { - // Each 🦕 reports unicodeWidth 2. With 10 columns and 2-col prefix, - // available = 8 → exactly 4 dinos fit, the rest drop. - const columns = 10; - const message = "🦕".repeat(10); - const truncatedMessage = "🦕".repeat(4); - - const expectedOutput = [ - `\r\x1b[K⠋\x1b[0m ${truncatedMessage}`, - "\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, "consoleSize", () => ({ columns, rows: 24 })); stub( Deno.stdout, "writeSync",