From 4b4eba7590c6728857ec2253822700aab73f009c Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 16 Jun 2026 20:06:12 -0300 Subject: [PATCH 1/2] fix(ui): render next-steps list before sweeping the header highlight animateHeader redrew only the header line in place, so the 'Next steps' header shimmered alone for the duration of the animation while the steps printed only afterward, making it look hung. Print the whole block up front, then step the cursor back up to sweep the header in place. Covers clerk link, clerk auth login, clerk init, and clerk deploy next-steps. --- .changeset/next-steps-animation-order.md | 5 +++ .../cli-core/src/commands/deploy/index.ts | 4 +-- packages/cli-core/src/lib/gradient.test.ts | 30 +++++++++++++++++ packages/cli-core/src/lib/gradient.ts | 32 +++++++++++++++++-- packages/cli-core/src/lib/log.ts | 2 +- packages/cli-core/src/lib/spinner.ts | 6 ++-- 6 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 .changeset/next-steps-animation-order.md diff --git a/.changeset/next-steps-animation-order.md b/.changeset/next-steps-animation-order.md new file mode 100644 index 00000000..18b05b09 --- /dev/null +++ b/.changeset/next-steps-animation-order.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Print the "Next steps" list before sweeping the highlight across its header, so the animation no longer leaves a lone "Next steps" line that looks hung while the steps are hidden. diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index e96719f3..73b5339e 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,7 +1,7 @@ import type { Program } from "../../cli-program.ts"; import { deployStatus } from "./status-command.ts"; import { isAgent } from "../../mode.ts"; -import { isInsideGutter, log } from "../../lib/log.ts"; +import { applyPrefix, isInsideGutter, log } from "../../lib/log.ts"; import { bold, dim } from "../../lib/color.ts"; import { animateHeader } from "../../lib/gradient.ts"; import { bar, intro, outro, pausedOutro, withSpinner } from "../../lib/spinner.ts"; @@ -633,8 +633,8 @@ async function finishDeploy( prefix: isInsideGutter() ? `${dim("│")} ` : "", label: "Next steps", fallback: bold, + body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId))}\n`, }); - log.info(nextStepsBody(ctx.appId, productionInstanceId)); outro("Success"); } diff --git a/packages/cli-core/src/lib/gradient.test.ts b/packages/cli-core/src/lib/gradient.test.ts index 1f3653e2..6f055021 100644 --- a/packages/cli-core/src/lib/gradient.test.ts +++ b/packages/cli-core/src/lib/gradient.test.ts @@ -112,6 +112,17 @@ describe("animateHeader (non-interactive)", () => { await animateHeader({ prefix: "│ ", label: "Next steps", fallback: bold }); expect(captured.stderr[0]).toBe(`│ ${bold("Next steps")}\n`); }); + + test("prints the header and body together in one write", async () => { + await animateHeader({ + prefix: "", + label: "Next steps", + fallback: bold, + body: " → a\n → b\n", + }); + expect(captured.stderr).toHaveLength(1); + expect(captured.stderr[0]).toBe(`${bold("Next steps")}\n → a\n → b\n`); + }); }); describe("animateHeader (interactive gating)", () => { @@ -145,6 +156,25 @@ describe("animateHeader (interactive gating)", () => { expect(captured.err).toContain("\x1b[?25h"); }); + test("prints the body before sweeping the header so it never looks hung", async () => { + await animateHeader({ + prefix: "", + label: "Hi", + fallback: bold, + body: " → a\n → b\n", + frames: 2, + intervalMs: 1, + }); + const out = captured.err; + expect(out).toContain("→ a"); + expect(out).toContain("→ b"); + // The body is written before the first in-place header redraw (\r). + expect(out.indexOf("→ a")).toBeLessThan(out.indexOf("\r")); + // The cursor steps up to the header (1 + 2 body newlines) and back down. + expect(out).toContain("\x1b[3A"); + expect(out).toContain("\x1b[3B"); + }); + test("NO_COLOR disables the animation (plain fallback, no redraw)", async () => { process.env.NO_COLOR = "1"; await run(); diff --git a/packages/cli-core/src/lib/gradient.ts b/packages/cli-core/src/lib/gradient.ts index 0a88de49..e6589840 100644 --- a/packages/cli-core/src/lib/gradient.ts +++ b/packages/cli-core/src/lib/gradient.ts @@ -114,29 +114,55 @@ interface AnimateHeaderOptions { prefix: string; label: string; fallback: (s: string) => string; + /** + * Text printed beneath the header (e.g. the next-steps list), including its + * own trailing newline(s). It is rendered up front so the whole block is on + * screen immediately; the highlight then sweeps across the header in place. + * Without it, only a lone header animates and looks hung until the caller + * prints the rest. + */ + body?: string; frames?: number; intervalMs?: number; write?: (s: string) => void; } export async function animateHeader(options: AnimateHeaderOptions): Promise { - const { prefix, label, fallback, frames = 18, intervalMs = 25, write = log.ui } = options; + const { + prefix, + label, + fallback, + body = "", + frames = 18, + intervalMs = 25, + write = log.ui, + } = options; if (!isInteractive() || colorDisabled()) { - write(`${prefix}${fallback(label)}\n`); + write(`${prefix}${fallback(label)}\n${body}`); return; } const truecolor = supportsTruecolor(); const span = Math.max(1, frames - 1); + // Rows the cursor ends below the header after the block is printed: the + // header's own newline plus every newline inside the body. + const rowsBelow = 1 + (body.match(/\n/g)?.length ?? 0); + hideCursor(); try { + // Print the whole block first so the body is visible immediately, then step + // back up to the header line and sweep the highlight across it. Only the + // header line is rewritten each frame, so the body stays put. + write(`${prefix}${shineText(label, { truecolor })}\n${body}`); + write(`\x1b[${rowsBelow}A`); for (let frame = 0; frame < frames; frame++) { const center = -0.3 + (frame / span) * 1.6; write(`\r\x1b[K${prefix}${shineText(label, { center, truecolor })}`); await sleep(intervalMs); } - write(`\r\x1b[K${prefix}${shineText(label, { truecolor })}\n`); + write(`\r\x1b[K${prefix}${shineText(label, { truecolor })}`); + write(`\x1b[${rowsBelow}B\r`); } finally { showCursor(); } diff --git a/packages/cli-core/src/lib/log.ts b/packages/cli-core/src/lib/log.ts index c5ee367a..18e6a5c2 100644 --- a/packages/cli-core/src/lib/log.ts +++ b/packages/cli-core/src/lib/log.ts @@ -44,7 +44,7 @@ export function isInsideGutter(): boolean { return prefixDepth > 0; } -function applyPrefix(msg: string): string { +export function applyPrefix(msg: string): string { if (prefixDepth === 0) return msg; const bar = dim(S_BAR); if (!msg) return bar; diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index 7457e3c0..b668c35c 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -54,16 +54,14 @@ export async function outro(messageOrSteps?: string | readonly string[]) { popPrefix(); if (Array.isArray(messageOrSteps)) { + const body = `${messageOrSteps.map((step) => ` ${cyan("→")} ${step}`).join("\n")}\n\n`; await animateHeader({ prefix: `${dim(S_BAR_END)} `, label: "Next steps", fallback: dim, + body, write: writeUi, }); - for (const step of messageOrSteps) { - writeUi(` ${cyan("→")} ${step}\n`); - } - writeUi("\n"); return; } From 4cd15ff4b3a6ecffeb130282c7c1f16023037365 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 18 Jun 2026 09:18:30 -0300 Subject: [PATCH 2/2] fix(ui): fall back to plain write when block exceeds terminal height The cursor-up escape that repositions the header only works when the block fits on screen without scrolling. With a tall next-steps body (~27 lines for deploy), a short terminal scrolls the buffer and the cursor-up no longer lands on the header, corrupting the output. Guard against this by comparing rowsBelow against process.stderr.rows before entering the animation path. --- packages/cli-core/src/lib/gradient.test.ts | 21 +++++++++++++++++++++ packages/cli-core/src/lib/gradient.ts | 8 ++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/cli-core/src/lib/gradient.test.ts b/packages/cli-core/src/lib/gradient.test.ts index 6f055021..338666e7 100644 --- a/packages/cli-core/src/lib/gradient.test.ts +++ b/packages/cli-core/src/lib/gradient.test.ts @@ -199,4 +199,25 @@ describe("animateHeader (interactive gating)", () => { await run(); expect(captured.err).toContain("\r"); }); + + test("falls back to a plain write when the block is taller than the terminal", async () => { + const savedRows = process.stderr.rows; + try { + Object.defineProperty(process.stderr, "rows", { value: 5, configurable: true }); + await animateHeader({ + prefix: "", + label: "Hi", + fallback: bold, + // 5 newlines → rowsBelow = 6, which exceeds rows = 5 + body: "a\nb\nc\nd\ne\n", + frames: 2, + intervalMs: 1, + }); + expect(captured.err).not.toContain("\r"); + expect(captured.err).not.toContain("\x1b[?25l"); + expect(captured.err).toContain("a"); + } finally { + Object.defineProperty(process.stderr, "rows", { value: savedRows, configurable: true }); + } + }); }); diff --git a/packages/cli-core/src/lib/gradient.ts b/packages/cli-core/src/lib/gradient.ts index e6589840..439302a2 100644 --- a/packages/cli-core/src/lib/gradient.ts +++ b/packages/cli-core/src/lib/gradient.ts @@ -149,6 +149,14 @@ export async function animateHeader(options: AnimateHeaderOptions): Promise= process.stderr.rows) { + write(`${prefix}${fallback(label)}\n${body}`); + return; + } + hideCursor(); try { // Print the whole block first so the body is visible immediately, then step