askeroo
Version:
A modern CLI prompt library with flow control, history navigation, and conditional prompts
439 lines • 20.9 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect, useRef } from "react";
import { Text, useInput } from "ink";
export const TextInput = ({ value, onChange, onSubmit, cursorPosition: externalCursorPosition, onCursorPositionChange, isActive, color = "cyan", placeholder, onEscape, onUpArrow, onDownArrow, disableArrowKeys = false, onShiftF, }) => {
// Use internal state if no external cursor position is provided
const [internalCursorPosition, setInternalCursorPosition] = useState(value.length);
// Track whether we're making an internal change to prevent cursor auto-sync
const isInternalChange = useRef(false);
// Undo/redo history
const history = useRef([]);
const historyIndex = useRef(-1);
const isUndoRedoAction = useRef(false);
const lastSavedValue = useRef(value);
const lastSavedCursor = useRef(value.length);
const isTypingWord = useRef(false); // Track if user is actively typing a word
const wordStartCursor = useRef(value.length); // Track cursor position when word typing started
const lastWasSpace = useRef(false); // Track if last character typed was a space
const textAfterWordStart = useRef(""); // Track text after cursor when word typing started
const cursorPosition = externalCursorPosition !== undefined
? externalCursorPosition
: internalCursorPosition;
const setCursorPosition = (pos) => {
if (onCursorPositionChange) {
onCursorPositionChange(pos);
}
else {
setInternalCursorPosition(pos);
}
};
// Add current state to history (called at word boundaries)
const pushToHistory = (overrideCursor) => {
const cursorToSave = overrideCursor !== undefined ? overrideCursor : cursorPosition;
const currentState = { value, cursorPosition: cursorToSave };
// Check if this is actually a new state worth saving
if (lastSavedValue.current === currentState.value &&
lastSavedCursor.current === currentState.cursorPosition) {
return;
}
// Remove any future history if we're not at the end
history.current = history.current.slice(0, historyIndex.current + 1);
// Add new state
history.current.push(currentState);
historyIndex.current++;
// Update last saved refs
lastSavedValue.current = currentState.value;
lastSavedCursor.current = currentState.cursorPosition;
// Limit history to last 50 entries
if (history.current.length > 50) {
history.current.shift();
historyIndex.current--;
}
// Reset typing state
isTypingWord.current = false;
};
// Save word to history when finishing a word (called at boundaries)
const saveCurrentWord = () => {
if (isTypingWord.current) {
pushToHistory();
}
};
// Initialize history with the initial value on mount
useEffect(() => {
if (history.current.length === 0) {
const initialState = { value, cursorPosition };
history.current.push(initialState);
historyIndex.current = 0;
lastSavedValue.current = value;
lastSavedCursor.current = cursorPosition;
}
}, []); // Run once on mount
// Undo last change
const undo = () => {
// First, save the current word if we're typing one
if (isTypingWord.current && historyIndex.current >= 0) {
// Save current state WITH the word first (if not already saved)
if (lastSavedValue.current !== value ||
lastSavedCursor.current !== cursorPosition) {
history.current = history.current.slice(0, historyIndex.current + 1);
history.current.push({
value: value,
cursorPosition: cursorPosition,
});
historyIndex.current++;
}
// Then undo by one step (which goes back to state before typing the word)
if (historyIndex.current > 0) {
historyIndex.current--;
const prevState = history.current[historyIndex.current];
isUndoRedoAction.current = true;
isInternalChange.current = true;
lastSavedValue.current = prevState.value;
lastSavedCursor.current = prevState.cursorPosition;
onChange(prevState.value);
setCursorPosition(prevState.cursorPosition);
}
isTypingWord.current = false;
textAfterWordStart.current = ""; // Reset
return; // Don't continue to regular undo
}
// Now undo
if (historyIndex.current > 0) {
historyIndex.current--;
const prevState = history.current[historyIndex.current];
isUndoRedoAction.current = true;
isInternalChange.current = true;
lastSavedValue.current = prevState.value;
lastSavedCursor.current = prevState.cursorPosition;
onChange(prevState.value);
setCursorPosition(prevState.cursorPosition);
}
};
// Redo last undone change
const redo = () => {
if (historyIndex.current < history.current.length - 1) {
historyIndex.current++;
const nextState = history.current[historyIndex.current];
isUndoRedoAction.current = true;
isInternalChange.current = true;
lastSavedValue.current = nextState.value;
lastSavedCursor.current = nextState.cursorPosition;
onChange(nextState.value);
setCursorPosition(nextState.cursorPosition);
}
};
// Sync internal cursor position when value changes externally
// but NOT when we make internal changes
useEffect(() => {
if (externalCursorPosition === undefined && !isInternalChange.current) {
setInternalCursorPosition(value.length);
}
// Reset the flags after each render
isInternalChange.current = false;
isUndoRedoAction.current = false;
}, [value, externalCursorPosition]);
useInput((input, key) => {
// Handle Shift+F if callback is provided (for filter toggle)
if (onShiftF && key.shift && (input === "f" || input === "F")) {
onShiftF();
return;
}
// Undo: Ctrl+Z (Windows) or Cmd+Z (Mac)
if ((key.ctrl && !key.meta && input === "z" && !key.shift) ||
(key.meta && !key.shift && (input === "z" || input === ""))) {
undo();
return;
}
// Redo: Ctrl+Y (Windows) or Cmd+Shift+Z (Mac)
if ((key.ctrl && !key.meta && input === "y") ||
(key.meta && key.shift && (input === "z" || input === ""))) {
redo();
return;
}
// Keyboard shortcuts
// Ctrl+U: Clear from beginning to cursor (Unix-style)
if (key.ctrl && input === "u") {
saveCurrentWord(); // Save current word before clearing
// Save state BEFORE clearing (if not already saved)
if (lastSavedValue.current !== value ||
lastSavedCursor.current !== cursorPosition) {
history.current = history.current.slice(0, historyIndex.current + 1);
history.current.push({
value: value,
cursorPosition: cursorPosition,
});
historyIndex.current++;
lastSavedValue.current = value;
lastSavedCursor.current = cursorPosition;
}
// Clear from beginning to cursor
isInternalChange.current = true;
const newValue = value.slice(cursorPosition);
const newCursor = 0;
onChange(newValue);
setCursorPosition(newCursor);
// Save the cleared state
const clearedState = {
value: newValue,
cursorPosition: newCursor,
};
if (lastSavedValue.current !== clearedState.value ||
lastSavedCursor.current !== clearedState.cursorPosition) {
history.current = history.current.slice(0, historyIndex.current + 1);
history.current.push(clearedState);
historyIndex.current++;
lastSavedValue.current = clearedState.value;
lastSavedCursor.current = clearedState.cursorPosition;
}
return;
}
// Ctrl+K (or Meta+K): Clear from cursor to end
if ((key.ctrl && input === "k") || (key.meta && input === "k")) {
saveCurrentWord(); // Save current word before clearing
// Save state BEFORE clearing (if not already saved)
if (lastSavedValue.current !== value ||
lastSavedCursor.current !== cursorPosition) {
history.current = history.current.slice(0, historyIndex.current + 1);
history.current.push({
value: value,
cursorPosition: cursorPosition,
});
historyIndex.current++;
lastSavedValue.current = value;
lastSavedCursor.current = cursorPosition;
}
// Clear from cursor to end
isInternalChange.current = true;
const newValue = value.slice(0, cursorPosition);
onChange(newValue);
setCursorPosition(cursorPosition);
// Save the cleared state
const clearedState = {
value: newValue,
cursorPosition: cursorPosition,
};
if (lastSavedValue.current !== clearedState.value ||
lastSavedCursor.current !== clearedState.cursorPosition) {
history.current = history.current.slice(0, historyIndex.current + 1);
history.current.push(clearedState);
historyIndex.current++;
lastSavedValue.current = clearedState.value;
lastSavedCursor.current = clearedState.cursorPosition;
}
return;
}
if (key.ctrl && input === "a") {
saveCurrentWord(); // Save current word before cursor movement
const newPos = 0;
setCursorPosition(newPos);
wordStartCursor.current = newPos; // Update word start for next typing
lastWasSpace.current = false; // Reset space tracking
return;
}
if (key.ctrl && input === "e") {
saveCurrentWord(); // Save current word before cursor movement
const newPos = value.length;
setCursorPosition(newPos);
wordStartCursor.current = newPos; // Update word start for next typing
lastWasSpace.current = false; // Reset space tracking
return;
}
// Cursor movement with arrows (if not disabled)
if (!disableArrowKeys) {
if (key.leftArrow) {
saveCurrentWord(); // Save word before cursor movement
const newPos = Math.max(0, cursorPosition - 1);
setCursorPosition(newPos);
wordStartCursor.current = newPos; // Update word start for next typing
lastWasSpace.current = false; // Reset space tracking
return;
}
if (key.rightArrow) {
saveCurrentWord(); // Save word before cursor movement
const newPos = Math.min(value.length, cursorPosition + 1);
setCursorPosition(newPos);
wordStartCursor.current = newPos; // Update word start for next typing
lastWasSpace.current = false; // Reset space tracking
return;
}
}
// Arrow navigation callbacks
if (key.upArrow && onUpArrow) {
saveCurrentWord(); // Save word before navigation
onUpArrow();
return;
}
if (key.downArrow && onDownArrow) {
saveCurrentWord(); // Save word before navigation
onDownArrow();
return;
}
// Escape handling
if (key.escape && onEscape) {
saveCurrentWord(); // Save word before escape
onEscape();
return;
}
// Submit on return
if (key.return && onSubmit) {
saveCurrentWord(); // Save word before submit
onSubmit(value);
return;
}
// Backspace/delete
if (key.backspace || key.delete || input === "\b") {
if (cursorPosition > 0) {
// If we were typing a word, this backspace ends it
saveCurrentWord();
isInternalChange.current = true;
const newValue = value.slice(0, cursorPosition - 1) +
value.slice(cursorPosition);
const newCursor = cursorPosition - 1;
onChange(newValue);
setCursorPosition(newCursor);
// Save the state after backspace with the new cursor position
// Note: pushToHistory uses current value/cursor from state,
// so we need to update lastSaved refs manually here
const currentState = {
value: newValue,
cursorPosition: newCursor,
};
if (lastSavedValue.current !== currentState.value ||
lastSavedCursor.current !== currentState.cursorPosition) {
history.current = history.current.slice(0, historyIndex.current + 1);
history.current.push(currentState);
historyIndex.current++;
lastSavedValue.current = currentState.value;
lastSavedCursor.current = currentState.cursorPosition;
}
}
return;
}
// Text input
if (input &&
input.length === 1 &&
!key.ctrl &&
!key.meta &&
!key.return &&
!key.escape &&
!key.upArrow &&
!key.downArrow &&
!key.leftArrow &&
!key.rightArrow) {
// Check if this is a space - word boundary
if (input === " ") {
// Save current word before adding space (if we were typing)
if (isTypingWord.current) {
// First save state WITHOUT the word but WITH text after (for undo of the word)
const textBeforeWord = value.slice(0, wordStartCursor.current);
const restoredValue = textBeforeWord + textAfterWordStart.current;
if (lastSavedValue.current !== restoredValue ||
lastSavedCursor.current !== wordStartCursor.current) {
history.current = history.current.slice(0, historyIndex.current + 1);
history.current.push({
value: restoredValue,
cursorPosition: wordStartCursor.current,
});
historyIndex.current++;
lastSavedValue.current = restoredValue;
lastSavedCursor.current = wordStartCursor.current;
}
// Then save state WITH the word but WITHOUT the space
if (lastSavedValue.current !== value ||
lastSavedCursor.current !== cursorPosition) {
history.current = history.current.slice(0, historyIndex.current + 1);
history.current.push({
value: value,
cursorPosition: cursorPosition,
});
historyIndex.current++;
lastSavedValue.current = value;
lastSavedCursor.current = cursorPosition;
}
isTypingWord.current = false;
// Don't reset textAfterWordStart here - keep it for the next word if typing in middle
}
// If last character was also a space, save current state before adding another
// This makes each extra space its own undo point
if (lastWasSpace.current) {
if (lastSavedValue.current !== value ||
lastSavedCursor.current !== cursorPosition) {
history.current = history.current.slice(0, historyIndex.current + 1);
history.current.push({
value: value,
cursorPosition: cursorPosition,
});
historyIndex.current++;
lastSavedValue.current = value;
lastSavedCursor.current = cursorPosition;
}
}
// Add the space
isInternalChange.current = true;
const newValue = value.slice(0, cursorPosition) +
input +
value.slice(cursorPosition);
const newCursor = cursorPosition + 1;
onChange(newValue);
setCursorPosition(newCursor);
// Track that we just typed a space
lastWasSpace.current = true;
wordStartCursor.current = newCursor; // Update for next word
// Update textAfterWordStart if we're in the middle of text
// Keep what comes after the new cursor position
if (newCursor < newValue.length) {
textAfterWordStart.current = newValue.slice(newCursor);
}
else {
textAfterWordStart.current = ""; // At end of text
}
}
else {
// Regular character - track start of word if just starting
if (!isTypingWord.current) {
// Only include space before cursor if we JUST typed it (lastWasSpace)
// Otherwise, the space was already there and shouldn't be part of the undo
if (lastWasSpace.current &&
cursorPosition > 0 &&
value[cursorPosition - 1] === " ") {
// We just typed a space - set wordStartCursor to include it
wordStartCursor.current = cursorPosition - 1;
// Update textAfterWordStart to what's after cursor NOW
textAfterWordStart.current =
value.slice(cursorPosition);
}
else {
// No space we just typed - save current state and start tracking word
if (lastSavedValue.current !== value ||
lastSavedCursor.current !== cursorPosition) {
history.current = history.current.slice(0, historyIndex.current + 1);
history.current.push({
value: value,
cursorPosition: cursorPosition,
});
historyIndex.current++;
lastSavedValue.current = value;
lastSavedCursor.current = cursorPosition;
}
wordStartCursor.current = cursorPosition;
// Save text that comes after cursor (for undoing insertions in middle)
textAfterWordStart.current =
value.slice(cursorPosition);
}
isTypingWord.current = true;
}
// Not a space anymore
lastWasSpace.current = false;
isInternalChange.current = true;
onChange(value.slice(0, cursorPosition) +
input +
value.slice(cursorPosition));
setCursorPosition(cursorPosition + 1);
}
return;
}
}, { isActive });
// Render text with cursor
return (_jsxs(Text, { color: color, children: [value.slice(0, cursorPosition), _jsx(Text, { backgroundColor: "grey", color: "black", children: cursorPosition < value.length ? value[cursorPosition] : " " }), value.slice(cursorPosition + (cursorPosition < value.length ? 1 : 0)), value.length === 0 && cursorPosition === 0 && "\u200B"] }));
};
//# sourceMappingURL=TextInput.js.map