First four independent files for @ commands. (#205)
This commit is contained in:
parent
df44ffbcff
commit
e0de69f384
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
|
||||||
|
interface SuggestionsDisplayProps {
|
||||||
|
suggestions: string[];
|
||||||
|
activeIndex: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
width: number;
|
||||||
|
scrollOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_SUGGESTIONS_TO_SHOW = 8;
|
||||||
|
|
||||||
|
export function SuggestionsDisplay({
|
||||||
|
suggestions,
|
||||||
|
activeIndex,
|
||||||
|
isLoading,
|
||||||
|
width,
|
||||||
|
scrollOffset,
|
||||||
|
}: SuggestionsDisplayProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box borderStyle="round" paddingX={1} width={width}>
|
||||||
|
<Text color="gray">Loading suggestions...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
return null; // Don't render anything if there are no suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the visible slice based on scrollOffset
|
||||||
|
const startIndex = scrollOffset;
|
||||||
|
const endIndex = Math.min(
|
||||||
|
scrollOffset + MAX_SUGGESTIONS_TO_SHOW,
|
||||||
|
suggestions.length,
|
||||||
|
);
|
||||||
|
const visibleSuggestions = suggestions.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={1}
|
||||||
|
width={width} // Use the passed width
|
||||||
|
>
|
||||||
|
{scrollOffset > 0 && <Text color="gray">▲</Text>}
|
||||||
|
|
||||||
|
{visibleSuggestions.map((suggestion, index) => {
|
||||||
|
const originalIndex = startIndex + index;
|
||||||
|
const isActive = originalIndex === activeIndex;
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={`${suggestion}-${originalIndex}`}
|
||||||
|
color={isActive ? 'black' : 'white'}
|
||||||
|
backgroundColor={isActive ? 'blue' : undefined}
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{endIndex < suggestions.length && <Text color="gray">▼</Text>}
|
||||||
|
{suggestions.length > MAX_SUGGESTIONS_TO_SHOW && (
|
||||||
|
<Text color="gray">
|
||||||
|
({activeIndex + 1}/{suggestions.length})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,183 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PartListUnion } from '@google/genai';
|
||||||
|
import { Config, getErrorMessage } from '@gemini-code/server';
|
||||||
|
import {
|
||||||
|
HistoryItem,
|
||||||
|
IndividualToolCallDisplay,
|
||||||
|
ToolCallStatus,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
// Helper function to add history items (could be moved to a shared util if needed elsewhere)
|
||||||
|
const addHistoryItem = (
|
||||||
|
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
||||||
|
itemData: Omit<HistoryItem, 'id'>,
|
||||||
|
id: number,
|
||||||
|
) => {
|
||||||
|
setHistory((prevHistory) => [
|
||||||
|
...prevHistory,
|
||||||
|
{ ...itemData, id } as HistoryItem,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HandleAtCommandParams {
|
||||||
|
query: string; // Raw user input
|
||||||
|
config: Config;
|
||||||
|
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>;
|
||||||
|
setDebugMessage: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
getNextMessageId: (baseTimestamp: number) => number;
|
||||||
|
userMessageTimestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HandleAtCommandResult {
|
||||||
|
processedQuery: PartListUnion; // Query to potentially send to Gemini
|
||||||
|
shouldProceed: boolean; // Whether the main hook should continue processing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes user input that might start with the '@' command to read files/directories.
|
||||||
|
* If it's an '@' command, it attempts to read the specified path, updates the UI
|
||||||
|
* with the tool call status, and prepares the query to be sent to the LLM.
|
||||||
|
*
|
||||||
|
* @returns An object containing the potentially modified query and a flag
|
||||||
|
* indicating if the main hook should proceed with the Gemini API call.
|
||||||
|
*/
|
||||||
|
export async function handleAtCommand({
|
||||||
|
query,
|
||||||
|
config,
|
||||||
|
setHistory,
|
||||||
|
setDebugMessage,
|
||||||
|
getNextMessageId,
|
||||||
|
userMessageTimestamp,
|
||||||
|
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
|
||||||
|
if (!trimmedQuery.startsWith('@')) {
|
||||||
|
// Not an '@' command, proceed as normal
|
||||||
|
// Add the user message here before returning
|
||||||
|
addHistoryItem(
|
||||||
|
setHistory,
|
||||||
|
{ type: 'user', text: query },
|
||||||
|
userMessageTimestamp,
|
||||||
|
);
|
||||||
|
// Use property shorthand for processedQuery
|
||||||
|
return { processedQuery: query, shouldProceed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- It is an '@' command ---
|
||||||
|
const filePath = trimmedQuery.substring(1);
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
// Handle case where it's just "@" - treat as normal input
|
||||||
|
addHistoryItem(
|
||||||
|
setHistory,
|
||||||
|
{ type: 'user', text: query },
|
||||||
|
userMessageTimestamp,
|
||||||
|
);
|
||||||
|
// Use property shorthand for processedQuery
|
||||||
|
return { processedQuery: query, shouldProceed: true }; // Send the "@" to the model
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolRegistry = config.getToolRegistry();
|
||||||
|
const readManyFilesTool = toolRegistry.getTool('read_many_files');
|
||||||
|
|
||||||
|
// Add user message first, so it appears before potential errors/tool UI
|
||||||
|
addHistoryItem(
|
||||||
|
setHistory,
|
||||||
|
{ type: 'user', text: query },
|
||||||
|
userMessageTimestamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!readManyFilesTool) {
|
||||||
|
const errorTimestamp = getNextMessageId(userMessageTimestamp);
|
||||||
|
addHistoryItem(
|
||||||
|
setHistory,
|
||||||
|
{ type: 'error', text: 'Error: read_many_files tool not found.' },
|
||||||
|
errorTimestamp,
|
||||||
|
);
|
||||||
|
// Use property shorthand for processedQuery
|
||||||
|
return { processedQuery: query, shouldProceed: false }; // Don't proceed if tool is missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Path Handling for @ command ---
|
||||||
|
let pathSpec = filePath;
|
||||||
|
// Basic check: If no extension or ends with '/', assume directory and add globstar.
|
||||||
|
if (!filePath.includes('.') || filePath.endsWith('/')) {
|
||||||
|
pathSpec = filePath.endsWith('/') ? `${filePath}**` : `${filePath}/**`;
|
||||||
|
}
|
||||||
|
const toolArgs = { paths: [pathSpec] };
|
||||||
|
const contentLabel =
|
||||||
|
pathSpec === filePath ? filePath : `directory ${filePath}`; // Adjust label
|
||||||
|
// --- End Path Handling ---
|
||||||
|
|
||||||
|
let toolCallDisplay: IndividualToolCallDisplay;
|
||||||
|
let processedQuery: PartListUnion = query; // Default to original query
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDebugMessage(`Reading via @ command: ${pathSpec}`);
|
||||||
|
const result = await readManyFilesTool.execute(toolArgs);
|
||||||
|
const fileContent = result.llmContent || '';
|
||||||
|
|
||||||
|
// Construct success UI
|
||||||
|
toolCallDisplay = {
|
||||||
|
callId: `client-read-${userMessageTimestamp}`,
|
||||||
|
name: readManyFilesTool.displayName,
|
||||||
|
description: readManyFilesTool.getDescription(toolArgs),
|
||||||
|
status: ToolCallStatus.Success,
|
||||||
|
resultDisplay: result.returnDisplay,
|
||||||
|
confirmationDetails: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepend file content to the query sent to the model
|
||||||
|
processedQuery = [
|
||||||
|
{
|
||||||
|
text: `--- Content from: ${contentLabel} ---
|
||||||
|
${fileContent}
|
||||||
|
--- End Content ---`,
|
||||||
|
},
|
||||||
|
// TODO: Handle cases like "@README.md explain this" by appending the rest of the query
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add the tool group UI
|
||||||
|
const toolGroupId = getNextMessageId(userMessageTimestamp);
|
||||||
|
addHistoryItem(
|
||||||
|
setHistory,
|
||||||
|
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
|
||||||
|
HistoryItem,
|
||||||
|
'id'
|
||||||
|
>,
|
||||||
|
toolGroupId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use property shorthand for processedQuery
|
||||||
|
return { processedQuery, shouldProceed: true }; // Proceed to Gemini
|
||||||
|
} catch (error) {
|
||||||
|
// Construct error UI
|
||||||
|
toolCallDisplay = {
|
||||||
|
callId: `client-read-${userMessageTimestamp}`,
|
||||||
|
name: readManyFilesTool.displayName,
|
||||||
|
description: readManyFilesTool.getDescription(toolArgs),
|
||||||
|
status: ToolCallStatus.Error,
|
||||||
|
resultDisplay: `Error reading ${contentLabel}: ${getErrorMessage(error)}`,
|
||||||
|
confirmationDetails: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add the tool group UI and signal not to proceed
|
||||||
|
const toolGroupId = getNextMessageId(userMessageTimestamp);
|
||||||
|
addHistoryItem(
|
||||||
|
setHistory,
|
||||||
|
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
|
||||||
|
HistoryItem,
|
||||||
|
'id'
|
||||||
|
>,
|
||||||
|
toolGroupId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use property shorthand for processedQuery
|
||||||
|
return { processedQuery: query, shouldProceed: false }; // Don't proceed on error
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,187 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { isNodeError } from '@gemini-code/server';
|
||||||
|
|
||||||
|
const MAX_SUGGESTIONS_TO_SHOW = 8;
|
||||||
|
|
||||||
|
export interface UseCompletionReturn {
|
||||||
|
suggestions: string[];
|
||||||
|
activeSuggestionIndex: number;
|
||||||
|
visibleStartIndex: number;
|
||||||
|
showSuggestions: boolean;
|
||||||
|
isLoadingSuggestions: boolean;
|
||||||
|
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
resetCompletionState: () => void;
|
||||||
|
navigateUp: () => void;
|
||||||
|
navigateDown: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompletion(
|
||||||
|
query: string,
|
||||||
|
cwd: string,
|
||||||
|
isActive: boolean,
|
||||||
|
): UseCompletionReturn {
|
||||||
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
|
const [activeSuggestionIndex, setActiveSuggestionIndex] =
|
||||||
|
useState<number>(-1);
|
||||||
|
const [visibleStartIndex, setVisibleStartIndex] = useState<number>(0);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
|
||||||
|
const [isLoadingSuggestions, setIsLoadingSuggestions] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
|
const resetCompletionState = useCallback(() => {
|
||||||
|
setSuggestions([]);
|
||||||
|
setActiveSuggestionIndex(-1);
|
||||||
|
setVisibleStartIndex(0);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setIsLoadingSuggestions(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Navigation Logic ---
|
||||||
|
const navigateUp = useCallback(() => {
|
||||||
|
if (suggestions.length === 0) return;
|
||||||
|
|
||||||
|
setActiveSuggestionIndex((prevIndex) => {
|
||||||
|
const newIndex = prevIndex <= 0 ? suggestions.length - 1 : prevIndex - 1;
|
||||||
|
|
||||||
|
// Adjust visible window if needed (scrolling up)
|
||||||
|
if (newIndex < visibleStartIndex) {
|
||||||
|
setVisibleStartIndex(newIndex);
|
||||||
|
} else if (
|
||||||
|
newIndex === suggestions.length - 1 &&
|
||||||
|
suggestions.length > MAX_SUGGESTIONS_TO_SHOW
|
||||||
|
) {
|
||||||
|
// Handle wrapping from first to last item
|
||||||
|
setVisibleStartIndex(
|
||||||
|
Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newIndex;
|
||||||
|
});
|
||||||
|
}, [suggestions.length, visibleStartIndex]);
|
||||||
|
|
||||||
|
const navigateDown = useCallback(() => {
|
||||||
|
if (suggestions.length === 0) return;
|
||||||
|
|
||||||
|
setActiveSuggestionIndex((prevIndex) => {
|
||||||
|
const newIndex = prevIndex >= suggestions.length - 1 ? 0 : prevIndex + 1;
|
||||||
|
|
||||||
|
// Adjust visible window if needed (scrolling down)
|
||||||
|
if (newIndex >= visibleStartIndex + MAX_SUGGESTIONS_TO_SHOW) {
|
||||||
|
setVisibleStartIndex(visibleStartIndex + 1);
|
||||||
|
} else if (
|
||||||
|
newIndex === 0 &&
|
||||||
|
suggestions.length > MAX_SUGGESTIONS_TO_SHOW
|
||||||
|
) {
|
||||||
|
// Handle wrapping from last to first item
|
||||||
|
setVisibleStartIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newIndex;
|
||||||
|
});
|
||||||
|
}, [suggestions.length, visibleStartIndex]);
|
||||||
|
// --- End Navigation Logic ---
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
resetCompletionState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const atIndex = query.lastIndexOf('@');
|
||||||
|
if (atIndex === -1) {
|
||||||
|
resetCompletionState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partialPath = query.substring(atIndex + 1);
|
||||||
|
const lastSlashIndex = partialPath.lastIndexOf('/');
|
||||||
|
const baseDirRelative =
|
||||||
|
lastSlashIndex === -1
|
||||||
|
? '.'
|
||||||
|
: partialPath.substring(0, lastSlashIndex + 1);
|
||||||
|
const prefix =
|
||||||
|
lastSlashIndex === -1
|
||||||
|
? partialPath
|
||||||
|
: partialPath.substring(lastSlashIndex + 1);
|
||||||
|
const baseDirAbsolute = path.resolve(cwd, baseDirRelative);
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
const fetchSuggestions = async () => {
|
||||||
|
setIsLoadingSuggestions(true);
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(baseDirAbsolute, {
|
||||||
|
withFileTypes: true,
|
||||||
|
});
|
||||||
|
const filteredSuggestions = entries
|
||||||
|
.filter((entry) => entry.name.startsWith(prefix))
|
||||||
|
.map((entry) => (entry.isDirectory() ? entry.name + '/' : entry.name))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort directories first, then alphabetically
|
||||||
|
const aIsDir = a.endsWith('/');
|
||||||
|
const bIsDir = b.endsWith('/');
|
||||||
|
if (aIsDir && !bIsDir) return -1;
|
||||||
|
if (!aIsDir && bIsDir) return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setSuggestions(filteredSuggestions);
|
||||||
|
setShowSuggestions(filteredSuggestions.length > 0);
|
||||||
|
setActiveSuggestionIndex(-1); // Reset selection on new suggestions
|
||||||
|
setVisibleStartIndex(0); // Reset scroll on new suggestions
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||||
|
// Directory doesn't exist, likely mid-typing, clear suggestions
|
||||||
|
if (isMounted) {
|
||||||
|
setSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`Error fetching completion suggestions for ${baseDirAbsolute}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
if (isMounted) {
|
||||||
|
resetCompletionState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoadingSuggestions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the fetch slightly
|
||||||
|
const debounceTimeout = setTimeout(fetchSuggestions, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
// Don't reset loading state here, let the next effect handle it or resetCompletionState
|
||||||
|
};
|
||||||
|
}, [query, cwd, isActive, resetCompletionState]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions,
|
||||||
|
activeSuggestionIndex,
|
||||||
|
visibleStartIndex,
|
||||||
|
showSuggestions,
|
||||||
|
isLoadingSuggestions,
|
||||||
|
setActiveSuggestionIndex,
|
||||||
|
setShowSuggestions,
|
||||||
|
resetCompletionState,
|
||||||
|
navigateUp,
|
||||||
|
navigateDown,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a query string potentially represents an '@' command.
|
||||||
|
* It triggers if the query starts with '@' or contains '@' preceded by whitespace
|
||||||
|
* and followed by a non-whitespace character.
|
||||||
|
*
|
||||||
|
* @param query The input query string.
|
||||||
|
* @returns True if the query looks like an '@' command, false otherwise.
|
||||||
|
*/
|
||||||
|
export const isPotentiallyAtCommand = (query: string): boolean =>
|
||||||
|
// Check if starts with @ OR has a space, then @, then a non-space character.
|
||||||
|
query.startsWith('@') || /\s@\S/.test(query);
|
Loading…
Reference in New Issue