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 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<React.SetStateAction<number>>;
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<InputPromptProps> = ({
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

View File

@ -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}
</Text>
);
})}

View File

@ -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 '@<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.
* It finds the first '@<path>', checks if the path is a file or directory,
@ -58,26 +111,51 @@ export async function handleAtCommand({
userMessageTimestamp,
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
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 },

View File

@ -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<string[]>([]);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [activeSuggestionIndex, setActiveSuggestionIndex] =
useState<number>(-1);
const [visibleStartIndex, setVisibleStartIndex] = useState<number>(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);

View File

@ -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, ' ');
}