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..338666e7 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(); @@ -169,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 0a88de49..439302a2 100644 --- a/packages/cli-core/src/lib/gradient.ts +++ b/packages/cli-core/src/lib/gradient.ts @@ -114,29 +114,63 @@ 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); + + // The cursor-up escape only lands on the header when the block fits on screen + // without scrolling. Fall back to a plain write on short terminals where the + // scroll would push the header out of reach. + if (process.stderr.rows != null && rowsBelow >= 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 + // 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; }