import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { useState } from 'react';
import type { ZodString } from 'zod';
// Width for each line of input display (account for borders, padding, prompt)
const LINE_WIDTH = 52;
/** Custom validation beyond schema - returns true if valid, or error message string if invalid */
type CustomValidation = (value: string) => true | string;
export interface SecretInputProps {
/** Label displayed above the input */
prompt: string;
/** Called when user submits a value */
onSubmit: (value: string) => void;
/** Called when user cancels (Esc) */
onCancel: () => void;
/** Called when user skips (empty value + Enter). If not provided, empty values are treated as cancel. */
onSkip?: () => void;
/** Initial value */
initialValue?: string;
/** Zod string schema for validation */
schema?: ZodString;
/** Custom validation function */
customValidation?: CustomValidation;
/** Placeholder text when empty */
placeholder?: string;
/** Optional description shown below the prompt */
description?: string;
/** Whether this component should receive input */
isActive?: boolean;
/** Character used for masking (default: '*') */
maskChar?: string;
/** Show partial value for verification (first/last N chars). 0 = fully masked. Default: 0 */
revealChars?: number;
}
function validateValue(value: string, schema?: ZodString, customValidation?: CustomValidation): string | undefined {
if (!value) return undefined;
if (customValidation) {
const result = customValidation(value);
if (result !== true) {
return result;
}
}
if (schema) {
const parseResult = schema.safeParse(value);
if (!parseResult.success) {
return parseResult.error.issues[0]?.message;
}
}
return undefined;
}
/**
* Secure input component for sensitive data like API keys and passwords.
*
* Features:
* - Masked input by default
* - Tab to toggle show/hide
* - Optional partial reveal (show first/last N chars)
* - Validation support (Zod schema and custom)
* - Skip functionality for optional inputs
*/
export function SecretInput({
prompt,
onSubmit,
onCancel,
onSkip,
initialValue = '',
schema,
customValidation,
placeholder,
description,
isActive = true,
maskChar = '*',
revealChars = 0,
}: SecretInputProps) {
const [showValue, setShowValue] = useState(false);
const [showError, setShowError] = useState(false);
const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
if (!trimmed) {
if (onSkip) {
onSkip();
} else {
onCancel();
}
return;
}
const validationError = validateValue(trimmed, schema, customValidation);
if (!validationError) {
onSubmit(trimmed);
} else {
setShowError(true);
}
},
onCancel,
onChange: () => setShowError(false),
isActive,
});
// Handle Tab separately for show/hide toggle
useInput(
(_input, key) => {
if (key.tab) {
setShowValue(s => !s);
}
},
{ isActive }
);
const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;
// Generate display value (masked or plain)
const getDisplayValue = (): string => {
if (showValue) {
return value;
}
if (value.length === 0) {
return '';
}
const cursorAtEnd = cursor === value.length;
// Editing (cursor not at end) - show actual value so user can see what they're doing
if (!cursorAtEnd) {
return value;
}
// Done typing (cursor at end) - show partial reveal if configured
if (revealChars > 0 && value.length > revealChars * 2) {
const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return `${start}${maskChar.repeat(middleLength)}${end}`;
}
// Full mask (no reveal configured or value too short)
return maskChar.repeat(value.length);
};
// Split text into lines and determine which line has the cursor
const getLines = (): { lines: string[]; cursorLine: number; cursorCol: number } => {
const displayValue = getDisplayValue();
if (displayValue.length === 0) {
return { lines: [], cursorLine: 0, cursorCol: 0 };
}
const lines: string[] = [];
for (let i = 0; i < displayValue.length; i += LINE_WIDTH) {
lines.push(displayValue.slice(i, i + LINE_WIDTH));
}
const cursorLine = Math.floor(cursor / LINE_WIDTH);
const cursorCol = cursor % LINE_WIDTH;
return { lines, cursorLine, cursorCol };
};
const { lines, cursorLine, cursorCol } = getLines();
const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
const showInvalidMark = hasInput && !isValid && hasValidation;
// Render a line with cursor if this is the cursor line
// Cursor overlays the character at position (or shows space at end)
const renderLine = (line: string, lineIndex: number, isFirstLine: boolean) => {
const isCursorLine = lineIndex === cursorLine;
const prefix = isFirstLine ? > : ;
if (isCursorLine) {
const before = line.slice(0, cursorCol);
const charAtCursor = line[cursorCol] ?? ' '; // Space if at end of line
const after = line.slice(cursorCol + 1);
return (
{prefix}
{before}
{after}
{isFirstLine && showCheckmark && ✓}
{isFirstLine && showInvalidMark && ✗}
);
}
return (
{prefix}
{line}
);
};
return (
{prompt}
{description && (
{description}
)}
{value ? (
lines.map((line, i) => renderLine(line, i, i === 0))
) : placeholder ? (
>
{placeholder.slice(1)}
) : (
>
)}
{(showError || showInvalidMark) && validationErrorMsg && (
{validationErrorMsg}
)}
Tab to {showValue ? 'hide' : 'show'} · Enter to submit · Esc to {onSkip ? 'go back' : 'cancel'}
{onSkip && ' · Leave empty to skip'}
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Specialized variants for common use cases
// ─────────────────────────────────────────────────────────────────────────────
export interface ApiKeySecretInputProps {
/** Model provider name for display */
providerName: string;
/** Environment variable name for the API key */
envVarName: string;
/** Called when user submits an API key */
onSubmit: (apiKey: string) => void;
/** Called when user skips */
onSkip: () => void;
/** Called when user cancels */
onCancel: () => void;
/** Whether this component should receive input */
isActive?: boolean;
}
/**
* Specialized SecretInput for API keys with provider-specific messaging.
*/
export function ApiKeySecretInput({
providerName,
envVarName,
onSubmit,
onSkip,
onCancel,
isActive = true,
}: ApiKeySecretInputProps) {
return (
);
}