terminal-chat-ui
Version:
Shared UI components for terminal-based chat interfaces using Theater actors
209 lines (208 loc) • 9.97 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
/**
* MultiLineInput - advanced multi-line input with vim-style editing (from theater-chat)
*/
import { Box, Text, useInput } from 'ink';
import { useCallback, useState } from 'react';
/**
* Advanced multi-line input with vim-style modal editing and cursor management
*/
export function MultiLineInput({ placeholder = 'Type your message...', onSubmit, maxHeight = 6, mode = 'insert', onModeChange, content = '', cursorPosition = 0, onContentChange, onCursorChange, disabled = false, verbose = false }) {
// Use internal state if not controlled
const [internalContent, setInternalContent] = useState('');
const [internalCursorPosition, setInternalCursorPosition] = useState(0);
// Determine if we're in controlled mode
const isControlled = onContentChange !== undefined;
// Get current values (controlled or uncontrolled)
const actualContent = isControlled ? content : internalContent;
const actualCursorPosition = isControlled ? cursorPosition : internalCursorPosition;
// Convert content to lines for display
const lines = actualContent.split('\n');
const isEmpty = actualContent.length === 0;
// Find cursor row and column
const getCursorLocation = useCallback((pos) => {
const beforeCursor = actualContent.slice(0, pos);
const row = beforeCursor.split('\n').length - 1;
const lastNewline = beforeCursor.lastIndexOf('\n');
const col = lastNewline === -1 ? pos : pos - lastNewline - 1;
return { row, col };
}, [actualContent]);
const { row: cursorRow, col: cursorCol } = getCursorLocation(actualCursorPosition);
// Text manipulation functions
const insertText = useCallback((text) => {
if (disabled)
return;
const before = actualContent.slice(0, actualCursorPosition);
const after = actualContent.slice(actualCursorPosition);
const newContent = before + text + after;
const newCursor = actualCursorPosition + text.length;
if (isControlled) {
onContentChange?.(newContent);
onCursorChange?.(newCursor);
}
else {
setInternalContent(newContent);
setInternalCursorPosition(newCursor);
}
}, [actualContent, actualCursorPosition, onContentChange, onCursorChange, disabled, isControlled]);
const deleteChar = useCallback((direction = 'backward') => {
if (disabled)
return;
if (direction === 'backward' && actualCursorPosition > 0) {
const before = actualContent.slice(0, actualCursorPosition - 1);
const after = actualContent.slice(actualCursorPosition);
const newContent = before + after;
const newCursor = actualCursorPosition - 1;
if (isControlled) {
onContentChange?.(newContent);
onCursorChange?.(newCursor);
}
else {
setInternalContent(newContent);
setInternalCursorPosition(newCursor);
}
}
else if (direction === 'forward' && actualCursorPosition < actualContent.length) {
const before = actualContent.slice(0, actualCursorPosition);
const after = actualContent.slice(actualCursorPosition + 1);
const newContent = before + after;
if (isControlled) {
onContentChange?.(newContent);
// Cursor stays same for forward delete
}
else {
setInternalContent(newContent);
// Cursor stays same for forward delete
}
}
}, [actualContent, actualCursorPosition, onContentChange, onCursorChange, disabled, isControlled]);
const moveCursor = useCallback((newPos) => {
if (disabled)
return;
const clampedPos = Math.max(0, Math.min(actualContent.length, newPos));
if (isControlled) {
onCursorChange?.(clampedPos);
}
else {
setInternalCursorPosition(clampedPos);
}
}, [actualContent.length, onCursorChange, disabled, isControlled]);
const handleSubmit = useCallback(() => {
if (disabled)
return;
const trimmed = actualContent.trim();
if (trimmed) {
onSubmit(trimmed);
// Clear the input after successful submission
if (isControlled) {
onContentChange?.('');
onCursorChange?.(0);
}
else {
setInternalContent('');
setInternalCursorPosition(0);
}
}
}, [actualContent, onSubmit, onContentChange, onCursorChange, disabled, isControlled]);
// Key input handler
useInput((input, key) => {
if (verbose) {
console.log('[DEBUG MultiLineInput] Input received:', { input, key, disabled });
}
if (disabled)
return;
if (key.escape) {
onModeChange?.('command');
return;
}
// Handle return key for both modes
if (key.return) {
if (mode === 'command') {
// In command mode, plain Return submits
handleSubmit();
return;
}
else if (mode === 'insert') {
// In insert mode, Return adds newline
insertText('\n');
return;
}
}
// Command mode key handling
if (mode === 'command') {
if (input === 'i') {
// 'i' enters insert mode
onModeChange?.('insert');
return;
}
// In command mode, ignore most other keys except navigation
if (key.leftArrow) {
moveCursor(actualCursorPosition - 1);
return;
}
if (key.rightArrow) {
moveCursor(actualCursorPosition + 1);
return;
}
// Ignore other input in command mode
return;
}
// Insert mode: Regular characters
if (input && !key.ctrl && !key.meta) {
insertText(input);
return;
}
// Insert mode only: Arrow key navigation
if (key.leftArrow) {
moveCursor(actualCursorPosition - 1);
return;
}
if (key.rightArrow) {
moveCursor(actualCursorPosition + 1);
return;
}
// Up/down arrow navigation
if (key.upArrow) {
const currentLineStart = actualContent.lastIndexOf('\n', actualCursorPosition - 1);
const prevLineStart = currentLineStart > 0 ? actualContent.lastIndexOf('\n', currentLineStart - 1) : -1;
if (prevLineStart !== -1) {
const targetCol = cursorCol;
const prevLineEnd = currentLineStart;
const prevLineLength = prevLineEnd - prevLineStart - 1;
const newPos = prevLineStart + 1 + Math.min(targetCol, prevLineLength);
moveCursor(newPos);
}
return;
}
if (key.downArrow) {
const currentLineEnd = actualContent.indexOf('\n', actualCursorPosition);
if (currentLineEnd !== -1) {
const nextLineEnd = actualContent.indexOf('\n', currentLineEnd + 1);
const targetCol = cursorCol;
const nextLineStart = currentLineEnd + 1;
const nextLineLength = nextLineEnd !== -1 ? nextLineEnd - nextLineStart : actualContent.length - nextLineStart;
const newPos = nextLineStart + Math.min(targetCol, nextLineLength);
moveCursor(newPos);
}
return;
}
// Delete (insert mode only)
if (key.backspace || key.delete) {
deleteChar('backward');
return;
}
});
// Render
const displayLines = lines.slice(0, maxHeight);
const hasMoreLines = lines.length > maxHeight;
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Box, { borderStyle: "round", borderColor: disabled ? "gray" : "gray", paddingLeft: 1, paddingRight: 1, flexDirection: "column", minHeight: 3, width: "100%", children: _jsxs(Box, { flexDirection: "column", children: [isEmpty ? (_jsxs(Text, { children: [_jsx(Text, { backgroundColor: "white", color: "black", children: " " }), _jsx(Text, { color: "gray", children: placeholder })] })) : (displayLines.map((line, index) => {
const isCurrentLine = index === cursorRow && cursorRow < maxHeight;
if (!isCurrentLine) {
return _jsx(Text, { children: line }, index);
}
const beforeCursor = line.slice(0, cursorCol);
const atCursor = cursorCol < line.length ? line[cursorCol] : ' ';
const afterCursor = cursorCol < line.length ? line.slice(cursorCol + 1) : '';
return (_jsxs(Text, { children: [beforeCursor, _jsx(Text, { backgroundColor: disabled ? "gray" : (mode === 'command' ? "blue" : "white"), color: mode === 'command' ? "white" : "black", children: atCursor }), afterCursor] }, index));
})), hasMoreLines && (_jsxs(Text, { color: "gray", dimColor: true, children: ["... ", lines.length - maxHeight, " more lines"] }))] }) }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: mode === 'insert' ? 'green' : 'blue', dimColor: true, children: mode?.toUpperCase() || 'INSERT' }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Line ", cursorRow + 1, ", Col ", cursorCol + 1, lines.length > 1 && ` • ${lines.length} lines`, !isEmpty && ` • ${content.length} chars`] })] })] }));
}