diff --git a/packages/cli/src/clack.ts b/packages/cli/src/clack.ts index 31be2e1b..94999476 100644 --- a/packages/cli/src/clack.ts +++ b/packages/cli/src/clack.ts @@ -81,27 +81,39 @@ class KtxCliPromptCancelledError extends Error { } export function createClackSpinner(): KtxCliSpinner { - return spinner(); + // clack colors the animated spinner frame magenta by default; styleFrame + // (typed in SpinnerOptions, absent from the README) recolors it ktx orange. + return spinner({ styleFrame: orange }); } -function magenta(text: string): string { - return ansiColor(text, 35, 39); +// ktx mascot orange (#FF8A4C) via 24-bit truecolor. +function orange(text: string): string { + if (!ansiEnabled()) { + return text; + } + return `${ESC}[38;2;255;138;76m${text}${ESC}[39m`; } function red(text: string): string { return ansiColor(text, 31, 39); } +/** + * Stderr-only, non-animated spinner. Use this instead of {@link createCliSpinner} + * when the next step reads stdin in raw mode (an Ink TUI or a keypress wait): + * the animated clack spinner seizes stdin via `@clack/core`'s `block()` and + * leaves it dirty, which the following raw-mode reader misreads as a stray key. + */ export function createStaticCliSpinner(io: KtxCliSpinnerIo): KtxCliSpinner { return { start(message) { - io.stderr.write(`${magenta('◐')} ${message}\n`); + io.stderr.write(`${orange('◐')} ${message}\n`); }, message(message) { - io.stderr.write(`${magenta('│')} ${message}\n`); + io.stderr.write(`${orange('│')} ${message}\n`); }, stop(message) { - io.stderr.write(`${magenta('◇')} ${message}\n`); + io.stderr.write(`${orange('◇')} ${message}\n`); }, error(message) { io.stderr.write(`${red('■')} ${message}\n`); @@ -109,6 +121,30 @@ export function createStaticCliSpinner(io: KtxCliSpinnerIo): KtxCliSpinner { }; } +/** + * Animated spinner in an interactive terminal, static `◐/◇/■` lines otherwise + * (scripts, CI, piped output) so logs stay clean and uncluttered by frames. + */ +export function createCliSpinner(io: KtxCliIo): KtxCliSpinner { + return io.stdout.isTTY === true ? createClackSpinner() : createStaticCliSpinner(io); +} + +export async function runWithCliSpinner( + spinner: KtxCliSpinner, + text: { start: string; success: string; failure: string }, + run: () => Promise, +): Promise { + spinner.start(text.start); + try { + const value = await run(); + spinner.stop(text.success); + return value; + } catch (error) { + spinner.error(text.failure); + throw error; + } +} + export function createClackPromptAdapter(): KtxCliPromptAdapter { return { async confirm(options) { diff --git a/packages/cli/src/database-tree-picker.ts b/packages/cli/src/database-tree-picker.ts index 6698a0d2..e51b7c69 100644 --- a/packages/cli/src/database-tree-picker.ts +++ b/packages/cli/src/database-tree-picker.ts @@ -1,7 +1,9 @@ import { parseDottedTableEntry } from './context/scan/enabled-tables.js'; import type { KtxTableListEntry } from './context/scan/types.js'; import type { KtxCliIo } from './cli-runtime.js'; +import { createStaticCliSpinner } from './clack.js'; import { profileMark } from './startup-profile.js'; +import { withSearchableMultiselectNavigation } from './prompt-navigation.js'; import { buildInitialState, buildPickerTree, @@ -275,7 +277,9 @@ export async function pickDatabaseScope( let selectedSchemas = initialStageOneSchemas(args); while (true) { const pickedSchemas = await args.prompts.autocompleteMultiselect({ - message: `Choose ${args.schemaNounPlural} to enable for ${args.connectionId}\nType to filter. Space to select. Enter when done.`, + message: withSearchableMultiselectNavigation( + `Choose ${args.schemaNounPlural} to enable for ${args.connectionId}`, + ), placeholder: `Search ${args.schemaNounPlural}`, options: schemaOptions(args), initialValues: selectedSchemas, @@ -286,7 +290,7 @@ export async function pickDatabaseScope( } selectedSchemas = pickedSchemas; if (selectedSchemas.length === 0) { - io.stderr.write(`Nothing selected - type to filter, or Escape to skip ${args.schemaNoun} scope.\n`); + io.stderr.write(`Nothing selected - type to search, or Escape to skip ${args.schemaNoun} scope.\n`); continue; } @@ -304,7 +308,19 @@ export async function pickDatabaseScope( continue; } - const discovered = await args.listTablesForSchemas(selectedSchemas); + // Static (stderr-only) spinner: the stage-two table picker below is a raw-mode + // Ink TUI, and an animated clack spinner would leave stdin dirty so Ink reads a + // stray Escape and exits immediately. + const tablesSpinner = createStaticCliSpinner(io); + tablesSpinner.start(`Listing tables in ${selectedSchemas.length} ${selectedNoun}…`); + let discovered: KtxTableListEntry[]; + try { + discovered = await args.listTablesForSchemas(selectedSchemas); + } catch (error) { + tablesSpinner.error('Could not list tables'); + throw error; + } + tablesSpinner.stop(`Found ${discovered.length} ${discovered.length === 1 ? 'table' : 'tables'}`); if (action === 'save' && args.existing.enabledTables.length === 0) { return { kind: 'selected', diff --git a/packages/cli/src/managed-local-embeddings.ts b/packages/cli/src/managed-local-embeddings.ts index 2ce5274d..999b38c1 100644 --- a/packages/cli/src/managed-local-embeddings.ts +++ b/packages/cli/src/managed-local-embeddings.ts @@ -1,6 +1,6 @@ import type { KtxEmbeddingConfig } from './llm/types.js'; import type { KtxCliIo } from './cli-runtime.js'; -import { writePrefixedLines } from './clack.js'; +import { createCliSpinner } from './clack.js'; import { ensureManagedPythonCommandRuntime, type KtxManagedPythonInstallPolicy, @@ -66,15 +66,22 @@ export async function ensureManagedLocalEmbeddingsDaemon( io: options.io, feature: 'local-embeddings', }); - const daemon = await startDaemon({ - cliVersion: options.cliVersion, - projectDir: options.projectDir, - features: ['local-embeddings'], - force: false, - }); - + const spinner = createCliSpinner(options.io); + spinner.start('Starting ktx embedding daemon (first run downloads the model)…'); + let daemon: ManagedPythonDaemonStartResult; + try { + daemon = await startDaemon({ + cliVersion: options.cliVersion, + projectDir: options.projectDir, + features: ['local-embeddings'], + force: false, + }); + } catch (error) { + spinner.error('ktx embedding daemon failed to start'); + throw error; + } const verb = daemon.status === 'started' ? 'Started' : 'Using'; - writePrefixedLines((chunk) => options.io.stderr.write(chunk), `${verb} ktx daemon: ${daemon.baseUrl}`); + spinner.stop(`${verb} ktx daemon: ${daemon.baseUrl}`); return { baseUrl: daemon.baseUrl, diff --git a/packages/cli/src/managed-python-command.ts b/packages/cli/src/managed-python-command.ts index cdc4e510..caa300ca 100644 --- a/packages/cli/src/managed-python-command.ts +++ b/packages/cli/src/managed-python-command.ts @@ -1,6 +1,6 @@ import { createPythonSemanticLayerComputePort, type KtxSemanticLayerComputePort } from './context/daemon/semantic-layer-compute.js'; import type { KtxCliIo } from './cli-runtime.js'; -import { createClackPromptAdapter, createStaticCliSpinner, type KtxCliSpinner } from './clack.js'; +import { createClackPromptAdapter, createCliSpinner, type KtxCliSpinner } from './clack.js'; import { installManagedPythonRuntime, readManagedPythonRuntimeStatus, @@ -105,7 +105,7 @@ export async function ensureManagedPythonCommandRuntime( } } - const progress = (options.spinner ?? (() => createStaticCliSpinner(options.io)))(); + const progress = (options.spinner ?? (() => createCliSpinner(options.io)))(); progress.start(`Installing ktx Python runtime (${feature}) with uv...`); try { const installed = await installRuntime({ diff --git a/packages/cli/src/prompt-navigation.ts b/packages/cli/src/prompt-navigation.ts index 619ee027..7dfb8c22 100644 --- a/packages/cli/src/prompt-navigation.ts +++ b/packages/cli/src/prompt-navigation.ts @@ -1,5 +1,50 @@ -const MULTISELECT_MENU_NAVIGATION_HINT = - 'Use Up/Down to move, Space to select or unselect, Enter to confirm, Escape to go back, or Ctrl+C to exit.'; +/** @internal */ +export const MULTISELECT_NAVIGATION_FRAGMENTS = { + move: 'Up/Down to move', + expand: 'Right/Left to expand or collapse', + select: 'Tab to select or unselect', + search: 'Type to search', + confirm: 'Enter to confirm', + back: 'Escape to go back', + backSearchableTree: 'Escape to clear search or go back', + exit: 'Ctrl+C to exit', +} as const; + +function composeNavigationHint(fragments: readonly string[]): string { + return `${fragments.join(', ')}.`; +} + +const fragment = MULTISELECT_NAVIGATION_FRAGMENTS; + +/** @internal */ +export const FLAT_MULTISELECT_NAVIGATION_HINT = composeNavigationHint([ + fragment.move, + fragment.select, + fragment.confirm, + fragment.back, + fragment.exit, +]); + +/** @internal */ +export const SEARCHABLE_MULTISELECT_NAVIGATION_HINT = composeNavigationHint([ + fragment.move, + fragment.select, + fragment.search, + fragment.confirm, + fragment.back, + fragment.exit, +]); + +export const TREE_PICKER_NAVIGATION_HINT = composeNavigationHint([ + fragment.move, + fragment.expand, + fragment.select, + fragment.search, + fragment.confirm, + fragment.backSearchableTree, + fragment.exit, +]); + const TEXT_INPUT_NAVIGATION_HINT = 'Press Escape to go back.'; function removeTrailingBlankLines(message: string): string { @@ -51,10 +96,17 @@ export function withMenuOptionsSpacing(options: T } export function withMultiselectNavigation(message: string): string { - if (message.includes(MULTISELECT_MENU_NAVIGATION_HINT)) { + if (message.includes(FLAT_MULTISELECT_NAVIGATION_HINT)) { return message; } - return `${message}\n${MULTISELECT_MENU_NAVIGATION_HINT}`; + return `${message}\n${FLAT_MULTISELECT_NAVIGATION_HINT}`; +} + +export function withSearchableMultiselectNavigation(message: string): string { + if (message.includes(SEARCHABLE_MULTISELECT_NAVIGATION_HINT)) { + return message; + } + return `${message}\n${SEARCHABLE_MULTISELECT_NAVIGATION_HINT}`; } export function withTextInputNavigation(message: string): string { diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 8804cacf..376ce7d7 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -17,6 +17,7 @@ import { type KtxSetupPromptOption, } from './setup-prompts.js'; import { readKtxMcpDaemonStatus } from './managed-mcp-daemon.js'; +import { withMultiselectNavigation } from './prompt-navigation.js'; export type KtxAgentTarget = 'claude-code' | 'claude-desktop' | 'codex' | 'cursor' | 'opencode' | 'universal'; export type KtxAgentScope = 'project' | 'global' | 'local'; @@ -84,14 +85,6 @@ interface KtxCliLauncher { args: string[]; } -function writeSetupInfo(io: KtxCliIo, message: string): void { - if (isWritableTtyOutput(io.stdout)) { - log.info(message, { output: io.stdout }); - return; - } - io.stdout.write(`${message}\n`); -} - function writeSetupStep(io: KtxCliIo, message: string): void { if (isWritableTtyOutput(io.stdout)) { log.step(message, { output: io.stdout }); @@ -1097,9 +1090,6 @@ export async function runKtxSetupAgentsStep( } const prompts = deps.prompts ?? createPromptAdapter(); - if (args.inputMode === 'auto' && args.target === undefined) { - writeSetupInfo(io, 'Space to select, Enter to confirm, Esc to go back.'); - } const mode = args.inputMode === 'disabled' ? args.mode @@ -1135,7 +1125,7 @@ export async function runKtxSetupAgentsStep( : args.inputMode === 'disabled' ? [] : ((await prompts.multiselect({ - message: 'Which agent targets should ktx install?', + message: withMultiselectNavigation('Which agent targets should ktx install?'), options: [ { value: 'claude-code', label: 'Claude Code' }, { value: 'claude-desktop', label: 'Claude Desktop' }, diff --git a/packages/cli/src/setup-banner.ts b/packages/cli/src/setup-banner.ts index 29455a78..1463d786 100644 --- a/packages/cli/src/setup-banner.ts +++ b/packages/cli/src/setup-banner.ts @@ -15,7 +15,7 @@ interface KtxBannerRow { const WORDMARK: readonly KtxBannerRow[] = [ { art: '███ ███', rgb: [253, 186, 116], ansi256: 215 }, - { art: '███ ▄██▀ ▀▀███▀▀ ▀██▄ ▄██▀', rgb: [251, 146, 60], ansi256: 214 }, + { art: '███ ▄██▀ ███████ ▀██▄ ▄██▀', rgb: [251, 146, 60], ansi256: 214 }, { art: '███▄██▀ ███ ▀████▀', rgb: [249, 115, 22], ansi256: 208 }, { art: '███▀██▄ ███ ▄████▄', rgb: [234, 88, 12], ansi256: 202 }, { art: '███ ▀██▄ ███ ▄██▀ ▀██▄', rgb: [194, 65, 12], ansi256: 166 }, diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index f7fc7a25..06519774 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -5,7 +5,7 @@ import { type KtxLocalProject, loadKtxProject } from './context/project/project. import { markKtxSetupStateStepComplete, readKtxSetupState } from './context/project/setup-config.js'; import { serializeKtxProjectConfig } from './context/project/config.js'; import type { KtxCliIo } from './cli-runtime.js'; -import { errorMessage, writePrefixedLines } from './clack.js'; +import { createCliSpinner, errorMessage, writePrefixedLines } from './clack.js'; import { formatErrorDetail } from './telemetry/scrubber.js'; import { buildPublicIngestPlan } from './public-ingest.js'; import { runKtxConnection } from './connection.js'; @@ -320,13 +320,22 @@ async function testRequiredConnections( project: KtxLocalProject, targets: KtxSetupContextTargets, testConnection: (projectDir: string, connectionId: string, io: KtxCliIo) => Promise, + io: KtxCliIo, ): Promise { const failures: ConnectionGateFailure[] = []; - for (const connectionId of requiredConnectionIds(targets)) { + const connectionIds = requiredConnectionIds(targets); + for (const [index, connectionId] of connectionIds.entries()) { + const driver = connectorTypeLabel(project, connectionId); + const counter = connectionIds.length > 1 ? ` (${index + 1}/${connectionIds.length})` : ''; + const spinner = createCliSpinner(io); + spinner.start(`Testing connection ${connectionId}${counter}…`); const buffered: BufferedCommandIo = createBufferedCommandIo(); const exitCode = await testConnection(projectDir, connectionId, buffered); - if (exitCode !== 0) { - failures.push({ connectionId, driver: connectorTypeLabel(project, connectionId) }); + if (exitCode === 0) { + spinner.stop(`Connection ${connectionId} (${driver}) is reachable`); + } else { + spinner.error(`Connection ${connectionId} (${driver}) is not reachable`); + failures.push({ connectionId, driver }); } } return failures.length === 0 ? { ok: true } : { ok: false, failures }; @@ -826,7 +835,7 @@ export async function runKtxSetupContextStep( // error text. const testConnection = deps.testConnection ?? defaultGateTestConnection; while (true) { - const gate = await testRequiredConnections(args.projectDir, project, targets, testConnection); + const gate = await testRequiredConnections(args.projectDir, project, targets, testConnection, io); if (gate.ok) { return await runBuild(args, io, deps, project, targets); } diff --git a/packages/cli/src/setup-demo-tour.ts b/packages/cli/src/setup-demo-tour.ts index 19c4806d..a8738ee7 100644 --- a/packages/cli/src/setup-demo-tour.ts +++ b/packages/cli/src/setup-demo-tour.ts @@ -1,4 +1,5 @@ import type { KtxCliIo } from './cli-runtime.js'; +import { createStaticCliSpinner, runWithCliSpinner } from './clack.js'; import type { ContextBuildTargetState, ContextBuildViewState, @@ -348,7 +349,14 @@ export async function runDemoTour( const ensureProject = deps.ensureProject ?? ensureSeededDemoProject; const projectDir = defaultDemoProjectDir(); - await ensureProject({ projectDir, force: false, io, cliVersion: args.cliVersion }); + // Static (stderr-only) spinner: the demo navigation below reads stdin in raw mode, + // and an animated clack spinner would leave stdin dirty so the first keypress wait + // sees a stray key and skips the intro. + await runWithCliSpinner( + createStaticCliSpinner(io), + { start: 'Preparing demo project…', success: 'Demo project ready', failure: 'Could not prepare demo project' }, + () => ensureProject({ projectDir, force: false, io, cliVersion: args.cliVersion }), + ); io.stdout.write(renderDemoBanner(projectDir) + '\n'); io.stdout.write(`\n│ ${dim('Press Enter to continue, Escape to go back')}\n└\n`); diff --git a/packages/cli/src/setup-embeddings.ts b/packages/cli/src/setup-embeddings.ts index 7e254f06..4ca6594d 100644 --- a/packages/cli/src/setup-embeddings.ts +++ b/packages/cli/src/setup-embeddings.ts @@ -6,7 +6,7 @@ import { markKtxSetupStateStepComplete, readKtxSetupState } from './context/proj import type { KtxEmbeddingConfig } from './llm/types.js'; import { type KtxEmbeddingHealthCheckResult, runKtxEmbeddingHealthCheck } from './llm/embedding-health.js'; import type { KtxCliIo } from './cli-runtime.js'; -import { createStaticCliSpinner, errorMessage, writePrefixedLines, type KtxCliSpinner } from './clack.js'; +import { createCliSpinner, errorMessage, writePrefixedLines, type KtxCliSpinner } from './clack.js'; import { ensureManagedLocalEmbeddingsDaemon, managedLocalEmbeddingHealthConfig, @@ -444,7 +444,7 @@ export async function runKtxSetupEmbeddingsStep( dimensions, credentialValue, }); - const healthSpinner = (deps.spinner ?? (() => createStaticCliSpinner(io)))(); + const healthSpinner = (deps.spinner ?? (() => createCliSpinner(io)))(); const progress = startHealthCheckProgress(healthSpinner, healthCheckStartText(selectedBackend, model, dimensions)); let health: KtxEmbeddingHealthCheckResult; try { diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index b89d27ff..8b7af13e 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -313,13 +313,13 @@ function buildVertexHealthConfig(vertex: { project?: string; location: string }, }; } -type LlmHealthProvider = 'Anthropic API' | 'Vertex AI'; +type LlmCheckProvider = 'Anthropic API' | 'Vertex AI' | 'Claude subscription' | 'Codex'; -function llmHealthCheckStartText(provider: LlmHealthProvider, model: string): string { +function llmCheckStartText(provider: LlmCheckProvider, model: string): string { return `Checking ${provider} LLM (${model}).`; } -function startLlmHealthCheckProgress( +function startLlmCheckProgress( spinner: KtxCliSpinner, message: string, ): { succeed(msg: string): void; fail(msg: string): void } { @@ -334,30 +334,26 @@ function startLlmHealthCheckProgress( }; } -async function runLlmHealthCheckWithProgress( - config: KtxLlmConfig, - provider: LlmHealthProvider, +async function validateModelWithProgress( + provider: LlmCheckProvider, model: string, - healthCheck: (config: KtxLlmConfig) => Promise, deps: KtxSetupModelDeps, -): Promise { - const progress = startLlmHealthCheckProgress( - (deps.spinner ?? createClackSpinner)(), - llmHealthCheckStartText(provider, model), - ); - let health: KtxLlmHealthCheckResult; + run: () => Promise, +): Promise { + const progress = startLlmCheckProgress((deps.spinner ?? createClackSpinner)(), llmCheckStartText(provider, model)); + let result: PresetModelValidationResult; try { - health = await healthCheck(config); + result = await run(); } catch (error) { progress.fail('LLM test failed'); throw error; } - if (health.ok) { + if (result.ok) { progress.succeed(`LLM test passed (${provider}, ${model})`); } else { progress.fail('LLM test failed'); } - return health; + return result; } function formatVertexHealthFailure(message: string, vertex: { project?: string; location: string }): string { @@ -857,14 +853,8 @@ export async function runKtxSetupAnthropicModelStep( const preset = presetForBackend('vertex'); const validation = await validatePresetModels( preset, - async (model) => - runLlmHealthCheckWithProgress( - buildVertexHealthConfig(vertex.values, model), - 'Vertex AI', - model, - healthCheck, - deps, - ), + (model) => + validateModelWithProgress('Vertex AI', model, deps, () => healthCheck(buildVertexHealthConfig(vertex.values, model))), io, ); if (validation.status !== 'ready') { @@ -889,7 +879,10 @@ export async function runKtxSetupAnthropicModelStep( const probe = deps.claudeCodeAuthProbe ?? runClaudeCodeAuthProbe; const validation = await validatePresetModels( preset, - async (model) => probe({ projectDir: args.projectDir, model, env: deps.env ?? process.env }), + (model) => + validateModelWithProgress('Claude subscription', model, deps, () => + probe({ projectDir: args.projectDir, model, env: deps.env ?? process.env }), + ), io, ); if (validation.status !== 'ready') { @@ -912,7 +905,11 @@ export async function runKtxSetupAnthropicModelStep( if (backendChoice.backend === 'codex') { const preset = presetForBackend('codex'); const probe = deps.codexAuthProbe ?? runCodexAuthProbe; - const validation = await validatePresetModels(preset, async (model) => probe({ projectDir: args.projectDir, model }), io); + const validation = await validatePresetModels( + preset, + (model) => validateModelWithProgress('Codex', model, deps, () => probe({ projectDir: args.projectDir, model })), + io, + ); if (validation.status !== 'ready') { io.stderr.write(`${validation.message}\n`); return { status: 'failed', projectDir: args.projectDir }; @@ -937,13 +934,9 @@ export async function runKtxSetupAnthropicModelStep( const preset = presetForBackend('anthropic'); const validation = await validatePresetModels( preset, - async (model) => - runLlmHealthCheckWithProgress( - buildAnthropicHealthConfig(credential.value, model), - 'Anthropic API', - model, - healthCheck, - deps, + (model) => + validateModelWithProgress('Anthropic API', model, deps, () => + healthCheck(buildAnthropicHealthConfig(credential.value, model)), ), io, ); diff --git a/packages/cli/src/setup-prompts.ts b/packages/cli/src/setup-prompts.ts index c6549222..0249ce6e 100644 --- a/packages/cli/src/setup-prompts.ts +++ b/packages/cli/src/setup-prompts.ts @@ -1,3 +1,4 @@ +import { updateSettings } from '@clack/core'; import { autocomplete, autocompleteMultiselect, @@ -19,6 +20,11 @@ import { renderKtxSetupBanner } from './setup-banner.js'; import { revealPassword } from './reveal-password-prompt.js'; import { withSetupInterruptConfirmation } from './setup-interrupt.js'; +// clack remaps Tab to Space only on non-text prompts (flat multiselect/select/ +// confirm); text inputs and autocomplete search set _track, so typed Tab is +// untouched. This makes Tab the single documented select key across setup. +updateSettings({ aliases: { tab: 'space' } }); + export interface KtxSetupPromptOption { value: Value; label: string; diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index c38ff113..a92dea6e 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -17,7 +17,7 @@ import { type KtxProjectConfig, type KtxProjectConnectionConfig, serializeKtxPro import { loadKtxProject } from './context/project/project.js'; import { markKtxSetupStateStepComplete } from './context/project/setup-config.js'; import type { KtxCliIo } from './cli-runtime.js'; -import { errorMessage, writePrefixedLines } from './clack.js'; +import { createCliSpinner, errorMessage, writePrefixedLines } from './clack.js'; import { pickNotionRootPages } from './notion-page-picker.js'; import { runKtxSourceMapping } from './source-mapping.js'; import { @@ -975,16 +975,20 @@ async function chooseMetabaseDatabaseId(input: { state: SourcePromptState; prompts: KtxSetupSourcesPromptAdapter; deps: KtxSetupSourcesDeps; + io: KtxCliIo; }): Promise { const sourceUrl = input.state.sourceUrl; const sourceApiKeyRef = input.state.sourceApiKeyRef; if (sourceUrl && sourceApiKeyRef) { + const discoverSpinner = createCliSpinner(input.io); + discoverSpinner.start('Discovering Metabase databases…'); try { const discovered = await (input.deps.discoverMetabaseDatabases ?? defaultDiscoverMetabaseDatabases)({ sourceUrl, sourceApiKeyRef, sourceConnectionId: input.state.sourceConnectionId ?? 'metabase-main', }); + discoverSpinner.stop(`Found ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}`); if (discovered.length === 1) { return discovered[0].id; } @@ -1008,6 +1012,7 @@ async function chooseMetabaseDatabaseId(input: { } catch { // Discovery is a convenience. Fall back to the raw id prompt when credentials // are unavailable locally or the Metabase API cannot be reached yet. + discoverSpinner.error('Could not reach Metabase — enter the database id manually'); } } @@ -1148,6 +1153,8 @@ async function promptForInteractiveSource( if (currentState.sourceLocation === 'path' && currentState.sourcePath) { scanDir = currentState.sourcePath; } else if (currentState.sourceLocation === 'git' && currentState.sourceGitUrl) { + const cloneSpinner = createCliSpinner(io); + cloneSpinner.start('Cloning repository to scan for dbt projects…'); try { const cacheDir = await mkdtemp(join(tmpdir(), 'ktx-setup-dbt-scan-')); const authToken = currentState.sourceAuthTokenRef @@ -1160,7 +1167,9 @@ async function promptForInteractiveSource( branch: currentState.sourceBranch ?? 'main', }); scanDir = cacheDir; + cloneSpinner.stop('Repository cloned'); } catch { + cloneSpinner.error('Could not clone repository'); // Clone failed — fall through to manual prompt } } @@ -1256,6 +1265,7 @@ async function promptForInteractiveSource( state, prompts, deps: { discoverMetabaseDatabases: discoverMetabaseDatabaseList }, + io, }); if (databaseId === 'back') return 'back'; state.metabaseDatabaseId = databaseId; @@ -1790,15 +1800,25 @@ async function validateSourceConnectionAndMapping(input: { io: KtxCliIo; deps: KtxSetupSourcesDeps; }): Promise { - const validation = await validateSource( - input.source, - { projectDir: input.args.projectDir, connectionId: input.connectionId, connection: input.connection }, - input.deps, - ); + const validateSpinner = createCliSpinner(input.io); + validateSpinner.start(`Validating ${sourceLabel(input.source)} source…`); + let validation: SourceValidationResult; + try { + validation = await validateSource( + input.source, + { projectDir: input.args.projectDir, connectionId: input.connectionId, connection: input.connection }, + input.deps, + ); + } catch (error) { + validateSpinner.error(`${sourceLabel(input.source)} source validation failed`); + throw error; + } if (!validation.ok) { + validateSpinner.error(`${sourceLabel(input.source)} source validation failed`); input.io.stderr.write(`${validation.message}\n`); return { status: 'failed' }; } + validateSpinner.stop(`${sourceLabel(input.source)} source validated`); if (input.source === 'metabase' || input.source === 'looker') { input.prompts.log?.(`Validating ${sourceLabel(input.source)} mapping...`); diff --git a/packages/cli/src/tree-picker-tui.tsx b/packages/cli/src/tree-picker-tui.tsx index 94fc0dd6..acf7cd4c 100644 --- a/packages/cli/src/tree-picker-tui.tsx +++ b/packages/cli/src/tree-picker-tui.tsx @@ -12,6 +12,7 @@ import { type PickerState, } from './tree-picker-state.js'; import type { KtxCliIo } from './cli-runtime.js'; +import { TREE_PICKER_NAVIGATION_HINT } from './prompt-navigation.js'; const COLOR_THEME = { text: 'white', @@ -31,9 +32,6 @@ const NO_COLOR_THEME = { type TreePickerTheme = Record; -const DEFAULT_TREE_PICKER_HELP_TEXT = - 'Up/Down to move, Right/Left to expand or collapse, Tab to select, Type to search, Enter to confirm, Escape to clear search or go back, Ctrl+C to exit.'; - const DEFAULT_SKIP_EMPTY_MESSAGE = 'Nothing selected. Skip this step? Press Enter to skip or Escape to go back.'; @@ -60,7 +58,6 @@ export type TreePickerResult = { kind: 'save'; selectedIds: string[] } | { kind: export interface TreePickerChrome { title: string; - helpText?: string; subtitleLines?: readonly string[]; warningLines?: readonly string[]; confirmSaveMessage?: (state: PickerState) => string; @@ -221,7 +218,6 @@ export function TreePickerApp(props: TreePickerAppProps): ReactNode { const hiddenBelow = Math.max(0, visibleIds.length - rows.offset - rows.items.length); const searchMatchCount = filterTree(state).visibleIds.size; const width = resolveTreePickerWidth(props.terminalWidth); - const helpText = props.chrome.helpText ?? DEFAULT_TREE_PICKER_HELP_TEXT; const skipEmptyMessage = props.chrome.skipEmptyMessage ?? DEFAULT_SKIP_EMPTY_MESSAGE; stateRef.current = state; @@ -284,7 +280,7 @@ export function TreePickerApp(props: TreePickerAppProps): ReactNode { borderColor={theme.active} paddingLeft={1} > - {helpText} + {TREE_PICKER_NAVIGATION_HINT} {(props.chrome.subtitleLines ?? []).map((line, idx) => ( @@ -304,7 +300,7 @@ export function TreePickerApp(props: TreePickerAppProps): ReactNode { Search: {state.isNavigating ? ( - {state.search.query || '(type to filter)'} + {state.search.query || '(type to search)'} ) : ( {state.search.query} diff --git a/packages/cli/test/clack.test.ts b/packages/cli/test/clack.test.ts new file mode 100644 index 00000000..a1add7d2 --- /dev/null +++ b/packages/cli/test/clack.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { type KtxCliSpinner, runWithCliSpinner } from '../src/clack.js'; + +function makeSpinner() { + const events: string[] = []; + const spinner: KtxCliSpinner = { + start: (msg) => events.push(`start:${msg}`), + message: (msg) => events.push(`message:${msg}`), + stop: (msg) => events.push(`stop:${msg}`), + error: (msg) => events.push(`error:${msg}`), + }; + return { events, spinner }; +} + +describe('runWithCliSpinner', () => { + it('starts then stops with the success text and returns the value', async () => { + const { events, spinner } = makeSpinner(); + + const value = await runWithCliSpinner(spinner, { start: 'Working…', success: 'Done', failure: 'Failed' }, async () => 42); + + expect(value).toBe(42); + expect(events).toEqual(['start:Working…', 'stop:Done']); + }); + + it('errors with the failure text and rethrows when the work throws', async () => { + const { events, spinner } = makeSpinner(); + const boom = new Error('boom'); + + await expect( + runWithCliSpinner(spinner, { start: 'Working…', success: 'Done', failure: 'Failed' }, async () => { + throw boom; + }), + ).rejects.toBe(boom); + expect(events).toEqual(['start:Working…', 'error:Failed']); + }); +}); diff --git a/packages/cli/test/prompt-navigation.test.ts b/packages/cli/test/prompt-navigation.test.ts index 9bbd34e9..0ba3fdc4 100644 --- a/packages/cli/test/prompt-navigation.test.ts +++ b/packages/cli/test/prompt-navigation.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { withMenuOptionSpacing, withMultiselectNavigation, withTextInputNavigation } from '../src/prompt-navigation.js'; +import { + FLAT_MULTISELECT_NAVIGATION_HINT, + MULTISELECT_NAVIGATION_FRAGMENTS, + SEARCHABLE_MULTISELECT_NAVIGATION_HINT, + TREE_PICKER_NAVIGATION_HINT, + withMenuOptionSpacing, + withMultiselectNavigation, + withSearchableMultiselectNavigation, + withTextInputNavigation, +} from '../src/prompt-navigation.js'; describe('prompt navigation helpers', () => { it('leaves compact single-line menu prompts unchanged', () => { @@ -18,10 +27,56 @@ describe('prompt navigation helpers', () => { it('keeps multiselect navigation copy multiline so menu renderers can separate it from options', () => { expect(withMultiselectNavigation('Which sources?')).toBe( - 'Which sources?\nUse Up/Down to move, Space to select or unselect, Enter to confirm, Escape to go back, or Ctrl+C to exit.', + 'Which sources?\nUp/Down to move, Tab to select or unselect, Enter to confirm, Escape to go back, Ctrl+C to exit.', ); }); + it('appends the searchable hint for autocomplete multiselect prompts', () => { + expect(withSearchableMultiselectNavigation('Choose schemas')).toBe( + 'Choose schemas\nUp/Down to move, Tab to select or unselect, Type to search, Enter to confirm, Escape to go back, Ctrl+C to exit.', + ); + }); + + it('does not duplicate the searchable hint when applied twice', () => { + const once = withSearchableMultiselectNavigation('Choose schemas'); + expect(withSearchableMultiselectNavigation(once)).toBe(once); + }); + + it('matches the approved hint wording for each multi-select surface', () => { + expect(FLAT_MULTISELECT_NAVIGATION_HINT).toBe( + 'Up/Down to move, Tab to select or unselect, Enter to confirm, Escape to go back, Ctrl+C to exit.', + ); + expect(SEARCHABLE_MULTISELECT_NAVIGATION_HINT).toBe( + 'Up/Down to move, Tab to select or unselect, Type to search, Enter to confirm, Escape to go back, Ctrl+C to exit.', + ); + expect(TREE_PICKER_NAVIGATION_HINT).toBe( + 'Up/Down to move, Right/Left to expand or collapse, Tab to select or unselect, Type to search, Enter to confirm, Escape to clear search or go back, Ctrl+C to exit.', + ); + }); + + it('composes every hint from the shared fragment vocabulary so wording cannot drift', () => { + const hints = [ + FLAT_MULTISELECT_NAVIGATION_HINT, + SEARCHABLE_MULTISELECT_NAVIGATION_HINT, + TREE_PICKER_NAVIGATION_HINT, + ]; + const sharedFragments = [ + MULTISELECT_NAVIGATION_FRAGMENTS.move, + MULTISELECT_NAVIGATION_FRAGMENTS.select, + MULTISELECT_NAVIGATION_FRAGMENTS.confirm, + MULTISELECT_NAVIGATION_FRAGMENTS.exit, + ]; + for (const fragment of sharedFragments) { + for (const hint of hints) { + expect(hint).toContain(fragment); + } + } + expect(MULTISELECT_NAVIGATION_FRAGMENTS.select).toBe('Tab to select or unselect'); + for (const hint of hints) { + expect(hint).not.toContain('Space'); + } + }); + it('adds a blank separator between text input helper copy and the editable value', () => { expect( withTextInputNavigation( diff --git a/packages/cli/test/setup-agents.test.ts b/packages/cli/test/setup-agents.test.ts index d08355e5..b85ad9a5 100644 --- a/packages/cli/test/setup-agents.test.ts +++ b/packages/cli/test/setup-agents.test.ts @@ -998,7 +998,7 @@ describe('setup agents', () => { ).resolves.toEqual({ status: 'skipped', projectDir: tempDir }); }); - it('prints one navigation hint before interactive agent target prompts', async () => { + it('wraps the agent target prompt with the navigation hint and prints no separate hint line', async () => { const io = makeIo(); const prompts = { select: vi.fn(async () => 'mcp-cli'), @@ -1022,13 +1022,14 @@ describe('setup agents', () => { ), ).resolves.toEqual({ status: 'back', projectDir: tempDir }); - expect(io.stdout()).toContain('Space to select, Enter to confirm, Esc to go back.'); - expect(io.stdout().match(/Space to select/g)).toHaveLength(1); expect(prompts.multiselect).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Which agent targets should ktx install?', + message: + 'Which agent targets should ktx install?\n' + + 'Up/Down to move, Tab to select or unselect, Enter to confirm, Escape to go back, Ctrl+C to exit.', }), ); + expect(io.stdout()).not.toContain('Space to select'); }); it('prints per-agent install summary after successful installation', async () => { diff --git a/packages/cli/test/setup-databases.test.ts b/packages/cli/test/setup-databases.test.ts index 5c25b266..7f0a5523 100644 --- a/packages/cli/test/setup-databases.test.ts +++ b/packages/cli/test/setup-databases.test.ts @@ -235,7 +235,7 @@ describe('setup databases step', () => { expect(prompts.multiselect).toHaveBeenCalledWith({ message: 'Which databases should ktx connect to?\n' + - 'Use Up/Down to move, Space to select or unselect, Enter to confirm, Escape to go back, or Ctrl+C to exit.', + 'Up/Down to move, Tab to select or unselect, Enter to confirm, Escape to go back, Ctrl+C to exit.', options: [ { value: 'postgres', label: 'PostgreSQL' }, { value: 'bigquery', label: 'BigQuery' }, @@ -273,7 +273,7 @@ describe('setup databases step', () => { expect(prompts.multiselect).toHaveBeenCalledTimes(2); expect(vi.mocked(prompts.multiselect).mock.calls[1]?.[0].message).toBe( 'Which databases should ktx connect to?\n' + - 'Use Up/Down to move, Space to select or unselect, Enter to confirm, Escape to go back, or Ctrl+C to exit.', + 'Up/Down to move, Tab to select or unselect, Enter to confirm, Escape to go back, Ctrl+C to exit.', ); }); diff --git a/packages/cli/test/setup-embeddings.test.ts b/packages/cli/test/setup-embeddings.test.ts index 5754913c..62ebda97 100644 --- a/packages/cli/test/setup-embeddings.test.ts +++ b/packages/cli/test/setup-embeddings.test.ts @@ -21,7 +21,7 @@ function makeIo() { return { io: { stdout: { - isTTY: true, + isTTY: false, write: (chunk: string) => { stdout += chunk; }, @@ -185,7 +185,7 @@ describe('setup embeddings step', () => { expect(io.stdout()).toContain('Embeddings ready: yes'); }); - it('uses a short non-animated local embeddings health-check status by default', async () => { + it('uses a short non-animated local embeddings health-check status when stdout is not a TTY', async () => { const io = makeIo(); const healthCheck = vi.fn(async () => ({ ok: true as const })); const prompts = makePromptAdapter({ selectValues: ['sentence-transformers'] }); diff --git a/packages/cli/test/setup-models.test.ts b/packages/cli/test/setup-models.test.ts index e6ec9001..4dfe66b7 100644 --- a/packages/cli/test/setup-models.test.ts +++ b/packages/cli/test/setup-models.test.ts @@ -161,6 +161,7 @@ describe('setup Anthropic model step', () => { it('configures Claude Code backend and validates local auth', async () => { const io = makeIo(); const authProbe = vi.fn(async () => ({ ok: true as const })); + const { events: spinnerEvents, spinner } = makeSpinnerEvents(); const result = await runKtxSetupAnthropicModelStep( { @@ -170,7 +171,7 @@ describe('setup Anthropic model step', () => { skipLlm: false, }, io.io, - { claudeCodeAuthProbe: authProbe }, + { claudeCodeAuthProbe: authProbe, spinner }, ); expect(result.status).toBe('ready'); @@ -183,17 +184,26 @@ describe('setup Anthropic model step', () => { expect(authProbe).toHaveBeenNthCalledWith(1, expect.objectContaining({ projectDir: tempDir, model: 'sonnet' })); expect(authProbe).toHaveBeenNthCalledWith(2, expect.objectContaining({ projectDir: tempDir, model: 'haiku' })); expect(authProbe).toHaveBeenNthCalledWith(3, expect.objectContaining({ projectDir: tempDir, model: 'opus' })); + expect(spinnerEvents).toEqual([ + 'start:Checking Claude subscription LLM (sonnet).', + 'stop:LLM test passed (Claude subscription, sonnet)', + 'start:Checking Claude subscription LLM (haiku).', + 'stop:LLM test passed (Claude subscription, haiku)', + 'start:Checking Claude subscription LLM (opus).', + 'stop:LLM test passed (Claude subscription, opus)', + ]); }); it('does not prompt for a Claude Code model during interactive setup', async () => { const io = makeIo(); const prompts = makePromptAdapter({ selectValues: ['claude-code'] }); const authProbe = vi.fn(async () => ({ ok: true as const })); + const { spinner } = makeSpinnerEvents(); const result = await runKtxSetupAnthropicModelStep( { projectDir: tempDir, inputMode: 'auto', skipLlm: false }, io.io, - { prompts, claudeCodeAuthProbe: authProbe }, + { prompts, claudeCodeAuthProbe: authProbe, spinner }, ); expect(result.status).toBe('ready'); @@ -214,6 +224,7 @@ describe('setup Anthropic model step', () => { it('configures Codex backend and validates local auth', async () => { const io = makeIo(); const codexAuthProbe = vi.fn(async () => ({ ok: true as const })); + const { events: spinnerEvents, spinner } = makeSpinnerEvents(); const result = await runKtxSetupAnthropicModelStep( { @@ -223,7 +234,7 @@ describe('setup Anthropic model step', () => { skipLlm: false, }, io.io, - { codexAuthProbe }, + { codexAuthProbe, spinner }, ); expect(result.status).toBe('ready'); @@ -234,6 +245,10 @@ describe('setup Anthropic model step', () => { }); expect(codexAuthProbe).toHaveBeenCalledTimes(1); expect(codexAuthProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'gpt-5.5' })); + expect(spinnerEvents).toEqual([ + 'start:Checking Codex LLM (gpt-5.5).', + 'stop:LLM test passed (Codex, gpt-5.5)', + ]); // The warning carries the clack gutter so it renders inside the setup frame. expect(io.stderr()).toContain('│ Codex backend isolation is limited'); expect(io.stderr()).toContain('may still load user Codex config'); @@ -242,6 +257,7 @@ describe('setup Anthropic model step', () => { it('defaults the Codex model to gpt-5.5 when none is provided non-interactively', async () => { const io = makeIo(); const codexAuthProbe = vi.fn(async () => ({ ok: true as const })); + const { spinner } = makeSpinnerEvents(); const result = await runKtxSetupAnthropicModelStep( { @@ -251,7 +267,7 @@ describe('setup Anthropic model step', () => { skipLlm: false, }, io.io, - { codexAuthProbe }, + { codexAuthProbe, spinner }, ); expect(result.status).toBe('ready'); @@ -283,6 +299,7 @@ describe('setup Anthropic model step', () => { 'utf-8', ); const io = makeIo(); + const { spinner } = makeSpinnerEvents(); const result = await runKtxSetupAnthropicModelStep( { @@ -294,6 +311,7 @@ describe('setup Anthropic model step', () => { io.io, { claudeCodeAuthProbe: async () => ({ ok: true as const }), + spinner, }, ); diff --git a/packages/cli/test/setup-prompts-tab-toggle.test.ts b/packages/cli/test/setup-prompts-tab-toggle.test.ts new file mode 100644 index 00000000..ddd106eb --- /dev/null +++ b/packages/cli/test/setup-prompts-tab-toggle.test.ts @@ -0,0 +1,44 @@ +import { PassThrough } from 'node:stream'; +import { multiselect } from '@clack/prompts'; +import { describe, expect, it } from 'vitest'; +// Importing the adapter module registers the tab→space alias on clack settings. +import '../src/setup-prompts.js'; + +type FakeInput = PassThrough & { isTTY: boolean; setRawMode: (value: boolean) => void }; +type FakeOutput = PassThrough & { isTTY: boolean; columns: number; rows: number }; + +function fakeTty(): { input: FakeInput; output: FakeOutput } { + const input = new PassThrough() as FakeInput; + input.isTTY = true; + input.setRawMode = () => {}; + const output = new PassThrough() as FakeOutput; + output.isTTY = true; + output.columns = 80; + output.rows = 24; + output.resume(); + return { input, output }; +} + +const tick = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +describe('Tab selection in a flat multiselect', () => { + it('toggles the focused option, proving the adapter alias drives a real clack multiselect', async () => { + const { input, output } = fakeTty(); + const result = multiselect({ + message: 'Pick', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + input, + output, + }); + + await tick(); + input.emit('keypress', '\t', { name: 'tab' }); + await tick(); + input.emit('keypress', '', { name: 'return' }); + + expect(await result).toEqual(['a']); + }); +}); diff --git a/packages/cli/test/setup-prompts.test.ts b/packages/cli/test/setup-prompts.test.ts index 8833849d..d66ada1c 100644 --- a/packages/cli/test/setup-prompts.test.ts +++ b/packages/cli/test/setup-prompts.test.ts @@ -1,3 +1,4 @@ +import { settings } from '@clack/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createKtxSetupPromptAdapter, @@ -63,6 +64,13 @@ describe('setup prompt adapter', () => { mocks.withSetupInterruptConfirmation.mockClear(); }); + it('registers Tab as a Space alias so flat multiselects toggle on Tab', () => { + // Importing the adapter module runs updateSettings({ aliases: { tab: 'space' } }). + // clack remaps Tab→Space on non-text prompts, which is what toggles a flat + // multiselect option; text inputs set _track, so their typed Tab is untouched. + expect(settings.aliases.get('tab')).toBe('space'); + }); + it('passes select hint and disabled options through Clack and delegates cancellation handling', async () => { mocks.select.mockResolvedValueOnce('openai'); const adapter = createKtxSetupPromptAdapter({ selectCancelValue: 'back' }); diff --git a/packages/cli/test/setup-sources.test.ts b/packages/cli/test/setup-sources.test.ts index a6a91805..0d01c189 100644 --- a/packages/cli/test/setup-sources.test.ts +++ b/packages/cli/test/setup-sources.test.ts @@ -761,7 +761,7 @@ describe('setup sources step', () => { expect(testPrompts.multiselect).toHaveBeenCalledWith( expect.objectContaining({ message: - 'Which context sources should ktx ingest?\nUse Up/Down to move, Space to select or unselect, Enter to confirm, Escape to go back, or Ctrl+C to exit.', + 'Which context sources should ktx ingest?\nUp/Down to move, Tab to select or unselect, Enter to confirm, Escape to go back, Ctrl+C to exit.', }), ); const options = vi.mocked(testPrompts.multiselect).mock.calls[0]?.[0].options ?? []; diff --git a/packages/cli/test/tree-picker-tui.test.tsx b/packages/cli/test/tree-picker-tui.test.tsx index f4f642c0..78c9a68f 100644 --- a/packages/cli/test/tree-picker-tui.test.tsx +++ b/packages/cli/test/tree-picker-tui.test.tsx @@ -193,24 +193,11 @@ describe('TreePickerApp', () => { expect(frame).toContain('◻ Engineering Docs ▸ (1)'); expect(frame).toContain('◻ Marketing'); expect(normalizeFrameWrap(frame)).toContain( - 'Up/Down to move, Right/Left to expand or collapse, Tab to select, Type to search, Enter to confirm, Escape to clear search or go back, Ctrl+C to exit.', + 'Up/Down to move, Right/Left to expand or collapse, Tab to select or unselect, Type to search, Enter to confirm, Escape to clear search or go back, Ctrl+C to exit.', ); expect(frame).toContain('Search:'); }); - it('renders custom help text when supplied', () => { - const { lastFrame } = renderInkTest( - , - ); - expect(lastFrame() ?? '').toContain('Bespoke instructions here.'); - }); - it('renders checked parents and locked descendants with locked glyphs', () => { const initialState = { ...state(),