{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink/text-input",
  "title": "Text Input",
  "description": "Single-line text input with placeholder, validation, mask",
  "dependencies": [
    "ink"
  ],
  "registryDependencies": [
    "https://termcn.dev/r/ink/interaction.json",
    "https://termcn.dev/r/ink/terminal-style.json",
    "https://termcn.dev/r/ink/terminal-symbols.json",
    "https://termcn.dev/r/ink/terminal-text.json",
    "https://termcn.dev/r/ink/types.json",
    "https://termcn.dev/r/ink/use-theme.json",
    "https://termcn.dev/r/ink/use-unicode.json"
  ],
  "files": [
    {
      "path": "registry/ui/text-input.tsx",
      "content": "import { useCursor, Box, Text } from \"ink\";\nimport React, { useEffect, useState } from \"react\";\n\nimport { useInteraction } from \"@/hooks/use-interaction\";\nimport { useTheme } from \"@/hooks/use-theme\";\nimport { useUnicode } from \"@/hooks/use-unicode\";\nimport { resolveBorderStyle } from \"@/lib/terminal-style\";\nimport { resolveTerminalSymbol } from \"@/lib/terminal-symbols\";\nimport {\n  cursorCellOffset,\n  graphemeLength,\n  insertAtGrapheme,\n  removeGraphemeAt,\n  removeGraphemeBefore,\n  sliceGraphemes,\n  splitGraphemes,\n} from \"@/lib/terminal-text\";\nimport type { BorderStyle } from \"@/components/ui/types\";\n\nexport interface TextInputProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  onChange?: (value: string) => void;\n  onSubmit?: (value: string) => void;\n  placeholder?: string;\n  mask?: string;\n  showCursor?: boolean;\n  highlightPastedText?: boolean;\n  validate?: (value: string) => string | null;\n  width?: number;\n  label?: string;\n  autoFocus?: boolean;\n  id?: string;\n  isActive?: boolean;\n  disabled?: boolean;\n  readOnly?: boolean;\n  required?: boolean;\n  \"aria-label\"?: string;\n  cursorOrigin?: { x: number; y: number };\n  bordered?: boolean;\n  borderStyle?: BorderStyle;\n  paddingX?: number;\n  cursor?: string;\n}\n\nexport const TextInput = ({\n  value: controlledValue,\n  defaultValue = \"\",\n  onValueChange,\n  onChange,\n  onSubmit,\n  placeholder = \"\",\n  mask,\n  showCursor = true,\n  highlightPastedText = false,\n  validate,\n  width = 40,\n  label,\n  autoFocus = false,\n  id,\n  isActive = true,\n  disabled = false,\n  readOnly = false,\n  required = false,\n  \"aria-label\": ariaLabel,\n  cursorOrigin,\n  bordered = true,\n  borderStyle = \"round\",\n  paddingX = 1,\n  cursor,\n}: TextInputProps) => {\n  const unicode = useUnicode();\n  const resolvedCursor = cursor ?? resolveTerminalSymbol(unicode, \"█\", \"|\");\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const [cursorOffset, setCursorOffset] = useState(0);\n  const [cursorWidth, setCursorWidth] = useState(0);\n  const [error, setError] = useState<string | null>(null);\n  const theme = useTheme();\n  const { setCursorPosition } = useCursor();\n\n  const value = controlledValue ?? internalValue;\n  const valueLength = graphemeLength(value);\n\n  useEffect(() => {\n    if (cursorOffset > valueLength) {\n      setCursorOffset(valueLength);\n    }\n  }, [cursorOffset, valueLength]);\n\n  const setValue = (next: string) => {\n    const changeHandler = onValueChange ?? onChange;\n    if (changeHandler) {\n      changeHandler(next);\n    } else {\n      setInternalValue(next);\n    }\n  };\n\n  const { isFocused } = useInteraction(\n    (input, key) => {\n      if (\n        key.upArrow ||\n        key.downArrow ||\n        (key.ctrl && input === \"c\") ||\n        key.tab ||\n        (key.shift && key.tab)\n      ) {\n        return;\n      }\n\n      if (key.return) {\n        const err = validate ? validate(value) : null;\n        if (err) {\n          setError(err);\n          return;\n        }\n        setError(null);\n        onSubmit?.(value);\n        return;\n      }\n\n      if (key.escape) {\n        return;\n      }\n\n      let nextOffset = cursorOffset;\n      let nextValue = value;\n      let nextCursorWidth = 0;\n\n      if (key.leftArrow) {\n        if (showCursor) {\n          nextOffset = Math.max(0, nextOffset - 1);\n        }\n      } else if (key.rightArrow) {\n        if (showCursor) {\n          nextOffset = Math.min(valueLength, nextOffset + 1);\n        }\n      } else if (key.home) {\n        nextOffset = 0;\n      } else if (key.end) {\n        nextOffset = valueLength;\n      } else if (!readOnly && key.backspace) {\n        const result = removeGraphemeBefore(value, cursorOffset);\n        nextValue = result.value;\n        nextOffset = result.cursor;\n      } else if (!readOnly && key.delete) {\n        const result = removeGraphemeAt(value, cursorOffset);\n        nextValue = result.value;\n        nextOffset = result.cursor;\n      } else if (!readOnly && input) {\n        nextValue = insertAtGrapheme(value, cursorOffset, input);\n        const insertedLength = graphemeLength(input);\n        nextOffset = cursorOffset + insertedLength;\n\n        if (insertedLength > 1) {\n          nextCursorWidth = insertedLength;\n        }\n      }\n\n      setCursorOffset(nextOffset);\n      setCursorWidth(nextCursorWidth);\n\n      if (nextValue !== value) {\n        setValue(nextValue);\n      }\n    },\n    { autoFocus, disabled, id, isActive }\n  );\n\n  const displayValue = mask ? mask.repeat(valueLength) : value;\n  const useNativeCursor = Boolean(cursorOrigin && showCursor && isFocused);\n  useEffect(() => {\n    setCursorPosition(\n      useNativeCursor && cursorOrigin\n        ? {\n            x: cursorOrigin.x + cursorCellOffset(displayValue, cursorOffset),\n            y: cursorOrigin.y,\n          }\n        : undefined\n    );\n    return () => setCursorPosition(undefined);\n  }, [\n    cursorOffset,\n    cursorOrigin,\n    displayValue,\n    setCursorPosition,\n    useNativeCursor,\n  ]);\n\n  let borderColor: string;\n  if (error) {\n    borderColor = theme.colors.error;\n  } else if (isFocused) {\n    borderColor = theme.colors.focusRing;\n  } else {\n    borderColor = theme.colors.border;\n  }\n\n  const pasteWidth = highlightPastedText ? cursorWidth : 0;\n\n  const renderValue = () => {\n    if (!value && placeholder) {\n      if (showCursor && isFocused && !useNativeCursor) {\n        const placeholderGraphemes = splitGraphemes(placeholder);\n        return (\n          <Text color={theme.colors.mutedForeground}>\n            <Text inverse>{placeholderGraphemes[0] ?? \" \"}</Text>\n            {placeholderGraphemes.slice(1)}\n          </Text>\n        );\n      }\n      return <Text color={theme.colors.mutedForeground}>{placeholder}</Text>;\n    }\n\n    if (!showCursor || !isFocused || useNativeCursor) {\n      return <Text color={theme.colors.foreground}>{displayValue}</Text>;\n    }\n\n    const before = sliceGraphemes(displayValue, 0, cursorOffset - pasteWidth);\n    const highlighted = sliceGraphemes(\n      displayValue,\n      cursorOffset - pasteWidth,\n      cursorOffset\n    );\n    const cursorChar =\n      cursorOffset < graphemeLength(displayValue)\n        ? sliceGraphemes(displayValue, cursorOffset, cursorOffset + 1)\n        : resolvedCursor;\n    const after = sliceGraphemes(displayValue, cursorOffset + 1);\n\n    return (\n      <Text color={theme.colors.foreground}>\n        {before}\n        {highlighted && <Text inverse>{highlighted}</Text>}\n        <Text inverse color={theme.colors.focusRing}>\n          {cursorChar}\n        </Text>\n        {after}\n      </Text>\n    );\n  };\n\n  return (\n    <Box flexDirection=\"column\">\n      {label && <Text bold>{label}</Text>}\n      {bordered ? (\n        <Box\n          aria-label={\n            ariaLabel ??\n            `${label ?? \"Text input\"}: ${\n              mask ? `${valueLength} characters` : value || \"empty\"\n            }${error ? `. Error: ${error}` : \"\"}`\n          }\n          aria-role=\"textbox\"\n          aria-state={{ disabled, readonly: readOnly, required }}\n          borderStyle={resolveBorderStyle(borderStyle, unicode)}\n          borderColor={borderColor}\n          width={width}\n          paddingX={paddingX}\n        >\n          {renderValue()}\n        </Box>\n      ) : (\n        <Box\n          aria-label={\n            ariaLabel ??\n            `${label ?? \"Text input\"}: ${\n              mask ? `${valueLength} characters` : value || \"empty\"\n            }${error ? `. Error: ${error}` : \"\"}`\n          }\n          aria-role=\"textbox\"\n          aria-state={{ disabled, readonly: readOnly, required }}\n          width={width}\n          paddingX={paddingX}\n        >\n          {renderValue()}\n        </Box>\n      )}\n      {error && <Text color={theme.colors.error}>{error}</Text>}\n    </Box>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "input"
  ],
  "type": "registry:ui"
}