First four independent files for @ commands. (#205)

This commit is contained in:
Allen Hutchison 2025-04-29 08:29:09 -07:00 committed by GitHub
parent df44ffbcff
commit e0de69f384
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 464 additions and 0 deletions

View File

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

View File

@ -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
}
}

View File

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

View File

@ -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);