@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
373 lines • 18.5 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { Box, Text, useFocus, useInput } from 'ink';
import Spinner from 'ink-spinner';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { commandRegistry } from '../commands.js';
import { DevelopmentModeIndicator } from '../components/development-mode-indicator.js';
import TextInput from '../components/text-input.js';
import { useInputState } from '../hooks/useInputState.js';
import { useResponsiveTerminal } from '../hooks/useTerminalWidth.js';
import { useTheme } from '../hooks/useTheme.js';
import { useUIStateContext } from '../hooks/useUIState.js';
import { promptHistory } from '../prompt-history.js';
import { getCurrentFileMention, getFileCompletions, } from '../utils/file-autocomplete.js';
import { handleFileMention } from '../utils/file-mention-handler.js';
import { assemblePrompt } from '../utils/prompt-processor.js';
export default function UserInput({ onSubmit, placeholder, customCommands = [], disabled = false, onCancel, onToggleMode, onToggleCompactDisplay, compactToolDisplay = true, developmentMode = 'normal', contextPercentUsed, }) {
const { isFocused, focus } = useFocus({ autoFocus: !disabled, id: 'user-input' });
const { colors } = useTheme();
const inputState = useInputState();
const uiState = useUIStateContext();
const { boxWidth, isNarrow } = useResponsiveTerminal();
const [textInputKey, setTextInputKey] = useState(0);
// Store the full InputState draft when starting history navigation, so it can be restored
const savedDraftRef = useRef({
displayValue: '',
placeholderContent: {},
});
// File autocomplete state
const [isFileAutocompleteMode, setIsFileAutocompleteMode] = useState(false);
const [fileCompletions, setFileCompletions] = useState([]);
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
const { input, historyIndex, setOriginalInput, setHistoryIndex, updateInput, resetInput, deletePlaceholder: _deletePlaceholder, currentState, setInputState, } = inputState;
const { showClearMessage, showCompletions, completions, pendingFileMentions, setShowClearMessage, setShowCompletions, setCompletions, setPendingFileMentions, resetUIState, } = uiState;
// Check if we're in bash mode (input starts with !)
const isBashMode = input.trim().startsWith('!');
// Check if we're in command mode (input starts with /)
const isCommandMode = input.trim().startsWith('/');
// Load history on mount
useEffect(() => {
void promptHistory.loadHistory();
}, []);
// Consume pending file mentions from explorer and insert into input
// Properly attach files by calling handleFileMention for each
useEffect(() => {
if (pendingFileMentions.length === 0)
return;
const attachFiles = async () => {
let state = currentState;
let displayValue = state.displayValue;
for (const filePath of pendingFileMentions) {
// Create a temporary mention text to replace
const mentionText = `@${filePath}`;
// Add the mention to display value first
displayValue = displayValue
? `${displayValue} ${mentionText}`
: mentionText;
// Handle the file mention to create placeholder
const result = await handleFileMention(filePath, displayValue, state.placeholderContent, mentionText);
if (result) {
state = result;
displayValue = result.displayValue;
}
}
setInputState(state);
setTextInputKey(prev => prev + 1);
setPendingFileMentions([]);
};
void attachFiles();
}, [
pendingFileMentions,
currentState,
setInputState,
setPendingFileMentions,
]);
// Trigger file autocomplete when input changes
useEffect(() => {
const runFileAutocomplete = async () => {
const mention = getCurrentFileMention(input, input.length);
if (mention) {
setIsFileAutocompleteMode(true);
const cwd = process.cwd();
const completions = await getFileCompletions(mention.mention, cwd);
setFileCompletions(completions);
setSelectedFileIndex(0); // Reset selection when completions change
}
else {
setIsFileAutocompleteMode(false);
setFileCompletions([]);
setSelectedFileIndex(0);
}
};
void runFileAutocomplete();
}, [input]);
// Calculate command completions using useMemo to prevent flashing
const commandCompletions = useMemo(() => {
if (!isCommandMode || isFileAutocompleteMode) {
return [];
}
const commandPrefix = input.slice(1).split(' ')[0];
const builtInCompletions = commandRegistry.getCompletions(commandPrefix);
const customCompletions = customCommands
.filter(cmd => {
// Include all when no prefix, otherwise filter by prefix
return (!commandPrefix ||
cmd.toLowerCase().includes(commandPrefix.toLowerCase()));
})
.sort((a, b) => a.localeCompare(b));
return [
...builtInCompletions.map(cmd => ({ name: cmd, isCustom: false })),
...customCompletions.map(cmd => ({ name: cmd, isCustom: true })),
];
}, [input, isCommandMode, isFileAutocompleteMode, customCommands]);
// Update UI state for command completions
useEffect(() => {
if (commandCompletions.length > 0) {
setCompletions(commandCompletions);
setShowCompletions(true);
}
else if (showCompletions) {
setCompletions([]);
setShowCompletions(false);
}
}, [commandCompletions, showCompletions, setCompletions, setShowCompletions]);
// Helper functions
// Handle file mention selection (Tab key in file autocomplete mode)
const handleFileSelection = useCallback(async () => {
if (!isFileAutocompleteMode || fileCompletions.length === 0) {
return false;
}
const mention = getCurrentFileMention(input, input.length);
if (!mention) {
return false;
}
// Select the currently highlighted file
const selectedPath = fileCompletions[selectedFileIndex]?.path;
if (!selectedPath) {
return false;
}
// Extract the original mention text (the @... part we're replacing)
const mentionText = input.substring(mention.startIndex, mention.endIndex);
// Handle the file mention to create placeholder
const result = await handleFileMention(selectedPath, currentState.displayValue, currentState.placeholderContent, mentionText);
if (result) {
setInputState(result);
setIsFileAutocompleteMode(false);
setFileCompletions([]);
setSelectedFileIndex(0);
setTextInputKey(prev => prev + 1);
return true;
}
return false;
}, [
isFileAutocompleteMode,
fileCompletions,
selectedFileIndex,
input,
currentState,
setInputState,
]);
// Handle form submission
const handleSubmit = useCallback(() => {
if (input.trim() && onSubmit) {
// Assemble the full prompt by replacing placeholders with content
const fullMessage = assemblePrompt(currentState);
// Save the InputState to history and send assembled message to AI
promptHistory.addPrompt(currentState);
onSubmit(fullMessage);
resetInput();
resetUIState();
promptHistory.resetIndex();
}
}, [input, onSubmit, resetInput, resetUIState, currentState]);
// Handle escape key logic
const handleEscape = useCallback(() => {
if (showClearMessage) {
resetInput();
resetUIState();
focus('user-input');
}
else {
setShowClearMessage(true);
}
}, [showClearMessage, resetInput, resetUIState, setShowClearMessage, focus]);
// History navigation
const handleHistoryNavigation = useCallback((direction) => {
const history = promptHistory.getHistory();
if (history.length === 0)
return;
if (direction === 'up') {
if (historyIndex === -1) {
// Save the full current state before starting navigation
savedDraftRef.current = currentState;
setOriginalInput(input);
setHistoryIndex(history.length - 1);
setInputState(history[history.length - 1]);
setTextInputKey(prev => prev + 1);
}
else if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
setInputState(history[newIndex]);
setTextInputKey(prev => prev + 1);
}
else if (historyIndex === 0) {
// At first history item, restore saved draft
setHistoryIndex(-2);
setInputState(savedDraftRef.current);
setTextInputKey(prev => prev + 1);
}
else if (historyIndex === -2) {
// At draft, cycle back to last history item
savedDraftRef.current = currentState;
setHistoryIndex(history.length - 1);
setInputState(history[history.length - 1]);
setTextInputKey(prev => prev + 1);
}
}
else {
if (historyIndex === -1) {
// Save draft, go to draft cycling state (visually a no-op)
savedDraftRef.current = currentState;
setOriginalInput(input);
setHistoryIndex(-2);
setInputState(savedDraftRef.current);
setTextInputKey(prev => prev + 1);
}
else if (historyIndex === -2) {
// At draft, cycle to first history item
savedDraftRef.current = currentState;
setHistoryIndex(0);
setInputState(history[0]);
setTextInputKey(prev => prev + 1);
}
else if (historyIndex >= 0 && historyIndex < history.length - 1) {
// Move forward in history
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
setInputState(history[newIndex]);
setTextInputKey(prev => prev + 1);
}
else if (historyIndex === history.length - 1) {
// At last history item, restore saved draft
setHistoryIndex(-2);
setInputState(savedDraftRef.current);
setTextInputKey(prev => prev + 1);
}
}
}, [
historyIndex,
input,
currentState,
setHistoryIndex,
setOriginalInput,
setInputState,
]);
useInput((inputChar, key) => {
// Handle escape for cancellation even when disabled
if (key.escape && disabled && onCancel) {
onCancel();
return;
}
// Handle shift+tab to toggle development mode (always available)
if (key.tab && key.shift && onToggleMode) {
onToggleMode();
return;
}
// Handle ctrl+o to toggle compact tool display (always available)
if (key.ctrl && inputChar === 'o' && onToggleCompactDisplay) {
onToggleCompactDisplay();
return;
}
// Block all other input when disabled
if (disabled) {
return;
}
// Handle special keys
if (key.escape) {
handleEscape();
return;
}
// Handle Tab key
if (key.tab) {
// File autocomplete takes priority
if (isFileAutocompleteMode) {
void handleFileSelection();
return;
}
// Command completion - use pre-calculated commandCompletions
if (input.startsWith('/')) {
if (commandCompletions.length === 1) {
// Auto-complete when there's exactly one match
const completion = commandCompletions[0];
const completedText = `/${completion.name}`;
// Use setInputState to bypass paste detection for autocomplete
setInputState({
displayValue: completedText,
placeholderContent: currentState.placeholderContent,
});
setTextInputKey(prev => prev + 1);
}
else if (commandCompletions.length > 1) {
// If completions are already showing, autocomplete to the first result
if (showCompletions && completions.length > 0) {
const completion = completions[0];
const completedText = `/${completion.name}`;
// Use setInputState to bypass paste detection for autocomplete
setInputState({
displayValue: completedText,
placeholderContent: currentState.placeholderContent,
});
setShowCompletions(false);
setTextInputKey(prev => prev + 1);
}
else {
// Show completions when there are multiple matches
setCompletions(commandCompletions);
setShowCompletions(true);
}
}
return;
}
}
// Space exits file autocomplete mode
if (inputChar === ' ' && isFileAutocompleteMode) {
setIsFileAutocompleteMode(false);
setFileCompletions([]);
}
// Clear clear message on other input
if (showClearMessage) {
setShowClearMessage(false);
focus('user-input');
}
// Handle return keys for multiline input
// Support Shift+Enter if the terminal sends it properly
if (key.return && key.shift) {
updateInput(input + '\n');
return;
}
// VSCode terminal sends Option+Enter as '\r' with key.return === false
// Regular Enter in VSCode sends '\r' with key.return === true
// So we use key.return to distinguish: false = multiline, true = submit
if (inputChar === '\r' && !key.return) {
updateInput(input + '\n');
return;
}
// Handle navigation
if (key.upArrow) {
// File autocomplete navigation takes priority
if (isFileAutocompleteMode && fileCompletions.length > 0) {
setSelectedFileIndex(prev => prev > 0 ? prev - 1 : fileCompletions.length - 1);
return;
}
handleHistoryNavigation('up');
return;
}
if (key.downArrow) {
// File autocomplete navigation takes priority
if (isFileAutocompleteMode && fileCompletions.length > 0) {
setSelectedFileIndex(prev => prev < fileCompletions.length - 1 ? prev + 1 : 0);
return;
}
handleHistoryNavigation('down');
return;
}
});
const textColor = disabled || !input ? colors.secondary : colors.primary;
// When disabled, show minimal UI to avoid cluttering the screen
if (disabled) {
return (_jsxs(Box, { flexDirection: "column", paddingY: 1, width: "100%", marginTop: 1, children: [_jsxs(Text, { color: colors.secondary, dimColor: true, children: [_jsx(Spinner, { type: "dots" }), " Press Esc to cancel", onToggleCompactDisplay && (_jsxs(Text, { children: [' ', "\u00B7 ctrl-o ", compactToolDisplay ? 'expand' : 'compact', ' ', isNarrow ? '' : 'tool results'] }))] }), _jsx(DevelopmentModeIndicator, { developmentMode: developmentMode, colors: colors, contextPercentUsed: contextPercentUsed ?? null })] }));
}
return (_jsxs(_Fragment, { children: [!isBashMode ? (_jsx(Text, { color: colors.primary, bold: true, children: "What would you like me to help with?" })) : (_jsx(Text, { color: colors.tool, bold: true, children: "Bash mode" })), _jsxs(Box, { flexDirection: "column", marginTop: 1, backgroundColor: colors.base, width: boxWidth, padding: 1, borderStyle: "bold", borderLeft: true, borderRight: false, borderTop: false, borderBottom: false, borderLeftColor: isBashMode ? colors.tool : colors.primary, children: [_jsxs(Box, { children: [input.length === 0 && (_jsxs(Text, { color: isBashMode ? colors.tool : textColor, children: ['>', " "] })), _jsx(TextInput, { value: input, onChange: updateInput, onSubmit: handleSubmit, placeholder: "/ commands, ! bash, \u2191/\u2193 history", focus: isFocused, wrapWidth: boxWidth - 3 }, textInputKey)] }), showClearMessage && (_jsx(Text, { color: colors.secondary, dimColor: true, children: "Press escape again to clear" }))] }), showCompletions && completions.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: colors.secondary, children: "Available commands:" }), completions.map((completion, index) => (_jsxs(Text, { color: completion.isCustom ? colors.info : colors.primary, children: ["/", completion.name] }, index)))] })), isFileAutocompleteMode && fileCompletions.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: colors.secondary, children: "File suggestions (\u2191/\u2193 to navigate, Tab to select):" }), fileCompletions.slice(0, 5).map((file, index) => (_jsxs(Text, { color: index === selectedFileIndex ? colors.info : colors.primary, bold: index === selectedFileIndex, children: [index === selectedFileIndex ? '▸ ' : ' ', file.path] }, index)))] })), _jsx(DevelopmentModeIndicator, { developmentMode: developmentMode, colors: colors, contextPercentUsed: contextPercentUsed ?? null })] }));
}
//# sourceMappingURL=user-input.js.map