diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 67727fd2..fbf84766 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -8,6 +8,7 @@ import React, { useCallback } from 'react'; import { Text, Box, useInput, useFocus, Key } from 'ink'; import TextInput from 'ink-text-input'; import { Colors } from '../colors.js'; +import { Suggestion } from './SuggestionsDisplay.js'; interface InputPromptProps { query: string; @@ -16,7 +17,7 @@ interface InputPromptProps { setInputKey: React.Dispatch>; onSubmit: (value: string) => void; showSuggestions: boolean; - suggestions: string[]; + suggestions: Suggestion[]; // Changed to Suggestion[] activeSuggestionIndex: number; navigateUp: () => void; navigateDown: () => void; @@ -63,7 +64,7 @@ export const InputPrompt: React.FC = ({ base = query.substring(0, atIndex + 1 + lastSlashIndexInPath + 1); } - const newValue = base + selectedSuggestion; + 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 diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index a9d24003..8c9cf377 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -6,9 +6,12 @@ import React from 'react'; import { Box, Text } from 'ink'; - +export interface Suggestion { + label: string; + value: string; +} interface SuggestionsDisplayProps { - suggestions: string[]; + suggestions: Suggestion[]; activeIndex: number; isLoading: boolean; width: number; @@ -62,7 +65,7 @@ export function SuggestionsDisplay({ color={isActive ? 'black' : 'white'} backgroundColor={isActive ? 'blue' : undefined} > - {suggestion} + {suggestion.label} ); })} diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index da3d71dd..09adc7c0 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -7,7 +7,12 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { PartListUnion } from '@google/genai'; -import { Config, getErrorMessage, isNodeError } from '@gemini-code/server'; +import { + Config, + getErrorMessage, + isNodeError, + unescapePath, +} from '@gemini-code/server'; import { HistoryItem, IndividualToolCallDisplay, @@ -39,6 +44,54 @@ interface HandleAtCommandResult { shouldProceed: boolean; } +/** + * Parses a query string to find the first '@' command, + * handling \ escaped spaces within the path. + */ +function parseAtCommand( + query: string, +): { textBefore: string; atPath: string; textAfter: string } | null { + let atIndex = -1; + for (let i = 0; i < query.length; i++) { + // Find the first '@' that is not preceded by a '\' + if (query[i] === '@' && (i === 0 || query[i - 1] !== '\\')) { + atIndex = i; + break; + } + } + + if (atIndex === -1) { + return null; // No '@' command found + } + + const textBefore = query.substring(0, atIndex).trim(); + let pathEndIndex = atIndex + 1; + let inEscape = false; + + while (pathEndIndex < query.length) { + const char = query[pathEndIndex]; + + if (inEscape) { + // Current char is escaped, move past it + inEscape = false; + } else if (char === '\\') { + // Start of an escape sequence + inEscape = true; + } else if (/\s/.test(char)) { + // Unescaped whitespace marks the end of the path + break; + } + pathEndIndex++; + } + + const rawAtPath = query.substring(atIndex, pathEndIndex); + const textAfter = query.substring(pathEndIndex).trim(); + + const atPath = unescapePath(rawAtPath); + + return { textBefore, atPath, textAfter }; +} + /** * Processes user input potentially containing an '@' command. * It finds the first '@', checks if the path is a file or directory, @@ -58,26 +111,51 @@ export async function handleAtCommand({ userMessageTimestamp, }: HandleAtCommandParams): Promise { const trimmedQuery = query.trim(); + const parsedCommand = parseAtCommand(trimmedQuery); - const atCommandRegex = /^(.*?)(@\S+)(.*)$/s; - const match = trimmedQuery.match(atCommandRegex); - - if (!match) { + if (!parsedCommand) { + // If no '@' was found, treat the whole query as user text and proceed + // This allows users to just type text without an @ command + addHistoryItem( + setHistory, + { type: 'user', text: query }, + userMessageTimestamp, + ); + // Let the main hook decide what to do (likely send to LLM) + return { processedQuery: [{ text: query }], shouldProceed: true }; + // Or, if an @ command is *required* when the function is called: + /* const errorTimestamp = getNextMessageId(userMessageTimestamp); addHistoryItem( setHistory, - { type: 'error', text: 'Error: Could not parse @ command.' }, + { type: 'error', text: 'Error: Could not find @ command.' }, + errorTimestamp, + ); + return { processedQuery: null, shouldProceed: false }; + */ + } + + const { textBefore, atPath, textAfter } = parsedCommand; + + // Add the original user query to history *before* processing + addHistoryItem( + setHistory, + { type: 'user', text: query }, + userMessageTimestamp, + ); + + const pathPart = atPath.substring(1); // Remove the leading '@' + + if (!pathPart) { + const errorTimestamp = getNextMessageId(userMessageTimestamp); + addHistoryItem( + setHistory, + { type: 'error', text: 'Error: No path specified after @.' }, errorTimestamp, ); return { processedQuery: null, shouldProceed: false }; } - const textBefore = match[1].trim(); - const atPath = match[2]; - const textAfter = match[3].trim(); - - const pathPart = atPath.substring(1); - addHistoryItem( setHistory, { type: 'user', text: query }, diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 36aed0d1..07a71630 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -7,11 +7,13 @@ import { useState, useEffect, useCallback } from 'react'; import * as fs from 'fs/promises'; import * as path from 'path'; -import { isNodeError } from '@gemini-code/server'; -import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js'; - +import { isNodeError, escapePath, unescapePath } from '@gemini-code/server'; +import { + MAX_SUGGESTIONS_TO_SHOW, + Suggestion, +} from '../components/SuggestionsDisplay.js'; export interface UseCompletionReturn { - suggestions: string[]; + suggestions: Suggestion[]; activeSuggestionIndex: number; visibleStartIndex: number; showSuggestions: boolean; @@ -28,7 +30,7 @@ export function useCompletion( cwd: string, isActive: boolean, ): UseCompletionReturn { - const [suggestions, setSuggestions] = useState([]); + const [suggestions, setSuggestions] = useState([]); const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1); const [visibleStartIndex, setVisibleStartIndex] = useState(0); @@ -121,10 +123,12 @@ export function useCompletion( lastSlashIndex === -1 ? '.' : partialPath.substring(0, lastSlashIndex + 1); - const prefix = + const prefix = unescapePath( lastSlashIndex === -1 ? partialPath - : partialPath.substring(lastSlashIndex + 1); + : partialPath.substring(lastSlashIndex + 1), + ); + const baseDirAbsolute = path.resolve(cwd, baseDirRelative); let isMounted = true; @@ -144,7 +148,11 @@ export function useCompletion( if (aIsDir && !bIsDir) return -1; if (!aIsDir && bIsDir) return 1; return a.localeCompare(b); - }); + }) + .map((entry) => ({ + label: entry, + value: escapePath(entry), + })); if (isMounted) { setSuggestions(filteredSuggestions); diff --git a/packages/server/src/utils/paths.ts b/packages/server/src/utils/paths.ts index f1a42131..6da3d4ab 100644 --- a/packages/server/src/utils/paths.ts +++ b/packages/server/src/utils/paths.ts @@ -100,3 +100,26 @@ export function makeRelative( // If the paths are the same, path.relative returns '', return '.' instead return relativePath || '.'; } + +/** + * Escapes spaces in a file path. + */ +export function escapePath(filePath: string): string { + let result = ''; + for (let i = 0; i < filePath.length; i++) { + // Only escape spaces that are not already escaped. + if (filePath[i] === ' ' && (i === 0 || filePath[i - 1] !== '\\')) { + result += '\\ '; + } else { + result += filePath[i]; + } + } + return result; +} + +/** + * Unescapes spaces in a file path. + */ +export function unescapePath(filePath: string): string { + return filePath.replace(/\\ /g, ' '); +}