feat: add searchable setup prompt pickers

This commit is contained in:
Andrey Avtomonov 2026-05-21 19:32:56 +02:00
parent fd2ba62d92
commit 6e1a31e159
6 changed files with 181 additions and 46 deletions

View file

@ -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?'),
}),

View file

@ -61,6 +61,11 @@ export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code';
/** @internal */
export interface KtxSetupModelPromptAdapter {
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
autocomplete(options: {
message: string;
placeholder?: string;
options: KtxSetupPromptOption[];
}): Promise<string>;
text(options: { message: string; placeholder?: string }): Promise<string | undefined>;
password(options: { message: string }): Promise<string | undefined>;
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,

View file

@ -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[] = [];

View file

@ -1,5 +1,7 @@
import type { Writable } from 'node:stream';
import {
autocomplete,
autocompleteMultiselect,
cancel,
confirm,
intro,
@ -38,6 +40,22 @@ interface KtxSetupMultiselectOptions<Value extends string = string> {
cursorAt?: Value;
}
interface KtxSetupAutocompleteOptions<Value extends string = string> {
message: string;
options: Array<KtxSetupPromptOption<Value>>;
placeholder?: string;
maxItems?: number;
}
interface KtxSetupAutocompleteMultiselectOptions<Value extends string = string> {
message: string;
options: Array<KtxSetupPromptOption<Value>>;
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<string>;
multiselect(options: KtxSetupMultiselectOptions): Promise<string[]>;
autocomplete(options: KtxSetupAutocompleteOptions): Promise<string>;
autocompleteMultiselect(options: KtxSetupAutocompleteMultiselectOptions): Promise<string[]>;
text(options: KtxSetupTextOptions): Promise<string | undefined>;
password(options: KtxSetupPasswordOptions): Promise<string | undefined>;
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) }),

View file

@ -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)' },

View file

@ -71,6 +71,11 @@ export interface KtxSetupSourcesPromptAdapter {
required?: boolean;
}): Promise<string[]>;
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
autocomplete(options: {
message: string;
placeholder?: string;
options: KtxSetupPromptOption[];
}): Promise<string>;
text(options: { message: string; placeholder?: string; initialValue?: string }): Promise<string | undefined>;
password(options: { message: string }): Promise<string | undefined>;
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()