diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index e14cea62..1c1ec424 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -96,17 +96,8 @@ export const App = ({ config, settings, cliVersion }: AppProps) => { const isInputActive = streamingState === StreamingState.Idle && !initError; - const { - query, - setQuery, - handleSubmit: handleHistorySubmit, - inputKey, - setInputKey, - } = useInputHistory({ - userMessages, - onSubmit: handleFinalSubmit, - isActive: isInputActive, - }); + // query and setQuery are now managed by useState here + const [query, setQuery] = useState(''); const completion = useCompletion( query, @@ -115,6 +106,22 @@ export const App = ({ config, settings, cliVersion }: AppProps) => { slashCommands, ); + const { + handleSubmit: handleHistorySubmit, + inputKey, + setInputKey, + } = useInputHistory({ + userMessages, + onSubmit: (value) => { + // Adapt onSubmit to use the lifted setQuery + handleFinalSubmit(value); + setQuery(''); // Clear query from the App's state + }, + isActive: isInputActive && !completion.showSuggestions, + query, + setQuery, + }); + // --- Render Logic --- const { staticallyRenderedHistoryItems, updatableHistoryItems } = diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index a7f06bce..20d4bcdf 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -39,93 +39,83 @@ export const InputPrompt: React.FC = ({ }) => { const { isFocused } = useFocus({ autoFocus: true }); - const handleAutocomplete = useCallback(() => { - if ( - activeSuggestionIndex < 0 || - activeSuggestionIndex >= suggestions.length - ) { - return; - } - const selectedSuggestion = suggestions[activeSuggestionIndex]; - const trimmedQuery = query.trimStart(); + const handleAutocomplete = useCallback( + (indexToUse: number) => { + if (indexToUse < 0 || indexToUse >= suggestions.length) { + return; + } + const selectedSuggestion = suggestions[indexToUse]; + const trimmedQuery = query.trimStart(); - if (trimmedQuery.startsWith('/')) { - // Handle / command completion - const slashIndex = query.indexOf('/'); - const base = query.substring(0, slashIndex + 1); - const newValue = base + selectedSuggestion.value; - setQuery(newValue); - } else { - // Handle @ command completion - const atIndex = query.lastIndexOf('@'); - if (atIndex === -1) return; - - // Find the part of the query after the '@' - const pathPart = query.substring(atIndex + 1); - // Find the last slash within that part - const lastSlashIndexInPath = pathPart.lastIndexOf('/'); - - let base = ''; - if (lastSlashIndexInPath === -1) { - // No slash after '@', replace everything after '@' - base = query.substring(0, atIndex + 1); + if (trimmedQuery.startsWith('/')) { + // Handle / command completion + const slashIndex = query.indexOf('/'); + const base = query.substring(0, slashIndex + 1); + const newValue = base + selectedSuggestion.value; + setQuery(newValue); } else { - // Slash found, keep everything up to and including the last slash - base = query.substring(0, atIndex + 1 + lastSlashIndexInPath + 1); + // Handle @ command completion + const atIndex = query.lastIndexOf('@'); + if (atIndex === -1) return; + + // Find the part of the query after the '@' + const pathPart = query.substring(atIndex + 1); + // Find the last slash within that part + const lastSlashIndexInPath = pathPart.lastIndexOf('/'); + + let base = ''; + if (lastSlashIndexInPath === -1) { + // No slash after '@', replace everything after '@' + base = query.substring(0, atIndex + 1); + } else { + // Slash found, keep everything up to and including the last slash + base = query.substring(0, atIndex + 1 + lastSlashIndexInPath + 1); + } + + const newValue = base + selectedSuggestion.value; + setQuery(newValue); } - const newValue = base + selectedSuggestion.value; - setQuery(newValue); - } - - resetCompletion(); // Hide suggestions after selection - setInputKey((k) => k + 1); // Increment key to force re-render and cursor reset - }, [ - query, - setQuery, - suggestions, - activeSuggestionIndex, - resetCompletion, - setInputKey, - ]); + resetCompletion(); // Hide suggestions after selection + setInputKey((k) => k + 1); // Increment key to force re-render and cursor reset + }, + [query, setQuery, suggestions, resetCompletion, setInputKey], + ); useInput( (input: string, key: Key) => { - let handled = false; + if (!isFocused) { + return; + } if (showSuggestions) { if (key.upArrow) { navigateUp(); - handled = true; } else if (key.downArrow) { navigateDown(); - handled = true; - } else if ((key.tab || key.return) && activeSuggestionIndex >= 0) { - handleAutocomplete(); - handled = true; + } else if (key.tab) { + if (suggestions.length > 0) { + const targetIndex = + activeSuggestionIndex === -1 ? 0 : activeSuggestionIndex; + if (targetIndex < suggestions.length) { + handleAutocomplete(targetIndex); + } + } + } else if (key.return) { + if (activeSuggestionIndex >= 0) { + handleAutocomplete(activeSuggestionIndex); + } else { + if (query.trim()) { + onSubmit(query); + } + } } else if (key.escape) { resetCompletion(); - handled = true; } } - - // Only submit on Enter if it wasn't handled above - if (!handled && key.return) { - if (query.trim()) { - onSubmit(query); - } - handled = true; - } - - if ( - handled && - showSuggestions && - (key.upArrow || key.downArrow || key.tab || key.escape || key.return) - ) { - // No explicit preventDefault needed, handled flag stops further processing - } + // Enter key when suggestions are NOT showing is handled by TextInput's onSubmit prop below }, - { isActive: isFocused }, + { isActive: true }, ); return ( @@ -138,7 +128,15 @@ export const InputPrompt: React.FC = ({ onChange={setQuery} placeholder="Enter your message or use tools (e.g., @src/file.txt)..." onSubmit={() => { - /* onSubmit is handled by useInput hook above */ + // This onSubmit is for the TextInput component itself. + // It should only fire if suggestions are NOT showing, + // as useInput handles Enter when suggestions are visible. + const trimmedQuery = query.trim(); + if (!showSuggestions && trimmedQuery) { + onSubmit(trimmedQuery); + } + // If suggestions ARE showing, useInput's Enter handler + // would have already dealt with it (either completing or submitting). }} /> diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 2d93d1cb..50e81589 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -106,16 +106,25 @@ export async function handleAtCommand({ // Add the original user query to history first addItem({ type: 'user', text: query }, userMessageTimestamp); + // If the atPath is just "@", pass the original query to the LLM + if (atPath === '@') { + setDebugMessage('Lone @ detected, passing directly to LLM.'); + return { processedQuery: [{ text: query }], shouldProceed: true }; + } + const pathPart = atPath.substring(1); // Remove leading '@' + // This error condition is for cases where pathPart becomes empty *after* the initial "@" check, + // which is unlikely with the current parser but good for robustness. if (!pathPart) { addItem( - { type: 'error', text: 'Error: No path specified after @.' }, + { type: 'error', text: 'Error: No valid path specified after @ symbol.' }, userMessageTimestamp, ); return { processedQuery: null, shouldProceed: false }; } + const contentLabel = pathPart; const toolRegistry = config.getToolRegistry(); const readManyFilesTool = toolRegistry.getTool('read_many_files'); @@ -129,7 +138,6 @@ export async function handleAtCommand({ // Determine path spec (file or directory glob) let pathSpec = pathPart; - const contentLabel = pathPart; try { const absolutePath = path.resolve(config.getTargetDir(), pathPart); const stats = await fs.stat(absolutePath); diff --git a/packages/cli/src/ui/hooks/useInputHistory.ts b/packages/cli/src/ui/hooks/useInputHistory.ts index 21d7b9bf..f8c873f1 100644 --- a/packages/cli/src/ui/hooks/useInputHistory.ts +++ b/packages/cli/src/ui/hooks/useInputHistory.ts @@ -4,13 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { useInput } from 'ink'; interface UseInputHistoryProps { userMessages: readonly string[]; onSubmit: (value: string) => void; isActive: boolean; + query: string; + setQuery: React.Dispatch>; } interface UseInputHistoryReturn { @@ -25,8 +27,9 @@ export function useInputHistory({ userMessages, onSubmit, isActive, + query, + setQuery, }: UseInputHistoryProps): UseInputHistoryReturn { - const [query, setQuery] = useState(''); const [historyIndex, setHistoryIndex] = useState(-1); const [originalQueryBeforeNav, setOriginalQueryBeforeNav] = useState(''); @@ -41,9 +44,8 @@ export function useInputHistory({ (value: string) => { const trimmedValue = value.trim(); if (trimmedValue) { - onSubmit(trimmedValue); + onSubmit(trimmedValue); // This will call handleFinalSubmit, which then calls setQuery('') from App.tsx } - setQuery(''); resetHistoryNav(); }, [onSubmit, resetHistoryNav], diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index bcae7b6c..b17b264d 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -13,8 +13,8 @@ * @returns True if the query looks like an '@' command, false otherwise. */ export const isAtCommand = (query: string): boolean => - // Check if starts with @ OR has a space, then @, then a non-space character. - query.startsWith('@') || /\s@\S/.test(query); + // Check if starts with @ OR has a space, then @ + query.startsWith('@') || /\s@/.test(query); /** * Checks if a query string potentially represents an '/' command.