summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks/atCommandProcessor.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/hooks/atCommandProcessor.ts')
-rw-r--r--packages/cli/src/ui/hooks/atCommandProcessor.ts425
1 files changed, 271 insertions, 154 deletions
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts
index dd97a0d6..4416ef82 100644
--- a/packages/cli/src/ui/hooks/atCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts
@@ -34,53 +34,82 @@ interface HandleAtCommandResult {
shouldProceed: boolean;
}
+interface AtCommandPart {
+ type: 'text' | 'atPath';
+ content: string;
+}
+
/**
- * Parses a query string to find the first '@<path>' command,
- * handling \ escaped spaces within the path.
+ * Parses a query string to find all '@<path>' commands and text segments.
+ * Handles \ escaped spaces within paths.
*/
-function parseAtCommand(
- query: string,
-): { textBefore: string; atPath: string; textAfter: string } | null {
- let atIndex = -1;
- for (let i = 0; i < query.length; i++) {
- if (query[i] === '@' && (i === 0 || query[i - 1] !== '\\')) {
- atIndex = i;
- break;
- }
- }
+function parseAllAtCommands(query: string): AtCommandPart[] {
+ const parts: AtCommandPart[] = [];
+ let currentIndex = 0;
- if (atIndex === -1) {
- return null;
- }
-
- const textBefore = query.substring(0, atIndex).trim();
- let pathEndIndex = atIndex + 1;
- let inEscape = false;
+ while (currentIndex < query.length) {
+ let atIndex = -1;
+ let nextSearchIndex = currentIndex;
+ // Find next unescaped '@'
+ while (nextSearchIndex < query.length) {
+ if (
+ query[nextSearchIndex] === '@' &&
+ (nextSearchIndex === 0 || query[nextSearchIndex - 1] !== '\\')
+ ) {
+ atIndex = nextSearchIndex;
+ break;
+ }
+ nextSearchIndex++;
+ }
- while (pathEndIndex < query.length) {
- const char = query[pathEndIndex];
- if (inEscape) {
- inEscape = false;
- } else if (char === '\\') {
- inEscape = true;
- } else if (/\s/.test(char)) {
+ if (atIndex === -1) {
+ // No more @
+ if (currentIndex < query.length) {
+ parts.push({ type: 'text', content: query.substring(currentIndex) });
+ }
break;
}
- pathEndIndex++;
- }
- const rawAtPath = query.substring(atIndex, pathEndIndex);
- const textAfter = query.substring(pathEndIndex).trim();
- const atPath = unescapePath(rawAtPath);
+ // Add text before @
+ if (atIndex > currentIndex) {
+ parts.push({
+ type: 'text',
+ content: query.substring(currentIndex, atIndex),
+ });
+ }
- return { textBefore, atPath, textAfter };
+ // Parse @path
+ let pathEndIndex = atIndex + 1;
+ let inEscape = false;
+ while (pathEndIndex < query.length) {
+ const char = query[pathEndIndex];
+ if (inEscape) {
+ inEscape = false;
+ } else if (char === '\\') {
+ inEscape = true;
+ } else if (/\s/.test(char)) {
+ // Path ends at first whitespace not escaped
+ break;
+ }
+ pathEndIndex++;
+ }
+ const rawAtPath = query.substring(atIndex, pathEndIndex);
+ // unescapePath expects the @ symbol to be present, and will handle it.
+ const atPath = unescapePath(rawAtPath);
+ parts.push({ type: 'atPath', content: atPath });
+ currentIndex = pathEndIndex;
+ }
+ // Filter out empty text parts that might result from consecutive @paths or leading/trailing spaces
+ return parts.filter(
+ (part) => !(part.type === 'text' && part.content.trim() === ''),
+ );
}
/**
- * Processes user input potentially containing an '@<path>' command.
- * If found, it attempts to read the specified file/directory using the
- * 'read_many_files' tool, adds the user query and tool result/error to history,
- * and prepares the content for the LLM.
+ * Processes user input potentially containing one or more '@<path>' commands.
+ * If found, it attempts to read the specified files/directories using the
+ * 'read_many_files' tool. The user query is modified to include resolved paths,
+ * and the content of the files is appended in a structured block.
*
* @returns An object indicating whether the main hook should proceed with an
* LLM call and the processed query parts (including file content).
@@ -93,42 +122,25 @@ export async function handleAtCommand({
messageId: userMessageTimestamp,
signal,
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
- const trimmedQuery = query.trim();
- const parsedCommand = parseAtCommand(trimmedQuery);
+ const commandParts = parseAllAtCommands(query);
+ const atPathCommandParts = commandParts.filter(
+ (part) => part.type === 'atPath',
+ );
- // If no @ command, add user query normally and proceed to LLM
- if (!parsedCommand) {
+ if (atPathCommandParts.length === 0) {
addItem({ type: 'user', text: query }, userMessageTimestamp);
return { processedQuery: [{ text: query }], shouldProceed: true };
}
- const { textBefore, atPath, textAfter } = parsedCommand;
-
- // Add the original user query to history first
addItem({ type: 'user', text: query }, userMessageTimestamp);
- // If the atPath is just "@", pass the original query to the LLM
- if (atPath === '@') {
- onDebugMessage('Lone @ detected, passing directly to LLM.');
- return { processedQuery: [{ text: query }], shouldProceed: true };
- }
-
- const pathPart = atPath.substring(1); // Remove leading '@'
-
- // This error condition is for cases where pathPart becomes empty *after* the initial "@" check,
- // which is unlikely with the current parser but good for robustness.
- if (!pathPart) {
- addItem(
- { type: 'error', text: 'Error: No valid path specified after @ symbol.' },
- userMessageTimestamp,
- );
- return { processedQuery: null, shouldProceed: false };
- }
-
- const contentLabel = pathPart;
+ const pathSpecsToRead: string[] = [];
+ const atPathToResolvedSpecMap = new Map<string, string>();
+ const contentLabelsForDisplay: string[] = [];
const toolRegistry = config.getToolRegistry();
const readManyFilesTool = toolRegistry.getTool('read_many_files');
+ const globTool = toolRegistry.getTool('glob');
if (!readManyFilesTool) {
addItem(
@@ -138,127 +150,237 @@ export async function handleAtCommand({
return { processedQuery: null, shouldProceed: false };
}
- // Determine path spec (file or directory glob)
- let pathSpec = pathPart;
- try {
- const absolutePath = path.resolve(config.getTargetDir(), pathPart);
- const stats = await fs.stat(absolutePath);
- if (stats.isDirectory()) {
- pathSpec = pathPart.endsWith('/') ? `${pathPart}**` : `${pathPart}/**`;
- onDebugMessage(`Path resolved to directory, using glob: ${pathSpec}`);
- } else {
- onDebugMessage(`Path resolved to file: ${pathSpec}`);
- }
- } catch (error) {
- if (isNodeError(error) && error.code === 'ENOENT') {
+ for (const atPathPart of atPathCommandParts) {
+ const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@"
+
+ if (originalAtPath === '@') {
onDebugMessage(
- `Path ${pathPart} not found directly, attempting glob search.`,
+ 'Lone @ detected, will be treated as text in the modified query.',
);
- const globTool = toolRegistry.getTool('glob');
- if (globTool) {
- try {
- const globResult = await globTool.execute(
- {
- pattern: `**/*${pathPart}*`,
- path: config.getTargetDir(), // Ensure glob searches from the root
- },
- signal,
- );
- // Assuming llmContent contains the list of files or a "no files found" message.
- // And that paths are absolute.
- if (
- globResult.llmContent &&
- typeof globResult.llmContent === 'string' &&
- !globResult.llmContent.startsWith('No files found') &&
- !globResult.llmContent.startsWith('Error:')
- ) {
- // Extract the first line after the header
- const lines = globResult.llmContent.split('\n');
- if (lines.length > 1 && lines[1]) {
- const firstMatchAbsolute = lines[1].trim();
- // Convert absolute path from glob to relative path for read_many_files
- pathSpec = path.relative(
- config.getTargetDir(),
- firstMatchAbsolute,
- );
- onDebugMessage(
- `Glob search found ${firstMatchAbsolute}, using relative path: ${pathSpec}`,
- );
+ continue;
+ }
+
+ const pathName = originalAtPath.substring(1);
+ if (!pathName) {
+ // This case should ideally not be hit if parseAllAtCommands ensures content after @
+ // but as a safeguard:
+ addItem(
+ {
+ type: 'error',
+ text: `Error: Invalid @ command '${originalAtPath}'. No path specified.`,
+ },
+ userMessageTimestamp,
+ );
+ // Decide if this is a fatal error for the whole command or just skip this @ part
+ // For now, let's be strict and fail the command if one @path is malformed.
+ return { processedQuery: null, shouldProceed: false };
+ }
+
+ let currentPathSpec = pathName;
+ let resolvedSuccessfully = false;
+
+ try {
+ const absolutePath = path.resolve(config.getTargetDir(), pathName);
+ const stats = await fs.stat(absolutePath);
+ if (stats.isDirectory()) {
+ currentPathSpec = pathName.endsWith('/')
+ ? `${pathName}**`
+ : `${pathName}/**`;
+ onDebugMessage(
+ `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
+ );
+ } else {
+ onDebugMessage(`Path ${pathName} resolved to file: ${currentPathSpec}`);
+ }
+ resolvedSuccessfully = true;
+ } catch (error) {
+ if (isNodeError(error) && error.code === 'ENOENT') {
+ onDebugMessage(
+ `Path ${pathName} not found directly, attempting glob search.`,
+ );
+ if (globTool) {
+ try {
+ const globResult = await globTool.execute(
+ { pattern: `**/*${pathName}*`, path: config.getTargetDir() },
+ signal,
+ );
+ if (
+ globResult.llmContent &&
+ typeof globResult.llmContent === 'string' &&
+ !globResult.llmContent.startsWith('No files found') &&
+ !globResult.llmContent.startsWith('Error:')
+ ) {
+ const lines = globResult.llmContent.split('\n');
+ if (lines.length > 1 && lines[1]) {
+ const firstMatchAbsolute = lines[1].trim();
+ currentPathSpec = path.relative(
+ config.getTargetDir(),
+ firstMatchAbsolute,
+ );
+ onDebugMessage(
+ `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
+ );
+ resolvedSuccessfully = true;
+ } else {
+ onDebugMessage(
+ `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
+ );
+ }
} else {
onDebugMessage(
- `Glob search for '**/*${pathPart}*' did not return a usable path. Proceeding with original: ${pathPart}`,
+ `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
);
- // pathSpec remains pathPart
}
- } else {
+ } catch (globError) {
+ console.error(
+ `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
+ );
onDebugMessage(
- `Glob search for '**/*${pathPart}*' found no files or an error occurred. Proceeding with original: ${pathPart}`,
+ `Error during glob search for ${pathName}. Path ${pathName} will be skipped.`,
);
- // pathSpec remains pathPart
}
- } catch (globError) {
- console.error(
- `Error during glob search: ${getErrorMessage(globError)}`,
- );
+ } else {
onDebugMessage(
- `Error during glob search. Proceeding with original: ${pathPart}`,
+ `Glob tool not found. Path ${pathName} will be skipped.`,
);
- // pathSpec remains pathPart
}
} else {
+ console.error(
+ `Error stating path ${pathName}: ${getErrorMessage(error)}`,
+ );
onDebugMessage(
- 'Glob tool not found. Proceeding with original path: ${pathPart}',
+ `Error stating path ${pathName}. Path ${pathName} will be skipped.`,
);
- // pathSpec remains pathPart
}
+ }
+
+ if (resolvedSuccessfully) {
+ pathSpecsToRead.push(currentPathSpec);
+ atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec);
+ contentLabelsForDisplay.push(pathName);
+ }
+ }
+
+ // Construct the initial part of the query for the LLM
+ let initialQueryText = '';
+ for (let i = 0; i < commandParts.length; i++) {
+ const part = commandParts[i];
+ if (part.type === 'text') {
+ initialQueryText += part.content;
} else {
- console.error(
- `Error stating path ${pathPart}: ${getErrorMessage(error)}`,
- );
- onDebugMessage(
- `Error stating path, proceeding with original: ${pathSpec}`,
- );
+ // type === 'atPath'
+ const resolvedSpec = atPathToResolvedSpecMap.get(part.content);
+ if (
+ i > 0 &&
+ initialQueryText.length > 0 &&
+ !initialQueryText.endsWith(' ') &&
+ resolvedSpec
+ ) {
+ // Add space if previous part was text and didn't end with space, or if previous was @path
+ const prevPart = commandParts[i - 1];
+ if (
+ prevPart.type === 'text' ||
+ (prevPart.type === 'atPath' &&
+ atPathToResolvedSpecMap.has(prevPart.content))
+ ) {
+ initialQueryText += ' ';
+ }
+ }
+ if (resolvedSpec) {
+ initialQueryText += `@${resolvedSpec}`;
+ } else {
+ // If not resolved for reading (e.g. lone @ or invalid path that was skipped),
+ // add the original @-string back, ensuring spacing if it's not the first element.
+ if (
+ i > 0 &&
+ initialQueryText.length > 0 &&
+ !initialQueryText.endsWith(' ') &&
+ !part.content.startsWith(' ')
+ ) {
+ initialQueryText += ' ';
+ }
+ initialQueryText += part.content;
+ }
}
}
+ initialQueryText = initialQueryText.trim();
- const toolArgs = { paths: [pathSpec] };
+ // Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
+ if (pathSpecsToRead.length === 0) {
+ onDebugMessage('No valid file paths found in @ commands to read.');
+ if (initialQueryText === '@' && query.trim() === '@') {
+ // If the only thing was a lone @, pass original query (which might have spaces)
+ return { processedQuery: [{ text: query }], shouldProceed: true };
+ } else if (!initialQueryText && query) {
+ // If all @-commands were invalid and no surrounding text, pass original query
+ return { processedQuery: [{ text: query }], shouldProceed: true };
+ }
+ // Otherwise, proceed with the (potentially modified) query text that doesn't involve file reading
+ return {
+ processedQuery: [{ text: initialQueryText || query }],
+ shouldProceed: true,
+ };
+ }
+
+ const processedQueryParts: PartUnion[] = [{ text: initialQueryText }];
+
+ const toolArgs = { paths: pathSpecsToRead };
let toolCallDisplay: IndividualToolCallDisplay;
try {
const result = await readManyFilesTool.execute(toolArgs, signal);
-
toolCallDisplay = {
callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName,
description: readManyFilesTool.getDescription(toolArgs),
status: ToolCallStatus.Success,
- resultDisplay: result.returnDisplay,
+ resultDisplay:
+ result.returnDisplay ||
+ `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
confirmationDetails: undefined,
};
- // Prepare the query parts for the LLM
- const processedQueryParts: PartUnion[] = [];
- if (textBefore) {
- processedQueryParts.push({ text: textBefore });
- }
-
- // Process the result from the tool
- processedQueryParts.push('\n--- Content from: ${contentLabel} ---\n');
- if (Array.isArray(result.llmContent)) {
- for (const part of result.llmContent) {
- processedQueryParts.push(part);
+ if (
+ result.llmContent &&
+ typeof result.llmContent === 'string' &&
+ result.llmContent.trim() !== ''
+ ) {
+ processedQueryParts.push({
+ text: '\n--- Content from referenced files ---',
+ });
+ const fileContentRegex =
+ /\n--- (.*?) ---\n([\s\S]*?)(?=\n--- .*? ---\n|$)/g;
+ let match;
+ const foundContentForSpecs = new Set<string>();
+ while ((match = fileContentRegex.exec(result.llmContent)) !== null) {
+ const filePathSpecInContent = match[1]; // This is a resolved pathSpec
+ const fileActualContent = match[2].trim();
+ if (pathSpecsToRead.includes(filePathSpecInContent)) {
+ // Ensure we only add content for paths we requested
+ processedQueryParts.push({
+ text: `\nContent from @${filePathSpecInContent}:\n`,
+ });
+ processedQueryParts.push({ text: fileActualContent });
+ foundContentForSpecs.add(filePathSpecInContent);
+ }
+ }
+ // Check if any requested pathSpecs didn't yield content in the parsed block, could indicate an issue.
+ for (const requestedSpec of pathSpecsToRead) {
+ if (!foundContentForSpecs.has(requestedSpec)) {
+ onDebugMessage(
+ `Content for @${requestedSpec} was expected but not found in read_many_files output.`,
+ );
+ // Optionally add a note about missing content for this spec
+ // processedQueryParts.push({ text: `\nContent for @${requestedSpec} not found or empty.\n` });
+ }
}
+ processedQueryParts.push({ text: '\n--- End of content ---' });
} else {
- processedQueryParts.push(result.llmContent);
- }
- processedQueryParts.push('\n--- End of content ---\n');
-
- if (textAfter) {
- processedQueryParts.push({ text: textAfter });
+ onDebugMessage(
+ 'read_many_files tool returned no content or empty content.',
+ );
}
- const processedQuery: PartListUnion = processedQueryParts;
- // Add the successful tool result to history
addItem(
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
HistoryItem,
@@ -266,20 +388,16 @@ export async function handleAtCommand({
>,
userMessageTimestamp,
);
-
- return { processedQuery, shouldProceed: true };
+ return { processedQuery: processedQueryParts, shouldProceed: true };
} catch (error: unknown) {
- // Handle errors during tool execution
toolCallDisplay = {
callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName,
description: readManyFilesTool.getDescription(toolArgs),
status: ToolCallStatus.Error,
- resultDisplay: `Error reading ${contentLabel}: ${getErrorMessage(error)}`,
+ resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
confirmationDetails: undefined,
};
-
- // Add the error tool result to history
addItem(
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
HistoryItem,
@@ -287,7 +405,6 @@ export async function handleAtCommand({
>,
userMessageTimestamp,
);
-
return { processedQuery: null, shouldProceed: false };
}
}