Add @ command handling to useGeminiStream (#217)

* First integration of at commands into useGeminiStream.ts

* feat: Integrate @ command for file/directory reading

   - Adds support for `@<path>` commands in the CLI UI to read file or directory contents using the `read_many_files` tool.
   - Refactors `useGeminiStream` hook to handle slash, passthrough, and @ commands before sending queries to the Gemini API.
   - Improves history item ID generation to prevent React duplicate key warnings.

* fix: Handle additional text after @ command path

   - Modifies the `@` command processor to parse text following the file/directory path (e.g., `@README.md explain this`).
   - Includes both the fetched file content and the subsequent text in the query sent to the Gemini API.
   - Resolves the TODO item in `atCommandProcessor.ts`.

* feat: Allow @ command anywhere in query and fix build

   - Update `atCommandProcessor` to correctly parse `@<path>` commands regardless of their position in the input string using regex. This enables queries like "Explain @README.md to me".
   - Fix build error in `useGeminiStream` by importing the missing `findSafeSplitPoint` function.

* rename isPotentiallyAtCommand to isAtCommand

* respond to review comments.
This commit is contained in:
Allen Hutchison 2025-04-29 15:39:36 -07:00 committed by GitHub
parent c1b23c008a
commit 889200d400
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 117 additions and 68 deletions

View File

@ -12,7 +12,7 @@ import {
ToolCallStatus, ToolCallStatus,
} from '../types.js'; } from '../types.js';
// Helper function to add history items (could be moved to a shared util if needed elsewhere) // Helper function to add history items
const addHistoryItem = ( const addHistoryItem = (
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>, setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
itemData: Omit<HistoryItem, 'id'>, itemData: Omit<HistoryItem, 'id'>,
@ -25,7 +25,7 @@ const addHistoryItem = (
}; };
interface HandleAtCommandParams { interface HandleAtCommandParams {
query: string; // Raw user input query: string; // Raw user input, potentially containing '@'
config: Config; config: Config;
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>; setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>;
setDebugMessage: React.Dispatch<React.SetStateAction<string>>; setDebugMessage: React.Dispatch<React.SetStateAction<string>>;
@ -34,17 +34,18 @@ interface HandleAtCommandParams {
} }
interface HandleAtCommandResult { interface HandleAtCommandResult {
processedQuery: PartListUnion; // Query to potentially send to Gemini processedQuery: PartListUnion | null; // Query for Gemini (null on error/no-proceed)
shouldProceed: boolean; // Whether the main hook should continue processing shouldProceed: boolean; // Whether the main hook should continue processing
} }
/** /**
* Processes user input that might start with the '@' command to read files/directories. * Processes user input potentially containing an '@<path>' command.
* If it's an '@' command, it attempts to read the specified path, updates the UI * It finds the first '@<path>', reads the specified path, updates the UI,
* with the tool call status, and prepares the query to be sent to the LLM. * and prepares the query for the LLM, incorporating the file content
* and surrounding text.
* *
* @returns An object containing the potentially modified query and a flag * @returns An object containing the potentially modified query (or null)
* indicating if the main hook should proceed with the Gemini API call. * and a flag indicating if the main hook should proceed.
*/ */
export async function handleAtCommand({ export async function handleAtCommand({
query, query,
@ -56,42 +57,50 @@ export async function handleAtCommand({
}: HandleAtCommandParams): Promise<HandleAtCommandResult> { }: HandleAtCommandParams): Promise<HandleAtCommandResult> {
const trimmedQuery = query.trim(); const trimmedQuery = query.trim();
if (!trimmedQuery.startsWith('@')) { // Regex to find the first occurrence of @ followed by non-whitespace chars
// Not an '@' command, proceed as normal // It captures the text before, the @path itself (including @), and the text after.
// Add the user message here before returning const atCommandRegex = /^(.*?)(@\S+)(.*)$/s; // s flag for dot to match newline
const match = trimmedQuery.match(atCommandRegex);
if (!match) {
// This should technically not happen if isPotentiallyAtCommand was true,
// but handle defensively.
const errorTimestamp = getNextMessageId(userMessageTimestamp);
addHistoryItem( addHistoryItem(
setHistory, setHistory,
{ type: 'user', text: query }, { type: 'error', text: 'Error: Could not parse @ command.' },
userMessageTimestamp, errorTimestamp,
); );
// Use property shorthand for processedQuery return { processedQuery: null, shouldProceed: false };
return { processedQuery: query, shouldProceed: true };
} }
// --- It is an '@' command --- const textBefore = match[1].trim();
const filePath = trimmedQuery.substring(1); const atPath = match[2]; // Includes the '@'
const textAfter = match[3].trim();
if (!filePath) { const pathPart = atPath.substring(1); // Remove the leading '@'
// Handle case where it's just "@" - treat as normal input
// Add user message for the full original @ command
addHistoryItem(
setHistory,
{ type: 'user', text: query }, // Use original full query for history
userMessageTimestamp,
);
if (!pathPart) {
// Handle case where it's just "@" or "@ " - treat as error/don't proceed
const errorTimestamp = getNextMessageId(userMessageTimestamp);
addHistoryItem( addHistoryItem(
setHistory, setHistory,
{ type: 'user', text: query }, { type: 'error', text: 'Error: No path specified after @.' },
userMessageTimestamp, errorTimestamp,
); );
// Use property shorthand for processedQuery return { processedQuery: null, shouldProceed: false };
return { processedQuery: query, shouldProceed: true }; // Send the "@" to the model
} }
const toolRegistry = config.getToolRegistry(); const toolRegistry = config.getToolRegistry();
const readManyFilesTool = toolRegistry.getTool('read_many_files'); 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) { if (!readManyFilesTool) {
const errorTimestamp = getNextMessageId(userMessageTimestamp); const errorTimestamp = getNextMessageId(userMessageTimestamp);
addHistoryItem( addHistoryItem(
@ -99,23 +108,21 @@ export async function handleAtCommand({
{ type: 'error', text: 'Error: read_many_files tool not found.' }, { type: 'error', text: 'Error: read_many_files tool not found.' },
errorTimestamp, errorTimestamp,
); );
// Use property shorthand for processedQuery return { processedQuery: null, shouldProceed: false }; // Don't proceed if tool is missing
return { processedQuery: query, shouldProceed: false }; // Don't proceed if tool is missing
} }
// --- Path Handling for @ command --- // --- Path Handling for @ command ---
let pathSpec = filePath; let pathSpec = pathPart; // Use the extracted path part
// Basic check: If no extension or ends with '/', assume directory and add globstar. // Basic check: If no extension or ends with '/', assume directory and add globstar.
if (!filePath.includes('.') || filePath.endsWith('/')) { if (!pathPart.includes('.') || pathPart.endsWith('/')) {
pathSpec = filePath.endsWith('/') ? `${filePath}**` : `${filePath}/**`; pathSpec = pathPart.endsWith('/') ? `${pathPart}**` : `${pathPart}/**`;
} }
const toolArgs = { paths: [pathSpec] }; const toolArgs = { paths: [pathSpec] };
const contentLabel = const contentLabel =
pathSpec === filePath ? filePath : `directory ${filePath}`; // Adjust label pathSpec === pathPart ? pathPart : `directory ${pathPart}`; // Adjust label
// --- End Path Handling --- // --- End Path Handling ---
let toolCallDisplay: IndividualToolCallDisplay; let toolCallDisplay: IndividualToolCallDisplay;
let processedQuery: PartListUnion = query; // Default to original query
try { try {
setDebugMessage(`Reading via @ command: ${pathSpec}`); setDebugMessage(`Reading via @ command: ${pathSpec}`);
@ -132,15 +139,19 @@ export async function handleAtCommand({
confirmationDetails: undefined, confirmationDetails: undefined,
}; };
// Prepend file content to the query sent to the model // Construct the query for Gemini, combining parts
processedQuery = [ const processedQueryParts = [];
{ if (textBefore) {
text: `--- Content from: ${contentLabel} --- processedQueryParts.push({ text: textBefore });
${fileContent} }
--- End Content ---`, processedQueryParts.push({
}, text: `\n--- Content from: ${contentLabel} ---\n${fileContent}\n--- End Content ---`,
// TODO: Handle cases like "@README.md explain this" by appending the rest of the query });
]; if (textAfter) {
processedQueryParts.push({ text: textAfter });
}
const processedQuery: PartListUnion = processedQueryParts;
// Add the tool group UI // Add the tool group UI
const toolGroupId = getNextMessageId(userMessageTimestamp); const toolGroupId = getNextMessageId(userMessageTimestamp);
@ -153,7 +164,6 @@ ${fileContent}
toolGroupId, toolGroupId,
); );
// Use property shorthand for processedQuery
return { processedQuery, shouldProceed: true }; // Proceed to Gemini return { processedQuery, shouldProceed: true }; // Proceed to Gemini
} catch (error) { } catch (error) {
// Construct error UI // Construct error UI
@ -177,7 +187,6 @@ ${fileContent}
toolGroupId, toolGroupId,
); );
// Use property shorthand for processedQuery return { processedQuery: null, shouldProceed: false }; // Don't proceed on error
return { processedQuery: query, shouldProceed: false }; // Don't proceed on error
} }
} }

View File

@ -27,9 +27,11 @@ import {
IndividualToolCallDisplay, IndividualToolCallDisplay,
ToolCallStatus, ToolCallStatus,
} from '../types.js'; } from '../types.js';
import { findSafeSplitPoint } from '../utils/markdownUtilities.js'; import { isAtCommand } from '../utils/commandUtils.js'; // Import the @ command checker
import { useSlashCommandProcessor } from './slashCommandProcessor.js'; import { useSlashCommandProcessor } from './slashCommandProcessor.js';
import { usePassthroughProcessor } from './passthroughCommandProcessor.js'; import { usePassthroughProcessor } from './passthroughCommandProcessor.js';
import { handleAtCommand } from './atCommandProcessor.js'; // Import the @ command handler
import { findSafeSplitPoint } from '../utils/markdownUtilities.js'; // Import the split point finder
const addHistoryItem = ( const addHistoryItem = (
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>, setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
@ -61,6 +63,7 @@ export const useGeminiStream = (
// ID Generation Callback // ID Generation Callback
const getNextMessageId = useCallback((baseTimestamp: number): number => { const getNextMessageId = useCallback((baseTimestamp: number): number => {
// Increment *before* adding to ensure uniqueness against the base timestamp
messageIdCounterRef.current += 1; messageIdCounterRef.current += 1;
return baseTimestamp + messageIdCounterRef.current; return baseTimestamp + messageIdCounterRef.current;
}, []); }, []);
@ -144,32 +147,63 @@ export const useGeminiStream = (
if (typeof query === 'string' && query.trim().length === 0) return; if (typeof query === 'string' && query.trim().length === 0) return;
const userMessageTimestamp = Date.now(); const userMessageTimestamp = Date.now();
messageIdCounterRef.current = 0; // Reset counter for this new submission
let queryToSendToGemini: PartListUnion | null = null;
if (typeof query === 'string') { if (typeof query === 'string') {
setDebugMessage(`User query: '${query}'`); const trimmedQuery = query.trim();
setDebugMessage(`User query: '${trimmedQuery}'`);
// 1. Check for Slash Commands // 1. Check for Slash Commands
if (handleSlashCommand(query)) { if (handleSlashCommand(trimmedQuery)) {
return; // Command was handled, exit early return; // Handled, exit
} }
// 2. Check for Passthrough Commands // 2. Check for Passthrough Commands
if (handlePassthroughCommand(query)) { if (handlePassthroughCommand(trimmedQuery)) {
return; // Command was handled, exit early return; // Handled, exit
} }
// 3. Add user message if not handled by slash/passthrough // 3. Check for @ Commands using the utility function
addHistoryItem( if (isAtCommand(trimmedQuery)) {
setHistory, const atCommandResult = await handleAtCommand({
{ type: 'user', text: query }, query: trimmedQuery,
userMessageTimestamp, config,
); setHistory,
setDebugMessage,
getNextMessageId,
userMessageTimestamp,
});
if (!atCommandResult.shouldProceed) {
return; // @ command handled it (e.g., error) or decided not to proceed
}
queryToSendToGemini = atCommandResult.processedQuery;
// User message and tool UI were added by handleAtCommand
} else {
// 4. It's a normal query for Gemini
addHistoryItem(
setHistory,
{ type: 'user', text: trimmedQuery },
userMessageTimestamp,
);
queryToSendToGemini = trimmedQuery;
}
} else { } else {
// For function responses (PartListUnion that isn't a string), // 5. It's a function response (PartListUnion that isn't a string)
// we don't add a user message here. The tool call/response UI handles it. // Tool call/response UI handles history. Always proceed.
queryToSendToGemini = query;
}
// --- Proceed to Gemini API call ---
if (queryToSendToGemini === null) {
// Should only happen if @ command failed and returned null query
setDebugMessage(
'Query processing resulted in null, not sending to Gemini.',
);
return;
} }
// 4. Proceed to Gemini API call
const client = geminiClientRef.current; const client = geminiClientRef.current;
if (!client) { if (!client) {
setInitError('Gemini client is not available.'); setInitError('Gemini client is not available.');
@ -188,7 +222,6 @@ export const useGeminiStream = (
setStreamingState(StreamingState.Responding); setStreamingState(StreamingState.Responding);
setInitError(null); setInitError(null);
messageIdCounterRef.current = 0; // Reset counter for new submission
const chat = chatSessionRef.current; const chat = chatSessionRef.current;
let currentToolGroupId: number | null = null; let currentToolGroupId: number | null = null;
@ -196,8 +229,12 @@ export const useGeminiStream = (
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal; const signal = abortControllerRef.current.signal;
// Use the original query for the Gemini call // Use the determined query for the Gemini call
const stream = client.sendMessageStream(chat, query, signal); const stream = client.sendMessageStream(
chat,
queryToSendToGemini,
signal,
);
// Process the stream events from the server logic // Process the stream events from the server logic
let currentGeminiText = ''; // To accumulate message content let currentGeminiText = ''; // To accumulate message content
@ -488,6 +525,9 @@ export const useGeminiStream = (
updateGeminiMessage, updateGeminiMessage,
handleSlashCommand, handleSlashCommand,
handlePassthroughCommand, handlePassthroughCommand,
// handleAtCommand is implicitly included via its direct call
setDebugMessage, // Added dependency for handleAtCommand & passthrough
setStreamingState, // Added dependency for handlePassthroughCommand
updateAndAddGeminiMessageContent, updateAndAddGeminiMessageContent,
], ],
); );

View File

@ -12,7 +12,7 @@
* @param query The input query string. * @param query The input query string.
* @returns True if the query looks like an '@' command, false otherwise. * @returns True if the query looks like an '@' command, false otherwise.
*/ */
export const isPotentiallyAtCommand = (query: string): boolean => export const isAtCommand = (query: string): boolean =>
// Check if starts with @ OR has a space, then @, then a non-space character. // Check if starts with @ OR has a space, then @, then a non-space character.
query.startsWith('@') || /\s@\S/.test(query); query.startsWith('@') || /\s@\S/.test(query);