Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/lib/init/clack-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,32 @@ export const STEP_LABELS_SHORT: Record<string, string> = {
export function shortStepLabel(stepId: string): string {
return STEP_LABELS_SHORT[stepId] ?? STEP_LABELS[stepId] ?? stepId;
}

/**
* Rotating progress messages shown while the CLI waits on a long-running
* server-side phase that doesn't emit intermediate suspends. The messages
* cycle on a timer so the spinner text changes and the UI doesn't look
* frozen.
*
* Only steps with known long waits need entries here. Steps that suspend
* frequently (read-files, apply-codemods) already update the spinner via
* the suspend payload's `detail` field.
*/
export const STEP_PROGRESS_MESSAGES: Record<string, string[]> = {
"plan-codemods": [
"Fetching SDK documentation...",
"Analyzing integration requirements...",
"Generating code modification plan...",
"Reviewing planned changes for correctness...",
"Finalizing integration plan...",
],
"detect-platform": [
"Scanning project files...",
"Identifying framework and language...",
"Analyzing project configuration...",
"Determining SDK compatibility...",
],
};

/** Interval between rotating progress messages (ms). */
export const PROGRESS_ROTATE_INTERVAL_MS = 12_000;
113 changes: 104 additions & 9 deletions src/lib/init/wizard-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ import {
import { logger } from "../logger.js";
import {
abortIfCancelled,
PROGRESS_ROTATE_INTERVAL_MS,
STEP_ACTIVE_LABELS,
STEP_LABELS,
STEP_PROGRESS_MESSAGES,
WizardCancelledError,
} from "./clack-utils.js";
import {
Expand Down Expand Up @@ -216,6 +218,85 @@ function describePostTool(payload: SuspendPayload): string | undefined {
}
}

type ProgressRotationHandle = {
/** Stop the rotation timer permanently. */
stop: () => void;
/**
* Pause rotation so recovery paths (e.g. "Reconnecting...") can
* set the spinner without the next tick overwriting it.
*/
pause: () => void;
/** Resume rotation after a paused recovery completes. */
resume: () => void;
};

const NOOP_ROTATION: ProgressRotationHandle = {
stop: () => {
// No rotating messages for this step.
},
pause: () => {
// noop
},
resume: () => {
// noop
},
};

/**
* Start a rotating progress message timer for steps that have long
* server-side phases without intermediate suspends. Returns a handle
* to stop or pause the timer.
*
* The timer cycles through {@link STEP_PROGRESS_MESSAGES} for the given
* step, updating the spinner text every {@link PROGRESS_ROTATE_INTERVAL_MS}.
* After exhausting all messages, it appends elapsed time so the user
* knows the system is still working.
*
* The handle exposes `pause()`/`resume()` so that recovery paths inside
* `resumeWithRecovery` can temporarily suppress rotation while showing
* "Reconnecting..." without the next tick overwriting it.
*/
function startProgressRotation(
stepId: string,
spin: SpinnerHandle,
spinState: SpinState
): ProgressRotationHandle {
const messages = STEP_PROGRESS_MESSAGES[stepId];
if (!messages || messages.length === 0) {
return NOOP_ROTATION;
}

let index = -1;
let paused = false;
const startedAt = Date.now();

const timer = setInterval(() => {
if (!spinState.running || paused) {
return;
}
index += 1;
if (index < messages.length) {
spin.message(messages[index]);
} else {
const elapsedSec = Math.round((Date.now() - startedAt) / 1000);
const lastMessage = messages.at(-1) ?? messages[0];
spin.message(`${lastMessage} (${elapsedSec}s)`);
}
Comment thread
cursor[bot] marked this conversation as resolved.
}, PROGRESS_ROTATE_INTERVAL_MS);

return {
stop: () => {
clearInterval(timer);
},
pause: () => {
paused = true;
},
resume: () => {
paused = false;
},
};
}

// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: suspend handling needs to branch across tool and interactive payload kinds
async function handleSuspendedStep(
ctx: StepContext,
Expand Down Expand Up @@ -534,6 +615,7 @@ type ResumeRetryArgs = {
tracingOptions: Record<string, unknown>;
spin: SpinnerHandle;
ui: WizardUI;
progressRotation?: ProgressRotationHandle;
};

/**
Expand Down Expand Up @@ -660,6 +742,7 @@ async function resumeWithRecovery(
tracingOptions,
spin,
ui,
progressRotation,
} = args;
try {
const raw = await withTimeout(
Expand All @@ -673,6 +756,7 @@ async function resumeWithRecovery(
return assertWorkflowResult(raw);
} catch (err) {
if (isStepAlreadyAdvancedError(err)) {
progressRotation?.pause();
spin.message("Reconnecting...");
const recovered = await tryRecoverCurrentRunState(
workflow,
Expand Down Expand Up @@ -704,6 +788,7 @@ async function resumeWithRecovery(
throw err;
}

progressRotation?.pause();
ui.setOverlay?.({
kind: "health",
message: "Connection interrupted, reconnecting...",
Expand Down Expand Up @@ -956,16 +1041,26 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
stepHistory
);

result = await resumeWithRecovery({
run,
workflow,
stepId: extracted.stepId,
payload: extracted.payload,
resumeData,
tracingOptions,
const progressRotation = startProgressRotation(
extracted.stepId,
spin,
ui,
});
spinState
);
try {
result = await resumeWithRecovery({
run,
workflow,
stepId: extracted.stepId,
payload: extracted.payload,
resumeData,
tracingOptions,
spin,
ui,
progressRotation,
});
} finally {
progressRotation.stop();
}
}
} catch (err) {
const isAuthFailure = err instanceof ApiError && err.status === 401;
Expand Down
30 changes: 30 additions & 0 deletions test/lib/init/clack-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
abortIfCancelled,
featureHint,
featureLabel,
PROGRESS_ROTATE_INTERVAL_MS,
STEP_ACTIVE_LABELS,
STEP_PROGRESS_MESSAGES,
sortFeatures,
WizardCancelledError,
} from "../../../src/lib/init/clack-utils.js";
Expand Down Expand Up @@ -125,3 +128,30 @@ describe("sortFeatures", () => {
]);
});
});

describe("STEP_PROGRESS_MESSAGES", () => {
test("plan-codemods has multiple rotating messages", () => {
const messages = STEP_PROGRESS_MESSAGES["plan-codemods"];
expect(messages).toBeDefined();
expect(messages!.length).toBeGreaterThanOrEqual(3);
for (const msg of messages!) {
expect(msg).toMatch(/\.\.\.$/);
}
});

test("detect-platform has multiple rotating messages", () => {
const messages = STEP_PROGRESS_MESSAGES["detect-platform"];
expect(messages).toBeDefined();
expect(messages!.length).toBeGreaterThanOrEqual(2);
});

test("every step with progress messages also has an active label", () => {
for (const stepId of Object.keys(STEP_PROGRESS_MESSAGES)) {
expect(STEP_ACTIVE_LABELS[stepId]).toBeDefined();
}
});

test("rotation interval is a positive number", () => {
expect(PROGRESS_ROTATE_INTERVAL_MS).toBeGreaterThan(0);
});
});
Loading
Loading