import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useStdout } from 'ink';
import { useState } from 'react';
import type { ZodString } from 'zod';
/** Custom validation beyond schema - returns true if valid, or error message string if invalid */
type CustomValidation = (value: string) => true | string;
interface TextInputProps {
prompt: string;
onSubmit: (value: string) => void;
onCancel: () => void;
placeholder?: string;
initialValue?: string;
/** Zod string schema for validation - error message is extracted from schema */
schema?: ZodString;
/** Custom validation beyond schema - both validate function and error message are required together */
customValidation?: CustomValidation;
allowEmpty?: boolean;
/** Mask character to hide input (e.g., '*' for passwords/API keys) */
mask?: string;
/** Hide the built-in "> " prompt arrow (default false) */
hideArrow?: boolean;
/** Allow text to wrap across multiple lines instead of truncating (default false) */
expandable?: boolean;
/** Called when the input value changes, with a setValue callback to transform input */
onChange?: (value: string, setValue: (v: string) => void) => void;
/** Called when backspace is pressed on an empty input */
onBackspaceEmpty?: () => void;
/** Called when up arrow is pressed */
onUpArrow?: () => void;
/** Called when down arrow is pressed */
onDownArrow?: () => void;
}
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;
}
export function TextInput({
prompt,
onSubmit,
onCancel,
placeholder,
initialValue = '',
schema,
customValidation,
allowEmpty = false,
mask,
hideArrow = false,
expandable = false,
onChange: onChangeProp,
onBackspaceEmpty,
onUpArrow,
onDownArrow,
}: TextInputProps) {
const [showError, setShowError] = useState(false);
const { stdout } = useStdout();
const { value, cursor, setValue } = useTextInput({
initialValue,
onChange: onChangeProp ? (v: string) => onChangeProp(v, setValue) : undefined,
onBackspaceEmpty,
onUpArrow,
onDownArrow,
onSubmit: val => {
const trimmed = val.trim();
const hasValue = allowEmpty || trimmed;
const validationError = validateValue(trimmed, schema, customValidation);
if (hasValue && !validationError) {
onSubmit(trimmed);
} else {
setShowError(true);
}
},
onCancel,
});
const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;
const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
const showInvalidMark = hasInput && !isValid && hasValidation;
// Get display value (masked or plain)
const displayValue = mask ? mask.repeat(value.length) : value;
// Simple split for cursor positioning (used by both modes)
const beforeCursorFull = displayValue.slice(0, cursor);
const charAtCursorFull = displayValue[cursor] ?? ' ';
const afterCursorFull = displayValue.slice(cursor + 1);
if (expandable) {
return (
{prompt && {prompt}}
{!hideArrow && > }
{beforeCursorFull}
{afterCursorFull}
{!value && placeholder && {placeholder.slice(1)}}
{showCheckmark && ✓}
{showInvalidMark && ✗}
{(showError || showInvalidMark) && validationErrorMsg && {validationErrorMsg}}
);
}
// Calculate available width for text display
// Account for: arrow (2 chars), cursor (1 char), checkmark/x (2 chars), padding (2 chars)
const terminalWidth = stdout?.columns ?? 80;
const reservedChars = (hideArrow ? 0 : 2) + 1 + 2 + 2;
const maxDisplayWidth = Math.max(20, terminalWidth - reservedChars);
// Calculate windowed view if text is too long
let beforeCursor: string;
let charAtCursor: string;
let afterCursor: string;
let showEllipsisBefore = false;
let showEllipsisAfter = false;
if (displayValue.length <= maxDisplayWidth) {
// Text fits - show everything
beforeCursor = beforeCursorFull;
charAtCursor = charAtCursorFull;
afterCursor = afterCursorFull;
} else {
// Text too long - create a window around cursor
const windowSize = maxDisplayWidth - 2; // Reserve space for ellipsis indicators
const halfWindow = Math.floor(windowSize / 2);
let windowStart = Math.max(0, cursor - halfWindow);
let windowEnd = Math.min(displayValue.length, windowStart + windowSize);
// Adjust window if we're near the end
if (windowEnd === displayValue.length) {
windowStart = Math.max(0, displayValue.length - windowSize);
}
// Adjust if we're near the start
if (windowStart === 0) {
windowEnd = Math.min(displayValue.length, windowSize);
}
showEllipsisBefore = windowStart > 0;
showEllipsisAfter = windowEnd < displayValue.length;
const cursorInWindow = cursor - windowStart;
const windowedText = displayValue.slice(windowStart, windowEnd);
beforeCursor = windowedText.slice(0, cursorInWindow);
charAtCursor = windowedText[cursorInWindow] ?? ' ';
afterCursor = windowedText.slice(cursorInWindow + 1);
}
return (
{prompt && {prompt}}
{!hideArrow && > }
{showEllipsisBefore && …}
{beforeCursor}
{afterCursor}
{showEllipsisAfter && …}
{!value && placeholder && {placeholder.slice(1)}}
{showCheckmark && ✓}
{showInvalidMark && ✗}
{(showError || showInvalidMark) && validationErrorMsg && {validationErrorMsg}}
);
}