diff --git a/packages/spark/src/clack-wizard.ts b/packages/spark/src/clack-wizard.ts index d6f36cc..6bdc1fd 100644 --- a/packages/spark/src/clack-wizard.ts +++ b/packages/spark/src/clack-wizard.ts @@ -83,6 +83,29 @@ const MANUAL_INSTRUMENTATION_CHOICE: ManualInstrumentationChoice = { label: COPY.instrumentation.modes.manual.label, }; +class WizardStepSpinner { + private spinner: ReturnType | undefined; + private active = false; + + update(message: string): void { + if (this.active) { + this.spinner!.message(message); + return; + } + + this.spinner ??= clack.spinner(); + this.spinner.start(message); + this.active = true; + } + + clear(): void { + if (!this.active) return; + + this.spinner!.clear(); + this.active = false; + } +} + type BooleanSelectChoices = { readonly yes: { readonly label: string; @@ -198,9 +221,14 @@ export async function runClackWizard(deps: WizardDeps): Promise { const envFilePath = await writeLocalEnvBraintrust(deps, session.apiKey); - await handleBraintrustCliSetup(deps, session); - - const codingToolStatuses = await preflightCodingTools(deps); + const setupSpinner = new WizardStepSpinner(); + let codingToolStatuses: readonly CodingToolStatus[]; + try { + await handleBraintrustCliSetup(deps, session, setupSpinner); + codingToolStatuses = await preflightCodingTools(deps, setupSpinner); + } finally { + setupSpinner.clear(); + } const hasUsableCodingTool = codingToolStatuses.some( (status) => status.usable, ); @@ -360,25 +388,10 @@ async function selectInstrumentationMode(args: { async function handleBraintrustCliSetup( deps: WizardDeps, session: WizardSessionCompleteResult, + spinner: WizardStepSpinner, ): Promise { let discovery = await deps.braintrustCli.discover(); let commandPath = discovery.commandPath; - const spinner = clack.spinner(); - let spinnerActive = false; - const updateSpinner = (message: string) => { - if (spinnerActive) { - spinner.message(message); - } else { - spinner.start(message); - spinnerActive = true; - } - }; - const clearSpinner = () => { - if (spinnerActive) { - spinner.clear(); - spinnerActive = false; - } - }; if (discovery.installed) { const installedLabel = @@ -391,13 +404,13 @@ async function handleBraintrustCliSetup( yesFirst: true, }); if (shouldUpdate && commandPath) { - updateSpinner(COPY.braintrustCli.updating); + spinner.update(COPY.braintrustCli.updating); try { await deps.braintrustCli.update(commandPath); discovery = await deps.braintrustCli.discover(); commandPath = discovery.commandPath ?? commandPath; } catch (error) { - clearSpinner(); + spinner.clear(); clack.log.warn( COPY.braintrustCli.updateFailed(summarizeBraintrustCliError(error)), ); @@ -411,11 +424,11 @@ async function handleBraintrustCliSetup( }); if (!shouldInstall) return; - updateSpinner(COPY.braintrustCli.installing); + spinner.update(COPY.braintrustCli.installing); try { await deps.braintrustCli.install(); } catch (error) { - clearSpinner(); + spinner.clear(); clack.log.warn( COPY.braintrustCli.installFailed(summarizeBraintrustCliError(error)), ); @@ -425,7 +438,7 @@ async function handleBraintrustCliSetup( discovery = await deps.braintrustCli.discover(); commandPath = discovery.commandPath; if (!discovery.installed || !commandPath) { - clearSpinner(); + spinner.clear(); clack.log.warn(COPY.braintrustCli.installedButNotFound); return; } @@ -434,11 +447,11 @@ async function handleBraintrustCliSetup( if (!commandPath) return; let currentContext: BraintrustCliContext; - updateSpinner(COPY.braintrustCli.checkingContext); + spinner.update(COPY.braintrustCli.checkingContext); try { currentContext = await deps.braintrustCli.status(commandPath); } catch (error) { - clearSpinner(); + spinner.clear(); clack.log.warn( COPY.braintrustCli.statusFailed(summarizeBraintrustCliError(error)), ); @@ -451,7 +464,7 @@ async function handleBraintrustCliSetup( project: session.projectName, }; if (braintrustCliContextConflicts(currentContext, targetContext)) { - clearSpinner(); + spinner.clear(); const shouldSwitch = await selectBoolean({ message: COPY.braintrustCli.switchContextQuestion({ currentContext, @@ -465,7 +478,7 @@ async function handleBraintrustCliSetup( } } - updateSpinner(COPY.braintrustCli.configuringContext); + spinner.update(COPY.braintrustCli.configuringContext); try { await deps.braintrustCli.loginAndSwitch(commandPath, { apiKey: session.apiKey, @@ -474,9 +487,8 @@ async function handleBraintrustCliSetup( orgName: session.orgName, projectName: session.projectName, }); - clearSpinner(); } catch (error) { - clearSpinner(); + spinner.clear(); clack.log.warn( COPY.braintrustCli.configureFailed(summarizeBraintrustCliError(error)), ); @@ -504,9 +516,9 @@ function braintrustCliContextConflicts( async function preflightCodingTools( deps: WizardDeps, + spinner: WizardStepSpinner, ): Promise { - const spinner = clack.spinner(); - spinner.start(COPY.instrumentation.builtIn.determiningAvailable); + spinner.update(COPY.instrumentation.builtIn.determiningAvailable); try { const statuses = await deps.codingTools.discover(); return await Promise.all( @@ -746,9 +758,9 @@ async function runInstrumentation( renderer.start(); let toolResult: CodingToolRunResult; - setTimeout(() => { + const spinnerDelay = setTimeout(() => { spinner.start(COPY.instrumentation.builtIn.running(toolLabel)); - }, 50); + }, 150); try { toolResult = await deps.codingTools.run({ id: args.toolId, @@ -765,6 +777,7 @@ async function runInstrumentation( await renderer.error(COPY.instrumentation.builtIn.codingAgentFailed); throw error; } finally { + clearTimeout(spinnerDelay); spinner.clear(); } diff --git a/packages/spark/test/clack-wizard.test.ts b/packages/spark/test/clack-wizard.test.ts index 27cd85b..d8a1b69 100644 --- a/packages/spark/test/clack-wizard.test.ts +++ b/packages/spark/test/clack-wizard.test.ts @@ -603,6 +603,20 @@ describe("runClackWizard", () => { expect(events).toContain("taskLog.success:Instrumentation complete."); }); + it("does not start the delayed coding agent spinner after a fast run", async () => { + const { events } = createPrompts({ + selects: ["yes", "no", "first", "proceed", "understood"], + }); + const deps = buildDeps(); + + await runClackWizard(deps); + await new Promise((resolve) => setTimeout(resolve, 60)); + + expect(events).not.toContain( + "spinner.start:Checking Claude Code can run...", + ); + }); + it("passes browser auth mode based on the account answer", async () => { const cases = [ { answer: true, expectedAuthMode: "signin" }, @@ -768,9 +782,25 @@ describe("runClackWizard", () => { expect(events).toContain( "spinner.message:Configuring Braintrust CLI context...", ); + expect(events).toContain( + "spinner.message:Determining available coding agents...", + ); expect(events).toContain("spinner.clear"); expect(events).not.toContain("spinner.stop:Installed Braintrust CLI."); expect(events).not.toContain("success:Configured Braintrust CLI."); + const configureSpinnerIndex = events.indexOf( + "spinner.message:Configuring Braintrust CLI context...", + ); + const codingAgentSpinnerIndex = events.indexOf( + "spinner.message:Determining available coding agents...", + ); + expect(codingAgentSpinnerIndex).toBeGreaterThan(configureSpinnerIndex); + expect( + events.slice(configureSpinnerIndex, codingAgentSpinnerIndex), + ).not.toContain("spinner.clear"); + expect(events).not.toContain( + "spinner.start:Determining available coding agents...", + ); expect( events.indexOf("spinner.message:Checking Braintrust CLI context..."), ).toBeLessThan(events.indexOf(`select:${INSTRUMENTATION_MODE_MESSAGE}`)); @@ -882,9 +912,25 @@ describe("runClackWizard", () => { expect(events).toContain( "spinner.message:Configuring Braintrust CLI context...", ); + expect(events).toContain( + "spinner.message:Determining available coding agents...", + ); expect(events).toContain("spinner.clear"); expect(events).not.toContain("spinner.stop:Updated Braintrust CLI."); expect(events).not.toContain("success:Configured Braintrust CLI."); + const configureSpinnerIndex = events.indexOf( + "spinner.message:Configuring Braintrust CLI context...", + ); + const codingAgentSpinnerIndex = events.indexOf( + "spinner.message:Determining available coding agents...", + ); + expect(codingAgentSpinnerIndex).toBeGreaterThan(configureSpinnerIndex); + expect( + events.slice(configureSpinnerIndex, codingAgentSpinnerIndex), + ).not.toContain("spinner.clear"); + expect(events).not.toContain( + "spinner.start:Determining available coding agents...", + ); expect(calls).toEqual(["update:/bin/bt", "status:/bin/bt", "login"]); });