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:
parent
c1b23c008a
commit
889200d400
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue