diff --git a/packages/cli/src/setup-models.test.ts b/packages/cli/src/setup-models.test.ts index ba9260be..444c3b4d 100644 --- a/packages/cli/src/setup-models.test.ts +++ b/packages/cli/src/setup-models.test.ts @@ -58,33 +58,35 @@ function makePromptAdapter(options: { const textValues = [...(options.textValues ?? [])]; const passwordValues = [...(options.passwordValues ?? [])]; let providerPromptCount = 0; + const choose = async ({ message }: { message: string }) => { + if (message.includes('LLM provider')) { + providerPromptCount += 1; + const nextProviderChoice = selectValues[0]; + if ( + nextProviderChoice === 'anthropic' || + nextProviderChoice === 'vertex' || + nextProviderChoice === 'claude-code' || + nextProviderChoice === 'back' + ) { + return selectValues.shift() ?? nextProviderChoice; + } + if (options.credentialChoice === 'back' && providerPromptCount > 1) { + return 'back'; + } + return options.providerChoice ?? 'anthropic'; + } + const nextValue = selectValues.shift(); + if (nextValue) { + return nextValue; + } + if (message.includes('Anthropic API key')) { + return options.credentialChoice ?? 'env'; + } + return options.modelChoice ?? 'claude-sonnet-4-6'; + }; return { - select: vi.fn(async ({ message }) => { - if (message.includes('LLM provider')) { - providerPromptCount += 1; - const nextProviderChoice = selectValues[0]; - if ( - nextProviderChoice === 'anthropic' || - nextProviderChoice === 'vertex' || - nextProviderChoice === 'claude-code' || - nextProviderChoice === 'back' - ) { - return selectValues.shift() ?? nextProviderChoice; - } - if (options.credentialChoice === 'back' && providerPromptCount > 1) { - return 'back'; - } - return options.providerChoice ?? 'anthropic'; - } - const nextValue = selectValues.shift(); - if (nextValue) { - return nextValue; - } - if (message.includes('Anthropic API key')) { - return options.credentialChoice ?? 'env'; - } - return options.modelChoice ?? 'claude-sonnet-4-6'; - }), + select: vi.fn(choose), + autocomplete: vi.fn(choose), text: vi.fn(async () => textValues.shift() ?? ''), password: vi.fn( async () => @@ -152,7 +154,7 @@ describe('setup Anthropic model step', () => { }, ); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.autocomplete).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('Which Anthropic model should KTX use?'), options: [ @@ -417,7 +419,7 @@ describe('setup Anthropic model step', () => { expect(readGcloudProject).toHaveBeenCalled(); expect(listGcloudProjects).toHaveBeenCalled(); expect(prompts.text).not.toHaveBeenCalled(); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.autocomplete).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('Which Google Cloud project should KTX use for Vertex AI?'), options: [ @@ -428,7 +430,7 @@ describe('setup Anthropic model step', () => { ], }), ); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.autocomplete).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('Which Anthropic model should KTX use?'), options: [ @@ -480,7 +482,7 @@ describe('setup Anthropic model step', () => { message: expect.stringContaining('How should KTX authenticate with Google Vertex AI?'), }), ); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.autocomplete).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('Which Google Cloud project should KTX use for Vertex AI?'), }), @@ -548,7 +550,7 @@ describe('setup Anthropic model step', () => { ); expect(result.status).toBe('ready'); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.autocomplete).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('Which Google Cloud project should KTX use for Vertex AI?'), options: [ @@ -595,25 +597,25 @@ describe('setup Anthropic model step', () => { expect(result.status).toBe('ready'); expect(listGcloudProjects).toHaveBeenCalledTimes(2); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.autocomplete).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('Could not list Google Cloud projects with gcloud'), options: expect.arrayContaining([{ value: 'retry', label: 'Retry loading Google Cloud projects' }]), }), ); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.autocomplete).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining( `${String.fromCharCode(0x1b)}[33mCould not list Google Cloud projects with gcloud`, ), }), ); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.autocomplete).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('gcloud auth login --update-adc'), }), ); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.autocomplete).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining( `${String.fromCharCode(0x1b)}[33mRun \`gcloud auth login --update-adc\``, @@ -643,7 +645,7 @@ describe('setup Anthropic model step', () => { expect(result.status).toBe('back'); expect(prompts.select).toHaveBeenNthCalledWith( - 3, + 2, expect.objectContaining({ message: expect.stringContaining('Which LLM provider should KTX use?'), }), @@ -887,7 +889,7 @@ describe('setup Anthropic model step', () => { ); expect(result.status).toBe('back'); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.autocomplete).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('Which Anthropic model should KTX use?'), options: expect.not.arrayContaining([expect.objectContaining({ value: 'skip' })]), @@ -919,7 +921,7 @@ describe('setup Anthropic model step', () => { ); expect(result.status).toBe('ready'); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.autocomplete).toHaveBeenCalledWith( expect.objectContaining({ message: expectedPromptMessage, }), @@ -965,7 +967,7 @@ describe('setup Anthropic model step', () => { expect(result.status).toBe('missing-input'); expect(BUNDLED_ANTHROPIC_MODELS.length).toBeGreaterThan(0); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.autocomplete).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('Which Anthropic model should KTX use?'), options: expect.arrayContaining([ @@ -1058,7 +1060,8 @@ describe('setup Anthropic model step', () => { expect(result.status).toBe('ready'); expect(healthCheck).toHaveBeenCalledTimes(2); - expect(prompts.select).toHaveBeenCalledTimes(5); + expect(prompts.select).toHaveBeenCalledTimes(3); + expect(prompts.autocomplete).toHaveBeenCalledTimes(2); expect(io.stderr()).toContain('Anthropic model health check failed: model not found'); expect(io.stderr()).toContain('Choose a different credential source or model, or Back.'); const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); @@ -1110,7 +1113,7 @@ describe('setup Anthropic model step', () => { expect(result.status).toBe('back'); expect(prompts.select).toHaveBeenNthCalledWith( - 4, + 3, expect.objectContaining({ message: expect.stringContaining('How should KTX find your Anthropic API key?'), }), diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index 7f7385b1..041eef5c 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -61,6 +61,11 @@ export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code'; /** @internal */ export interface KtxSetupModelPromptAdapter { select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; + autocomplete(options: { + message: string; + placeholder?: string; + options: KtxSetupPromptOption[]; + }): Promise; text(options: { message: string; placeholder?: string }): Promise; password(options: { message: string }): Promise; cancel(message: string): void; @@ -617,13 +622,14 @@ async function chooseInteractiveVertexProject( io.stdout.write('│ gcloud did not return any visible Google Cloud projects. Enter a project ID manually or choose Back.\n'); } - const choice = await prompts.select({ + const choice = await prompts.autocomplete({ message: `Which Google Cloud project should KTX use for Vertex AI?\n\n${[ VERTEX_PROJECT_PROMPT_CONTEXT, listFailureMessage, ] .filter((value): value is string => Boolean(value)) .join('\n\n')}`, + placeholder: 'Type to search projects', options: [ ...orderedProjects.map((project) => ({ value: project.projectId, @@ -778,8 +784,9 @@ async function chooseModel( { value: 'manual', label: 'Enter a model ID manually' }, { value: 'back', label: 'Back' }, ]; - const choice = await prompts.select({ + const choice = await prompts.autocomplete({ message: `Which Anthropic model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`, + placeholder: 'Type to search models', options: modelOptions, }); if (choice === 'back') { @@ -810,8 +817,9 @@ async function chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: Kt const selectableModels = VERTEX_ANTHROPIC_MODELS.filter(isSelectableAnthropicModel); const prompts = deps.prompts ?? createPromptAdapter(); - const choice = await prompts.select({ + const choice = await prompts.autocomplete({ message: `Which Anthropic model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`, + placeholder: 'Type to search models', options: [ ...selectableModels.map((model) => ({ value: model.id, diff --git a/packages/cli/src/setup-prompts.test.ts b/packages/cli/src/setup-prompts.test.ts index 23ffd669..95f4b68b 100644 --- a/packages/cli/src/setup-prompts.test.ts +++ b/packages/cli/src/setup-prompts.test.ts @@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => { isCancel: vi.fn((value: unknown): value is symbol => value === cancelSymbol), log: { info: vi.fn() }, multiselect: vi.fn(), + autocomplete: vi.fn(), + autocompleteMultiselect: vi.fn(), note: vi.fn(), password: vi.fn(), select: vi.fn(), @@ -29,6 +31,8 @@ vi.mock('@clack/prompts', () => ({ isCancel: mocks.isCancel, log: mocks.log, multiselect: mocks.multiselect, + autocomplete: mocks.autocomplete, + autocompleteMultiselect: mocks.autocompleteMultiselect, note: mocks.note, password: mocks.password, select: mocks.select, @@ -47,6 +51,8 @@ describe('setup prompt adapter', () => { mocks.isCancel.mockClear(); mocks.log.info.mockReset(); mocks.multiselect.mockReset(); + mocks.autocomplete.mockReset(); + mocks.autocompleteMultiselect.mockReset(); mocks.note.mockReset(); mocks.password.mockReset(); mocks.select.mockReset(); @@ -160,6 +166,52 @@ describe('setup prompt adapter', () => { expect(mocks.cancel).toHaveBeenCalledWith('Setup cancelled.'); }); + it('returns autocomplete selections and maps cancel to back', async () => { + mocks.autocomplete.mockResolvedValueOnce('analytics'); + const adapter = createKtxSetupPromptAdapter({ selectCancelValue: 'back' }); + + await expect( + adapter.autocomplete({ + message: 'Dataset', + placeholder: 'Type to search', + options: [{ value: 'analytics', label: 'analytics' }], + }), + ).resolves.toBe('analytics'); + + mocks.autocomplete.mockResolvedValueOnce(mocks.cancelSymbol); + await expect( + adapter.autocomplete({ + message: 'Dataset', + options: [{ value: 'analytics', label: 'analytics' }], + }), + ).resolves.toBe('back'); + }); + + it('returns autocomplete multiselect selections and maps cancel to back', async () => { + mocks.autocompleteMultiselect.mockResolvedValueOnce(['analytics', 'mart']); + const adapter = createKtxSetupPromptAdapter({ selectCancelValue: 'back', multiselectCancelValue: 'back' }); + + await expect( + adapter.autocompleteMultiselect({ + message: 'Datasets', + placeholder: 'Type to filter', + options: [ + { value: 'analytics', label: 'analytics', hint: 'suggested' }, + { value: 'mart', label: 'mart' }, + ], + initialValues: ['analytics'], + }), + ).resolves.toEqual(['analytics', 'mart']); + + mocks.autocompleteMultiselect.mockResolvedValueOnce(mocks.cancelSymbol); + await expect( + adapter.autocompleteMultiselect({ + message: 'Datasets', + options: [{ value: 'analytics', label: 'analytics' }], + }), + ).resolves.toEqual(['back']); + }); + it('keeps setup intro and note plain for non-stream output', async () => { const { createKtxSetupUiAdapter } = await import('./setup-prompts.js'); const chunks: string[] = []; diff --git a/packages/cli/src/setup-prompts.ts b/packages/cli/src/setup-prompts.ts index f5faacd8..1609bd76 100644 --- a/packages/cli/src/setup-prompts.ts +++ b/packages/cli/src/setup-prompts.ts @@ -1,5 +1,7 @@ import type { Writable } from 'node:stream'; import { + autocomplete, + autocompleteMultiselect, cancel, confirm, intro, @@ -38,6 +40,22 @@ interface KtxSetupMultiselectOptions { cursorAt?: Value; } +interface KtxSetupAutocompleteOptions { + message: string; + options: Array>; + placeholder?: string; + maxItems?: number; +} + +interface KtxSetupAutocompleteMultiselectOptions { + message: string; + options: Array>; + placeholder?: string; + required?: boolean; + maxItems?: number; + initialValues?: Value[]; +} + interface KtxSetupTextOptions { message: string; placeholder?: string; @@ -53,6 +71,8 @@ interface KtxSetupPasswordOptions { export interface KtxSetupPromptAdapter { select(options: KtxSetupSelectOptions): Promise; multiselect(options: KtxSetupMultiselectOptions): Promise; + autocomplete(options: KtxSetupAutocompleteOptions): Promise; + autocompleteMultiselect(options: KtxSetupAutocompleteMultiselectOptions): Promise; text(options: KtxSetupTextOptions): Promise; password(options: KtxSetupPasswordOptions): Promise; cancel(message: string): void; @@ -117,6 +137,50 @@ export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOption return selected; } }, + async autocomplete(promptOptions) { + const value = await withSetupInterruptConfirmation(() => + autocomplete(withMenuOptionsSpacing(promptOptions)), + ); + if (isCancel(value)) { + if (cancelOnSelectCancel) { + cancel(cancelMessage); + } + return options.selectCancelValue; + } + return String(value); + }, + async autocompleteMultiselect(promptOptions) { + while (true) { + const value = await withSetupInterruptConfirmation(() => + autocompleteMultiselect(withMenuOptionsSpacing(promptOptions)), + ); + if (isCancel(value)) { + if (cancelOnMultiselectCancel) { + cancel(cancelMessage); + } + return [multiselectCancelValue]; + } + const selected = [...value].map(String); + if ( + selected.length === 0 && + !promptOptions.required && + options.confirmEmptyOptionalMultiselect === true + ) { + const skipConfirmed = await confirm({ + message: 'Nothing selected. Skip this step?', + initialValue: false, + }); + if (isCancel(skipConfirmed)) { + cancel(cancelMessage); + return [multiselectCancelValue]; + } + if (!skipConfirmed) { + continue; + } + } + return selected; + } + }, async text(promptOptions) { const value = await withSetupInterruptConfirmation(() => text({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }), diff --git a/packages/cli/src/setup-sources.test.ts b/packages/cli/src/setup-sources.test.ts index a9de4436..d75933c1 100644 --- a/packages/cli/src/setup-sources.test.ts +++ b/packages/cli/src/setup-sources.test.ts @@ -48,6 +48,7 @@ function prompts(values: { return { multiselect: vi.fn(async () => multiselectValues.shift() ?? []), select: vi.fn(async () => selectValues.shift() ?? 'skip'), + autocomplete: vi.fn(async () => selectValues.shift() ?? 'skip'), text: vi.fn(async () => (textValues.length > 0 ? textValues.shift() : '')), password: vi.fn(async () => (passwordValues.length > 0 ? passwordValues.shift() : undefined)), cancel: vi.fn(), @@ -548,8 +549,9 @@ describe('setup sources step', () => { ], }); if (testCase.source === 'metabase') { - expect(testPrompts.select).toHaveBeenCalledWith({ + expect(testPrompts.autocomplete).toHaveBeenCalledWith({ message: 'Metabase database', + placeholder: 'Type to search databases', options: [ { value: '1', label: '1: Finance (postgres)' }, { value: '2', label: '2: Analytics (postgres)' }, diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index ff8eb420..a3f8019d 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -71,6 +71,11 @@ export interface KtxSetupSourcesPromptAdapter { required?: boolean; }): Promise; select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; + autocomplete(options: { + message: string; + placeholder?: string; + options: KtxSetupPromptOption[]; + }): Promise; text(options: { message: string; placeholder?: string; initialValue?: string }): Promise; password(options: { message: string }): Promise; cancel(message: string): void; @@ -931,8 +936,9 @@ async function chooseMetabaseDatabaseId(input: { return discovered[0].id; } if (discovered.length > 1) { - const selected = await input.prompts.select({ + const selected = await input.prompts.autocomplete({ message: 'Metabase database', + placeholder: 'Type to search databases', options: [ ...discovered .slice()