Support escaping spaces in file paths. (#241)
This commit is contained in:
parent
ca53565240
commit
53ac7952c7
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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, ' ');
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue