/** * text-input.tsx — Ink TextInput component with full cursor navigation. * * A reusable text input component for Ink that replaces blessed-textarea-patch.ts. * * Features: * - Arrow key cursor navigation (left/right/up/down) * - Home/End to jump within lines * - Insert/delete at cursor position * - Ctrl+S to submit (not Enter) * - Ctrl+E to open $EDITOR * - Single-line and multi-line modes * - Placeholder text when empty * - Focus/blur support * * Usage: * */ import React, { useState, useEffect, useCallback } from "react"; import { Box, Text, useInput } from "ink"; import { TextBuffer } from "./text-buffer"; export interface TextInputProps { /** Current value of the text input. */ value?: string; /** Called when the value changes (controlled component). */ onChange?: (value: string) => void; /** Called when the user presses Ctrl+S to submit. */ onSubmit?: (value: string) => void; /** * Called when the user presses Ctrl+E to open an external editor. * The parent component should handle spawning the editor and updating * the value via onChange when the editor returns. */ onEditorRequest?: (currentValue: string) => void; /** Placeholder text shown when the input is empty. */ placeholder?: string; /** Whether the input accepts multiple lines. Default: false. */ multiline?: boolean; /** Whether the input is focused and accepting keyboard input. Default: true. */ focus?: boolean; } /** * Render the text value with a cursor indicator. * * In Ink, we simulate a cursor by inverting colors at the cursor position. * The cursor is shown as an inverse-colored character (or space at the end). */ function renderWithCursor( value: string, cursorPos: number, focused: boolean, ): React.ReactElement { if (!focused || value.length === 0) { if (value.length === 0 && focused) { // Show cursor in empty field return ( ); } return {value}; } // Cursor at end of value if (cursorPos >= value.length) { return ( {value} ); } const before = value.slice(0, cursorPos); const cursor = value[cursorPos]; const after = value.slice(cursorPos + 1); return ( {before} {cursor} {after} ); } /** * TextInput — a reusable Ink text input component. * * Manages a TextBuffer internally and dispatches onChange/onSubmit callbacks. * Supports single-line and multi-line modes. */ export function TextInput({ value = "", onChange, onSubmit, onEditorRequest, placeholder, multiline = false, focus = true, }: TextInputProps): React.ReactElement { // Internal buffer tracks cursor position independently of the controlled value const [buffer] = useState(() => new TextBuffer(value)); const [cursorPos, setCursorPos] = useState(buffer.cursorPos); // Sync buffer with external value changes useEffect(() => { if (buffer.value !== value) { buffer.setValue(value); setCursorPos(buffer.cursorPos); } }, [value, buffer]); const handleInput = useCallback( (input: string, key: import("ink").Key) => { // Ctrl+S — submit if (key.ctrl && input === "s") { onSubmit?.(buffer.value); return; } // Ctrl+E — open external editor if (key.ctrl && input === "e") { onEditorRequest?.(buffer.value); return; } // Arrow keys if (key.leftArrow) { buffer.moveLeft(); setCursorPos(buffer.cursorPos); return; } if (key.rightArrow) { buffer.moveRight(); setCursorPos(buffer.cursorPos); return; } if (key.upArrow) { buffer.moveUp(); setCursorPos(buffer.cursorPos); return; } if (key.downArrow) { buffer.moveDown(); setCursorPos(buffer.cursorPos); return; } // Home / End if (key.home) { buffer.moveHome(); setCursorPos(buffer.cursorPos); return; } if (key.end) { buffer.moveEnd(); setCursorPos(buffer.cursorPos); return; } // Backspace if (key.backspace) { buffer.deleteBack(); setCursorPos(buffer.cursorPos); onChange?.(buffer.value); return; } // Delete if (key.delete) { buffer.deleteForward(); setCursorPos(buffer.cursorPos); onChange?.(buffer.value); return; } // Return/Enter if (key.return) { if (multiline) { buffer.insert("\n"); setCursorPos(buffer.cursorPos); onChange?.(buffer.value); } // Single-line mode: Enter is a no-op (Ctrl+S is the submit key) return; } // Tab — ignore (used for focus navigation in forms) if (key.tab) { return; } // Escape — ignore (could be used by parent for navigation) if (key.escape) { return; } // Filter out control characters (but allow normal text input) if (key.ctrl || key.meta) { return; } // Regular character input if (input && input.length > 0) { buffer.insert(input); setCursorPos(buffer.cursorPos); onChange?.(buffer.value); } }, [buffer, onChange, onSubmit, onEditorRequest, multiline] ); useInput(handleInput, { isActive: focus }); // Render const isEmpty = buffer.value.length === 0; const showPlaceholder = isEmpty && placeholder && !focus; if (showPlaceholder) { return ( {placeholder} ); } // Show placeholder even when focused but empty (dimmed) if (isEmpty && placeholder && focus) { return ( {placeholder} {focus && ( )} ); } // Multiline rendering: render each line separately if (multiline && buffer.value.includes("\n")) { const lines = buffer.lines; let charOffset = 0; return ( {lines.map((line, lineIdx) => { const lineStart = charOffset; charOffset += line.length + 1; // +1 for newline // Check if cursor is on this line const cursorOnLine = cursorPos >= lineStart && cursorPos <= lineStart + line.length; if (cursorOnLine && focus) { const colInLine = cursorPos - lineStart; return ( {renderWithCursor(line, colInLine, true)} ); } return ( {line || " "} ); })} ); } // Single-line rendering return ( {renderWithCursor(buffer.value, cursorPos, focus)} ); }