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 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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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, ' ');
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue