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