Skip to content

Commit d8f849b

Browse files
authored
fix(ui): print next steps before animating the "Next steps" header (#343)
* 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. * 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.
1 parent 694188c commit d8f849b

6 files changed

Lines changed: 98 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"clerk": patch
3+
---
4+
5+
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.

packages/cli-core/src/commands/deploy/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Program } from "../../cli-program.ts";
22
import { deployStatus } from "./status-command.ts";
33
import { isAgent } from "../../mode.ts";
4-
import { isInsideGutter, log } from "../../lib/log.ts";
4+
import { applyPrefix, isInsideGutter, log } from "../../lib/log.ts";
55
import { bold, dim } from "../../lib/color.ts";
66
import { animateHeader } from "../../lib/gradient.ts";
77
import { bar, intro, outro, pausedOutro, withSpinner } from "../../lib/spinner.ts";
@@ -633,8 +633,8 @@ async function finishDeploy(
633633
prefix: isInsideGutter() ? `${dim("│")} ` : "",
634634
label: "Next steps",
635635
fallback: bold,
636+
body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId))}\n`,
636637
});
637-
log.info(nextStepsBody(ctx.appId, productionInstanceId));
638638
outro("Success");
639639
}
640640

packages/cli-core/src/lib/gradient.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,17 @@ describe("animateHeader (non-interactive)", () => {
112112
await animateHeader({ prefix: "│ ", label: "Next steps", fallback: bold });
113113
expect(captured.stderr[0]).toBe(`│ ${bold("Next steps")}\n`);
114114
});
115+
116+
test("prints the header and body together in one write", async () => {
117+
await animateHeader({
118+
prefix: "",
119+
label: "Next steps",
120+
fallback: bold,
121+
body: " → a\n → b\n",
122+
});
123+
expect(captured.stderr).toHaveLength(1);
124+
expect(captured.stderr[0]).toBe(`${bold("Next steps")}\n → a\n → b\n`);
125+
});
115126
});
116127

117128
describe("animateHeader (interactive gating)", () => {
@@ -145,6 +156,25 @@ describe("animateHeader (interactive gating)", () => {
145156
expect(captured.err).toContain("\x1b[?25h");
146157
});
147158

159+
test("prints the body before sweeping the header so it never looks hung", async () => {
160+
await animateHeader({
161+
prefix: "",
162+
label: "Hi",
163+
fallback: bold,
164+
body: " → a\n → b\n",
165+
frames: 2,
166+
intervalMs: 1,
167+
});
168+
const out = captured.err;
169+
expect(out).toContain("→ a");
170+
expect(out).toContain("→ b");
171+
// The body is written before the first in-place header redraw (\r).
172+
expect(out.indexOf("→ a")).toBeLessThan(out.indexOf("\r"));
173+
// The cursor steps up to the header (1 + 2 body newlines) and back down.
174+
expect(out).toContain("\x1b[3A");
175+
expect(out).toContain("\x1b[3B");
176+
});
177+
148178
test("NO_COLOR disables the animation (plain fallback, no redraw)", async () => {
149179
process.env.NO_COLOR = "1";
150180
await run();
@@ -169,4 +199,25 @@ describe("animateHeader (interactive gating)", () => {
169199
await run();
170200
expect(captured.err).toContain("\r");
171201
});
202+
203+
test("falls back to a plain write when the block is taller than the terminal", async () => {
204+
const savedRows = process.stderr.rows;
205+
try {
206+
Object.defineProperty(process.stderr, "rows", { value: 5, configurable: true });
207+
await animateHeader({
208+
prefix: "",
209+
label: "Hi",
210+
fallback: bold,
211+
// 5 newlines → rowsBelow = 6, which exceeds rows = 5
212+
body: "a\nb\nc\nd\ne\n",
213+
frames: 2,
214+
intervalMs: 1,
215+
});
216+
expect(captured.err).not.toContain("\r");
217+
expect(captured.err).not.toContain("\x1b[?25l");
218+
expect(captured.err).toContain("a");
219+
} finally {
220+
Object.defineProperty(process.stderr, "rows", { value: savedRows, configurable: true });
221+
}
222+
});
172223
});

packages/cli-core/src/lib/gradient.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,29 +114,63 @@ interface AnimateHeaderOptions {
114114
prefix: string;
115115
label: string;
116116
fallback: (s: string) => string;
117+
/**
118+
* Text printed beneath the header (e.g. the next-steps list), including its
119+
* own trailing newline(s). It is rendered up front so the whole block is on
120+
* screen immediately; the highlight then sweeps across the header in place.
121+
* Without it, only a lone header animates and looks hung until the caller
122+
* prints the rest.
123+
*/
124+
body?: string;
117125
frames?: number;
118126
intervalMs?: number;
119127
write?: (s: string) => void;
120128
}
121129

122130
export async function animateHeader(options: AnimateHeaderOptions): Promise<void> {
123-
const { prefix, label, fallback, frames = 18, intervalMs = 25, write = log.ui } = options;
131+
const {
132+
prefix,
133+
label,
134+
fallback,
135+
body = "",
136+
frames = 18,
137+
intervalMs = 25,
138+
write = log.ui,
139+
} = options;
124140

125141
if (!isInteractive() || colorDisabled()) {
126-
write(`${prefix}${fallback(label)}\n`);
142+
write(`${prefix}${fallback(label)}\n${body}`);
127143
return;
128144
}
129145

130146
const truecolor = supportsTruecolor();
131147
const span = Math.max(1, frames - 1);
148+
// Rows the cursor ends below the header after the block is printed: the
149+
// header's own newline plus every newline inside the body.
150+
const rowsBelow = 1 + (body.match(/\n/g)?.length ?? 0);
151+
152+
// The cursor-up escape only lands on the header when the block fits on screen
153+
// without scrolling. Fall back to a plain write on short terminals where the
154+
// scroll would push the header out of reach.
155+
if (process.stderr.rows != null && rowsBelow >= process.stderr.rows) {
156+
write(`${prefix}${fallback(label)}\n${body}`);
157+
return;
158+
}
159+
132160
hideCursor();
133161
try {
162+
// Print the whole block first so the body is visible immediately, then step
163+
// back up to the header line and sweep the highlight across it. Only the
164+
// header line is rewritten each frame, so the body stays put.
165+
write(`${prefix}${shineText(label, { truecolor })}\n${body}`);
166+
write(`\x1b[${rowsBelow}A`);
134167
for (let frame = 0; frame < frames; frame++) {
135168
const center = -0.3 + (frame / span) * 1.6;
136169
write(`\r\x1b[K${prefix}${shineText(label, { center, truecolor })}`);
137170
await sleep(intervalMs);
138171
}
139-
write(`\r\x1b[K${prefix}${shineText(label, { truecolor })}\n`);
172+
write(`\r\x1b[K${prefix}${shineText(label, { truecolor })}`);
173+
write(`\x1b[${rowsBelow}B\r`);
140174
} finally {
141175
showCursor();
142176
}

packages/cli-core/src/lib/log.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function isInsideGutter(): boolean {
4444
return prefixDepth > 0;
4545
}
4646

47-
function applyPrefix(msg: string): string {
47+
export function applyPrefix(msg: string): string {
4848
if (prefixDepth === 0) return msg;
4949
const bar = dim(S_BAR);
5050
if (!msg) return bar;

packages/cli-core/src/lib/spinner.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,14 @@ export async function outro(messageOrSteps?: string | readonly string[]) {
5454
popPrefix();
5555

5656
if (Array.isArray(messageOrSteps)) {
57+
const body = `${messageOrSteps.map((step) => ` ${cyan("→")} ${step}`).join("\n")}\n\n`;
5758
await animateHeader({
5859
prefix: `${dim(S_BAR_END)} `,
5960
label: "Next steps",
6061
fallback: dim,
62+
body,
6163
write: writeUi,
6264
});
63-
for (const step of messageOrSteps) {
64-
writeUi(` ${cyan("→")} ${step}\n`);
65-
}
66-
writeUi("\n");
6765
return;
6866
}
6967

0 commit comments

Comments
 (0)