import { updateSettings } from '@clack/core'; import { autocomplete, autocompleteMultiselect, cancel, confirm, intro, isCancel, log, multiselect, note, select, text, } from '@clack/prompts'; import type { KtxCliIo } from './cli-runtime.js'; import { unicodeSupported } from './io/symbols.js'; import { colorDepthForOutput, isWritableTtyOutput } from './io/tty.js'; import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js'; 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; hint?: string; disabled?: boolean; } interface KtxSetupSelectOptions { message: string; options: Array>; initialValue?: Value; maxItems?: number; } interface KtxSetupMultiselectOptions { message: string; options: Array>; required?: boolean; initialValues?: Value[]; maxItems?: number; 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; initialValue?: string; defaultValue?: string; } interface KtxSetupPasswordOptions { message: string; mask?: string; } 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; log(message: string): void; } export interface KtxSetupPromptAdapterOptions { selectCancelValue: 'back' | 'exit'; multiselectCancelValue?: 'back'; confirmEmptyOptionalMultiselect?: boolean; cancelOnSelectCancel?: boolean; cancelOnMultiselectCancel?: boolean; cancelMessage?: string; } const DEFAULT_SETUP_CANCEL_MESSAGE = 'Setup cancelled.'; export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOptions): KtxSetupPromptAdapter { const cancelMessage = options.cancelMessage ?? DEFAULT_SETUP_CANCEL_MESSAGE; const cancelOnSelectCancel = options.cancelOnSelectCancel ?? true; const cancelOnMultiselectCancel = options.cancelOnMultiselectCancel ?? true; const multiselectCancelValue = options.multiselectCancelValue ?? 'back'; return { async select(promptOptions) { const value = await withSetupInterruptConfirmation(() => select(withMenuOptionsSpacing(promptOptions))); if (isCancel(value)) { if (cancelOnSelectCancel) { cancel(cancelMessage); } return options.selectCancelValue; } return String(value); }, async multiselect(promptOptions) { while (true) { const value = await withSetupInterruptConfirmation(() => multiselect(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 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) }), ); return isCancel(value) ? undefined : String(value); }, async password(promptOptions) { const value = await withSetupInterruptConfirmation(() => revealPassword({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }), ); return isCancel(value) ? undefined : String(value); }, cancel(message) { cancel(message); }, log(message) { log.info(message); }, }; } interface KtxSetupNoteOptions { format?: (line: string) => string; } export interface KtxSetupUiAdapter { intro(title: string, io: KtxCliIo): void; note(message: string, title: string, io: KtxCliIo, options?: KtxSetupNoteOptions): void; } export function createKtxSetupUiAdapter(): KtxSetupUiAdapter { return { intro(title, io) { if (isWritableTtyOutput(io.stdout)) { const banner = renderKtxSetupBanner({ columns: io.stdout.columns ?? 80, colorDepth: colorDepthForOutput(io.stdout), unicode: unicodeSupported, }); if (banner !== '') { io.stdout.write(banner); } intro(title, { output: io.stdout }); return; } io.stdout.write(`${title}\n`); }, note(message, title, io, options) { if (isWritableTtyOutput(io.stdout)) { note(message, title, { output: io.stdout, ...(options?.format ? { format: options.format } : {}), }); return; } io.stdout.write(`\n${title}:\n`); io.stdout.write(`${message}\n`); }, }; }