Support escaping spaces in file paths. (#241)

This commit is contained in:
Jacob Richman 2025-05-01 18:02:04 -07:00 committed by GitHub
parent ca53565240
commit 53ac7952c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 138 additions and 25 deletions

View File

@ -8,6 +8,7 @@ import React, { useCallback } from 'react';
import { Text, Box, useInput, useFocus, Key } from 'ink'; import { Text, Box, useInput, useFocus, Key } from 'ink';
import TextInput from 'ink-text-input'; import TextInput from 'ink-text-input';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { Suggestion } from './SuggestionsDisplay.js';
interface InputPromptProps { interface InputPromptProps {
query: string; query: string;
@ -16,7 +17,7 @@ interface InputPromptProps {
setInputKey: React.Dispatch<React.SetStateAction<number>>; setInputKey: React.Dispatch<React.SetStateAction<number>>;
onSubmit: (value: string) => void; onSubmit: (value: string) => void;
showSuggestions: boolean; showSuggestions: boolean;
suggestions: string[]; suggestions: Suggestion[]; // Changed to Suggestion[]
activeSuggestionIndex: number; activeSuggestionIndex: number;
navigateUp: () => void; navigateUp: () => void;
navigateDown: () => void; navigateDown: () => void;
@ -63,7 +64,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
base = query.substring(0, atIndex + 1 + lastSlashIndexInPath + 1); base = query.substring(0, atIndex + 1 + lastSlashIndexInPath + 1);
} }
const newValue = base + selectedSuggestion; const newValue = base + selectedSuggestion.value;
setQuery(newValue); setQuery(newValue);
resetCompletion(); // Hide suggestions after selection resetCompletion(); // Hide suggestions after selection
setInputKey((k) => k + 1); // Increment key to force re-render and cursor reset setInputKey((k) => k + 1); // Increment key to force re-render and cursor reset

View File

@ -6,9 +6,12 @@
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
export interface Suggestion {
label: string;
value: string;
}
interface SuggestionsDisplayProps { interface SuggestionsDisplayProps {
suggestions: string[]; suggestions: Suggestion[];
activeIndex: number; activeIndex: number;
isLoading: boolean; isLoading: boolean;
width: number; width: number;
@ -62,7 +65,7 @@ export function SuggestionsDisplay({
color={isActive ? 'black' : 'white'} color={isActive ? 'black' : 'white'}
backgroundColor={isActive ? 'blue' : undefined} backgroundColor={isActive ? 'blue' : undefined}
> >
{suggestion} {suggestion.label}
</Text> </Text>
); );
})} })}

View File

@ -7,7 +7,12 @@
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { PartListUnion } from '@google/genai'; import { PartListUnion } from '@google/genai';
import { Config, getErrorMessage, isNodeError } from '@gemini-code/server'; import {
Config,
getErrorMessage,
isNodeError,
unescapePath,
} from '@gemini-code/server';
import { import {
HistoryItem, HistoryItem,
IndividualToolCallDisplay, IndividualToolCallDisplay,
@ -39,6 +44,54 @@ interface HandleAtCommandResult {
shouldProceed: boolean; shouldProceed: boolean;
} }
/**
* Parses a query string to find the first '@<path>' 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 '@<path>' command. * Processes user input potentially containing an '@<path>' command.
* It finds the first '@<path>', checks if the path is a file or directory, * It finds the first '@<path>', checks if the path is a file or directory,
@ -58,26 +111,51 @@ export async function handleAtCommand({
userMessageTimestamp, userMessageTimestamp,
}: HandleAtCommandParams): Promise<HandleAtCommandResult> { }: HandleAtCommandParams): Promise<HandleAtCommandResult> {
const trimmedQuery = query.trim(); const trimmedQuery = query.trim();
const parsedCommand = parseAtCommand(trimmedQuery);
const atCommandRegex = /^(.*?)(@\S+)(.*)$/s; if (!parsedCommand) {
const match = trimmedQuery.match(atCommandRegex); // If no '@' was found, treat the whole query as user text and proceed
// This allows users to just type text without an @ command
if (!match) { 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); const errorTimestamp = getNextMessageId(userMessageTimestamp);
addHistoryItem( addHistoryItem(
setHistory, 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, errorTimestamp,
); );
return { processedQuery: null, shouldProceed: false }; 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( addHistoryItem(
setHistory, setHistory,
{ type: 'user', text: query }, { type: 'user', text: query },

View File

@ -7,11 +7,13 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { isNodeError } from '@gemini-code/server'; import { isNodeError, escapePath, unescapePath } from '@gemini-code/server';
import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js'; import {
MAX_SUGGESTIONS_TO_SHOW,
Suggestion,
} from '../components/SuggestionsDisplay.js';
export interface UseCompletionReturn { export interface UseCompletionReturn {
suggestions: string[]; suggestions: Suggestion[];
activeSuggestionIndex: number; activeSuggestionIndex: number;
visibleStartIndex: number; visibleStartIndex: number;
showSuggestions: boolean; showSuggestions: boolean;
@ -28,7 +30,7 @@ export function useCompletion(
cwd: string, cwd: string,
isActive: boolean, isActive: boolean,
): UseCompletionReturn { ): UseCompletionReturn {
const [suggestions, setSuggestions] = useState<string[]>([]); const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [activeSuggestionIndex, setActiveSuggestionIndex] = const [activeSuggestionIndex, setActiveSuggestionIndex] =
useState<number>(-1); useState<number>(-1);
const [visibleStartIndex, setVisibleStartIndex] = useState<number>(0); const [visibleStartIndex, setVisibleStartIndex] = useState<number>(0);
@ -121,10 +123,12 @@ export function useCompletion(
lastSlashIndex === -1 lastSlashIndex === -1
? '.' ? '.'
: partialPath.substring(0, lastSlashIndex + 1); : partialPath.substring(0, lastSlashIndex + 1);
const prefix = const prefix = unescapePath(
lastSlashIndex === -1 lastSlashIndex === -1
? partialPath ? partialPath
: partialPath.substring(lastSlashIndex + 1); : partialPath.substring(lastSlashIndex + 1),
);
const baseDirAbsolute = path.resolve(cwd, baseDirRelative); const baseDirAbsolute = path.resolve(cwd, baseDirRelative);
let isMounted = true; let isMounted = true;
@ -144,7 +148,11 @@ export function useCompletion(
if (aIsDir && !bIsDir) return -1; if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1; if (!aIsDir && bIsDir) return 1;
return a.localeCompare(b); return a.localeCompare(b);
}); })
.map((entry) => ({
label: entry,
value: escapePath(entry),
}));
if (isMounted) { if (isMounted) {
setSuggestions(filteredSuggestions); setSuggestions(filteredSuggestions);

View File

@ -100,3 +100,26 @@ export function makeRelative(
// If the paths are the same, path.relative returns '', return '.' instead // If the paths are the same, path.relative returns '', return '.' instead
return relativePath || '.'; 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, ' ');
}