diff --git a/packages/cli/src/setup-embeddings.test.ts b/packages/cli/src/setup-embeddings.test.ts index 5f37697e..67ef83b3 100644 --- a/packages/cli/src/setup-embeddings.test.ts +++ b/packages/cli/src/setup-embeddings.test.ts @@ -133,6 +133,12 @@ describe('setup embeddings step', () => { const healthCheck = vi.fn(async () => ({ ok: true as const })); const prompts = makePromptAdapter({ selectValues: ['sentence-transformers'] }); const ensureLocalEmbeddings = vi.fn(async () => managedDaemon()); + const spinnerEvents: string[] = []; + const spinner = vi.fn(() => ({ + start: (msg: string) => spinnerEvents.push(`start:${msg}`), + stop: (msg: string) => spinnerEvents.push(`stop:${msg}`), + error: (msg: string) => spinnerEvents.push(`error:${msg}`), + })); const result = await runKtxSetupEmbeddingsStep( { @@ -143,7 +149,7 @@ describe('setup embeddings step', () => { skipEmbeddings: false, }, io.io, - { prompts, env: {}, healthCheck, ensureLocalEmbeddings }, + { prompts, env: {}, healthCheck, ensureLocalEmbeddings, spinner }, ); expect(result.status).toBe('ready'); @@ -168,8 +174,8 @@ describe('setup embeddings step', () => { expect(config.scan.enrichment.embeddings).toMatchObject(config.ingest.embeddings); expect(config.setup?.completed_steps).toEqual(undefined); expect((await readKtxSetupState(tempDir)).completed_steps).toContain('embeddings'); - expect(io.stdout()).toContain( - 'Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.', + expect(spinnerEvents).toContainEqual( + 'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.', ); expect(io.stdout()).toContain('Embeddings ready: yes'); }); @@ -184,6 +190,12 @@ describe('setup embeddings step', () => { resolveHealthCheck = resolve; }), ); + const spinnerEvents: string[] = []; + const spinner = vi.fn(() => ({ + start: (msg: string) => spinnerEvents.push(`start:${msg}`), + stop: (msg: string) => spinnerEvents.push(`stop:${msg}`), + error: (msg: string) => spinnerEvents.push(`error:${msg}`), + })); const result = runKtxSetupEmbeddingsStep( { @@ -194,12 +206,12 @@ describe('setup embeddings step', () => { skipEmbeddings: false, }, io.io, - { prompts, env: {}, healthCheck, ensureLocalEmbeddings: vi.fn(async () => managedDaemon()) }, + { prompts, env: {}, healthCheck, ensureLocalEmbeddings: vi.fn(async () => managedDaemon()), spinner }, ); await vi.waitFor(() => { - expect(io.stdout()).toContain( - '\r│ - Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.', + expect(spinnerEvents).toContainEqual( + 'start:Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.', ); }); diff --git a/packages/cli/src/setup-embeddings.ts b/packages/cli/src/setup-embeddings.ts index 9354ad75..8d3d3765 100644 --- a/packages/cli/src/setup-embeddings.ts +++ b/packages/cli/src/setup-embeddings.ts @@ -13,6 +13,7 @@ import { } from '@ktx/context/project'; import { type KtxEmbeddingConfig, type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from '@ktx/llm'; import type { KtxCliIo } from './cli-runtime.js'; +import { createClackSpinner, type KtxCliSpinner } from './clack.js'; import { ensureManagedLocalEmbeddingsDaemon, managedLocalEmbeddingHealthConfig, @@ -61,6 +62,7 @@ export interface KtxSetupEmbeddingsDeps { installPolicy: KtxManagedPythonInstallPolicy; io: KtxCliIo; }) => Promise; + spinner?: () => KtxCliSpinner; } type BackendChoice = KtxSetupEmbeddingBackend | 'back'; @@ -83,14 +85,6 @@ const EMBEDDING_OPTION_PROMPT_CONTEXT = 'KTX uses embeddings for semantic search over semantic-layer sources, wiki context, schema metadata, ' + 'and relationship evidence.'; const LOCAL_EMBEDDING_HEALTH_TIMEOUT_MS = 120_000; -const HEALTH_CHECK_SPINNER_FRAMES = ['-', '\\', '|', '/'] as const; -const HEALTH_CHECK_SPINNER_INTERVAL_MS = 120; -const CLEAR_CURRENT_LINE = '\x1b[2K\r'; - -interface HealthCheckProgress { - succeed(message: string): void; - fail(message: string): void; -} function createPromptAdapter(): KtxSetupEmbeddingsPromptAdapter { return { @@ -350,42 +344,14 @@ function healthCheckStartText(backend: KtxSetupEmbeddingBackend, model: string, return `Checking ${backend} embeddings (${model}, ${dimensions} dimensions).`; } -function startHealthCheckProgress(io: KtxCliIo, message: string): HealthCheckProgress { - if (io.stdout.isTTY !== true) { - io.stdout.write(`│ ${message}\n`); - const noop = () => undefined; - return { - succeed: noop, - fail: noop, - }; - } - - let frameIndex = 0; - let stopped = false; - const writeFrame = () => { - io.stdout.write(`${CLEAR_CURRENT_LINE}│ ${HEALTH_CHECK_SPINNER_FRAMES[frameIndex]} ${message}`); - }; - writeFrame(); - const interval = setInterval(() => { - frameIndex = (frameIndex + 1) % HEALTH_CHECK_SPINNER_FRAMES.length; - writeFrame(); - }, HEALTH_CHECK_SPINNER_INTERVAL_MS); - - const stop = (finalMessage: string) => { - if (stopped) { - return; - } - stopped = true; - clearInterval(interval); - io.stdout.write(`${CLEAR_CURRENT_LINE}│ ${finalMessage}\n`); - }; - +function startHealthCheckProgress(spinner: KtxCliSpinner, message: string): { succeed(msg: string): void; fail(msg: string): void } { + spinner.start(message); return { - succeed(message) { - stop(message); + succeed(msg: string) { + spinner.stop(msg); }, - fail(message) { - stop(message); + fail(msg: string) { + spinner.error(msg); }, }; } @@ -474,7 +440,8 @@ export async function runKtxSetupEmbeddingsStep( dimensions, credentialValue, }); - const progress = startHealthCheckProgress(io, healthCheckStartText(selectedBackend, model, dimensions)); + const healthSpinner = (deps.spinner ?? createClackSpinner)(); + const progress = startHealthCheckProgress(healthSpinner, healthCheckStartText(selectedBackend, model, dimensions)); let health: KtxEmbeddingHealthCheckResult; try { health = await healthCheck(healthConfig);