UNPKG

@nanocollective/nanocoder

Version:

A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter

313 lines 14.4 kB
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PASTE_CHUNK_BASE_WINDOW_MS, PASTE_CHUNK_MAX_WINDOW_MS, PASTE_LARGE_CONTENT_THRESHOLD_CHARS, PASTE_RAPID_DETECTION_MS, } from '../constants.js'; import { PlaceholderType } from '../types/hooks.js'; import { handleAtomicDeletion } from '../utils/atomic-deletion.js'; import { PasteDetector } from '../utils/paste-detection.js'; import { handlePaste } from '../utils/paste-utils.js'; // Scales the paste window size based on content length. // Prevents truncation on slow terminals while keeping small pastes snappy function getDynamicPasteWindow(contentLength) { // Add ~1ms buffer per 10 chars, capped at max window const dynamicExtension = Math.floor(contentLength / 10); return Math.min(PASTE_CHUNK_BASE_WINDOW_MS + dynamicExtension, PASTE_CHUNK_MAX_WINDOW_MS); } // Helper functions function createEmptyInputState() { return { displayValue: '', placeholderContent: {}, }; } export function useInputState() { // Core state following the spec const [currentState, setCurrentState] = useState(createEmptyInputState()); const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); // Legacy compatibility - these are derived from currentState const [historyIndex, setHistoryIndex] = useState(-1); const [_hasLargeContent, setHasLargeContent] = useState(false); const [originalInput, setOriginalInput] = useState(''); // Paste detection const pasteDetectorRef = useRef(new PasteDetector()); const debounceTimerRef = useRef(null); // Track recent paste for chunked paste handling (VS Code terminal issue) const lastPasteTimeRef = useRef(0); const lastPasteIdRef = useRef(null); // Cached line count for performance const [cachedLineCount, setCachedLineCount] = useState(1); // Helper to push current state to undo stack const pushToUndoStack = useCallback((newState) => { setUndoStack(prev => [...prev, currentState]); setRedoStack([]); // Clear redo stack on new action setCurrentState(newState); }, [currentState]); // Update input with paste detection and atomic deletion const updateInput = useCallback((newInput) => { // First, check for atomic deletion (placeholder removal) const atomicDeletionResult = handleAtomicDeletion(currentState, newInput); if (atomicDeletionResult) { // Atomic deletion occurred - apply it pushToUndoStack(atomicDeletionResult); return; } const now = Date.now(); const timeSinceLastPaste = now - lastPasteTimeRef.current; // Check if this might be a continuation of a recent paste (chunked paste in VS Code) const existingPlaceholder = lastPasteIdRef.current ? currentState.placeholderContent[lastPasteIdRef.current] : null; const dynamicWindow = existingPlaceholder ? getDynamicPasteWindow(existingPlaceholder.content.length) : PASTE_CHUNK_BASE_WINDOW_MS; if (lastPasteIdRef.current && timeSinceLastPaste < dynamicWindow && existingPlaceholder) { // This looks like a chunked paste continuation // Extract the new text that was added (should be at the end) const placeholder = currentState.placeholderContent[lastPasteIdRef.current]; const expectedLength = currentState.displayValue.length; const addedChunk = newInput.slice(expectedLength); if (addedChunk.length > 0 && placeholder.type === PlaceholderType.PASTE) { // Merge the new chunk into the existing paste placeholder const updatedContent = placeholder.content + addedChunk; const oldPlaceholder = placeholder.displayText; const newPlaceholder = `[Paste #${lastPasteIdRef.current}: ${updatedContent.length} chars]`; const updatedPlaceholderContent = { ...currentState.placeholderContent, [lastPasteIdRef.current]: { ...placeholder, content: updatedContent, originalSize: updatedContent.length, displayText: newPlaceholder, }, }; // Replace old placeholder with updated one in display value const newDisplayValue = currentState.displayValue.replace(oldPlaceholder, newPlaceholder); pushToUndoStack({ displayValue: newDisplayValue, placeholderContent: updatedPlaceholderContent, }); // Update paste detector to the new display value pasteDetectorRef.current.updateState(newDisplayValue); lastPasteTimeRef.current = now; // Extend the window return; } } // Then detect if this might be a paste const detection = pasteDetectorRef.current.detectPaste(newInput); if (detection.isPaste && detection.addedText.length > 0) { // If we have an active paste within a short window (even if state hasn't fully updated), // treat this as a continuation to prevent duplicate placeholders const isVeryRecentPaste = timeSinceLastPaste < PASTE_RAPID_DETECTION_MS; const activePasteId = lastPasteIdRef.current; const activePlaceholder = activePasteId ? currentState.placeholderContent[activePasteId] : null; const activeWindow = activePlaceholder ? getDynamicPasteWindow(activePlaceholder.content.length) : PASTE_CHUNK_BASE_WINDOW_MS; if (activePasteId && (isVeryRecentPaste || (timeSinceLastPaste < activeWindow && activePlaceholder))) { // If we don't have the placeholder in state yet, just update detector and skip // This happens when multiple detections fire before React updates state const placeholder = currentState.placeholderContent[activePasteId]; if (!placeholder) { // Skip duplicate early detection pasteDetectorRef.current.updateState(newInput); return; } // Treat as chunked continuation if (placeholder.type === PlaceholderType.PASTE) { const updatedContent = placeholder.content + detection.addedText; const oldPlaceholder = placeholder.displayText; const newPlaceholder = `[Paste #${activePasteId}: ${updatedContent.length} chars]`; const updatedPlaceholderContent = { ...currentState.placeholderContent, [activePasteId]: { ...placeholder, content: updatedContent, originalSize: updatedContent.length, displayText: newPlaceholder, }, }; const newDisplayValue = currentState.displayValue.replace(oldPlaceholder, newPlaceholder); pushToUndoStack({ displayValue: newDisplayValue, placeholderContent: updatedPlaceholderContent, }); pasteDetectorRef.current.updateState(newDisplayValue); lastPasteTimeRef.current = now; return; } } // Try to handle as paste (new paste) const pasteResult = handlePaste(detection.addedText, currentState.displayValue, currentState.placeholderContent, detection.method); if (pasteResult) { // Large paste detected - create placeholder pushToUndoStack(pasteResult); // Update paste detector state to match the new display value (with placeholder) // This prevents detection confusion on subsequent pastes pasteDetectorRef.current.updateState(pasteResult.displayValue); // Track this paste for potential chunked continuation const pasteId = Object.keys(pasteResult.placeholderContent).find(id => !currentState.placeholderContent[id] && pasteResult.placeholderContent[id].type === PlaceholderType.PASTE); if (pasteId) { lastPasteIdRef.current = pasteId; lastPasteTimeRef.current = now; } } else { // Small paste - treat as normal input pushToUndoStack({ displayValue: newInput, placeholderContent: currentState.placeholderContent, }); } } else { // Normal typing pushToUndoStack({ displayValue: newInput, placeholderContent: currentState.placeholderContent, }); } // Update derived state const immediateLineCount = Math.max(1, newInput.split(/\r\n|\r|\n/).length); setCachedLineCount(immediateLineCount); // Clear any previous debounce timer if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } debounceTimerRef.current = setTimeout(() => { setHasLargeContent(newInput.length > PASTE_LARGE_CONTENT_THRESHOLD_CHARS); }, 50); }, [currentState, pushToUndoStack]); // Undo function (Ctrl+_) const undo = useCallback(() => { if (undoStack.length > 0) { const previousState = undoStack[undoStack.length - 1]; const newUndoStack = undoStack.slice(0, -1); setRedoStack(prev => [...prev, currentState]); setUndoStack(newUndoStack); setCurrentState(previousState); // Update paste detector state pasteDetectorRef.current.updateState(previousState.displayValue); } }, [undoStack, currentState]); // Redo function (Ctrl+Y) const redo = useCallback(() => { if (redoStack.length > 0) { const nextState = redoStack[redoStack.length - 1]; const newRedoStack = redoStack.slice(0, -1); setUndoStack(prev => [...prev, currentState]); setRedoStack(newRedoStack); setCurrentState(nextState); // Update paste detector state pasteDetectorRef.current.updateState(nextState.displayValue); } }, [redoStack, currentState]); // Delete placeholder atomically const deletePlaceholder = useCallback((placeholderId) => { // Sanitize placeholderId to ensure it only contains safe characters const sanitizedPlaceholderId = placeholderId.replace(/[^a-zA-Z0-9_-]/g, ''); const placeholderPattern = `[Paste #${sanitizedPlaceholderId}: \\d+ chars]`; /* nosemgrep */ const regex = new RegExp(placeholderPattern.replace(/[[\]]/g, '\\$&'), 'g'); const newDisplayValue = currentState.displayValue.replace(regex, ''); const newPlaceholderContent = { ...currentState.placeholderContent }; delete newPlaceholderContent[placeholderId]; pushToUndoStack({ displayValue: newDisplayValue, placeholderContent: newPlaceholderContent, }); }, [currentState, pushToUndoStack]); // Reset all state const resetInput = useCallback(() => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } setCurrentState(createEmptyInputState()); setUndoStack([]); setRedoStack([]); setHasLargeContent(false); setOriginalInput(''); setHistoryIndex(-1); setCachedLineCount(1); pasteDetectorRef.current.reset(); lastPasteTimeRef.current = 0; lastPasteIdRef.current = null; }, []); // Cleanup on unmount useEffect(() => { return () => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } }; }, []); // Set full InputState (for history navigation) const setInputState = useCallback((newState) => { setCurrentState(newState); pasteDetectorRef.current.updateState(newState.displayValue); }, []); // Legacy setters for compatibility const setInput = useCallback((newInput) => { setCurrentState(prev => ({ ...prev, displayValue: newInput, })); pasteDetectorRef.current.updateState(newInput); }, []); // Compute legacy pastedContent for backward compatibility const legacyPastedContent = useMemo(() => { const pastedContent = {}; Object.entries(currentState.placeholderContent).forEach(([id, content]) => { if (content.type === PlaceholderType.PASTE) { pastedContent[id] = content.content; } }); return pastedContent; }, [currentState.placeholderContent]); return useMemo(() => ({ // New spec-compliant interface currentState, undoStack, redoStack, undo, redo, deletePlaceholder, setInputState, // Legacy interface for compatibility input: currentState.displayValue, originalInput, historyIndex, setInput, setOriginalInput, setHistoryIndex, updateInput, resetInput, cachedLineCount, // Computed legacy property for backward compatibility pastedContent: legacyPastedContent, }), [ currentState, undoStack, redoStack, undo, redo, deletePlaceholder, setInputState, originalInput, historyIndex, setInput, updateInput, resetInput, cachedLineCount, legacyPastedContent, ]); } //# sourceMappingURL=useInputState.js.map