Run `npm run format`

- Also updated README.md accordingly.

Part of https://b.corp.google.com/issues/411384603
This commit is contained in:
Taylor Mullen 2025-04-17 18:06:21 -04:00 committed by N. Taylor Mullen
parent 7928c1727f
commit cfc697a96d
45 changed files with 4373 additions and 3332 deletions

View File

@ -51,3 +51,13 @@ To debug the CLI application using VS Code:
2. In VS Code, use the "Attach" launch configuration (found in `.vscode/launch.json`). This configuration is set up to attach to the Node.js process listening on port 9229, which is the default port used by `--inspect-brk`. 2. In VS Code, use the "Attach" launch configuration (found in `.vscode/launch.json`). This configuration is set up to attach to the Node.js process listening on port 9229, which is the default port used by `--inspect-brk`.
Alternatively, you can use the "Launch Program" configuration in VS Code if you prefer to launch the currently open file directly, but the "Attach" method is generally recommended for debugging the main CLI entry point. Alternatively, you can use the "Launch Program" configuration in VS Code if you prefer to launch the currently open file directly, but the "Attach" method is generally recommended for debugging the main CLI entry point.
## Formatting
To format the code in this project, run the following command from the root directory:
```bash
npm run format
```
This command uses Prettier to format the code according to the project's style guidelines.

View File

@ -95,7 +95,11 @@ export default tseslint.config(
'@typescript-eslint/no-namespace': ['error', { allowDeclarations: true }], '@typescript-eslint/no-namespace': ['error', { allowDeclarations: true }],
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'warn', 'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
], ],
'no-cond-assign': 'error', 'no-cond-assign': 'error',
'no-debugger': 'error', 'no-debugger': 'error',
@ -108,12 +112,14 @@ export default tseslint.config(
}, },
{ {
selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])',
message: 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', message:
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
}, },
], ],
'no-unsafe-finally': 'error', 'no-unsafe-finally': 'error',
'no-unused-expressions': 'off', // Disable base rule 'no-unused-expressions': 'off', // Disable base rule
'@typescript-eslint/no-unused-expressions': [ // Enable TS version '@typescript-eslint/no-unused-expressions': [
// Enable TS version
'error', 'error',
{ allowShortCircuit: true, allowTernary: true }, { allowShortCircuit: true, allowTernary: true },
], ],

View File

@ -24,7 +24,7 @@ export async function parseArguments(): Promise<CliArgs> {
// Handle warnings for extra arguments here // Handle warnings for extra arguments here
if (argv._ && argv._.length > 0) { if (argv._ && argv._.length > 0) {
console.warn( console.warn(
`Warning: Additional arguments provided (${argv._.join(', ')}), but will be ignored.` `Warning: Additional arguments provided (${argv._.join(', ')}), but will be ignored.`,
); );
} }

View File

@ -31,7 +31,9 @@ export function loadEnvironment(): void {
dotenv.config({ path: envFilePath }); dotenv.config({ path: envFilePath });
if (!process.env.GEMINI_API_KEY) { if (!process.env.GEMINI_API_KEY) {
console.error('Error: GEMINI_API_KEY environment variable is not set in the loaded .env file.'); console.error(
'Error: GEMINI_API_KEY environment variable is not set in the loaded .env file.',
);
process.exit(1); process.exit(1);
} }
} }
@ -40,7 +42,9 @@ export function getApiKey(): string {
loadEnvironment(); loadEnvironment();
const apiKey = process.env.GEMINI_API_KEY; const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) { if (!apiKey) {
throw new Error('GEMINI_API_KEY is missing. Ensure loadEnvironment() was called successfully.'); throw new Error(
'GEMINI_API_KEY is missing. Ensure loadEnvironment() was called successfully.',
);
} }
return apiKey; return apiKey;
} }

View File

@ -1,13 +1,20 @@
import { import {
GenerateContentConfig, GoogleGenAI, Part, Chat, GenerateContentConfig,
GoogleGenAI,
Part,
Chat,
Type, Type,
SchemaUnion, SchemaUnion,
PartListUnion, PartListUnion,
Content Content,
} from '@google/genai'; } from '@google/genai';
import { getApiKey } from '../config/env.js'; import { getApiKey } from '../config/env.js';
import { CoreSystemPrompt } from './prompts.js'; import { CoreSystemPrompt } from './prompts.js';
import { type ToolCallEvent, type ToolCallConfirmationDetails, ToolCallStatus } from '../ui/types.js'; import {
type ToolCallEvent,
type ToolCallConfirmationDetails,
ToolCallStatus,
} from '../ui/types.js';
import process from 'node:process'; import process from 'node:process';
import { toolRegistry } from '../tools/tool-registry.js'; import { toolRegistry } from '../tools/tool-registry.js';
import { ToolResult } from '../tools/tools.js'; import { ToolResult } from '../tools/tools.js';
@ -41,8 +48,12 @@ export class GeminiClient {
// --- Get environmental information --- // --- Get environmental information ---
const cwd = process.cwd(); const cwd = process.cwd();
const today = new Date().toLocaleDateString(undefined, { // Use locale-aware date formatting const today = new Date().toLocaleDateString(undefined, {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' // Use locale-aware date formatting
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}); });
const platform = process.platform; const platform = process.platform;
@ -62,7 +73,7 @@ ${folderStructure}
try { try {
const chat = this.ai.chats.create({ const chat = this.ai.chats.create({
model: 'gemini-2.0-flash',//'gemini-2.0-flash', model: 'gemini-2.0-flash', //'gemini-2.0-flash',
config: { config: {
systemInstruction: CoreSystemPrompt, systemInstruction: CoreSystemPrompt,
...this.defaultHyperParameters, ...this.defaultHyperParameters,
@ -71,21 +82,21 @@ ${folderStructure}
history: [ history: [
// --- Add the context as a single part in the initial user message --- // --- Add the context as a single part in the initial user message ---
{ {
role: "user", role: 'user',
parts: [initialContextPart] // Pass the single Part object in an array parts: [initialContextPart], // Pass the single Part object in an array
}, },
// --- Add an empty model response to balance the history --- // --- Add an empty model response to balance the history ---
{ {
role: "model", role: 'model',
parts: [{ text: "Got it. Thanks for the context!" }] // A slightly more conversational model response parts: [{ text: 'Got it. Thanks for the context!' }], // A slightly more conversational model response
} },
// --- End history modification --- // --- End history modification ---
], ],
}); });
return chat; return chat;
} catch (error) { } catch (error) {
console.error("Error initializing Gemini chat session:", error); console.error('Error initializing Gemini chat session:', error);
const message = error instanceof Error ? error.message : "Unknown error."; const message = error instanceof Error ? error.message : 'Unknown error.';
throw new Error(`Failed to initialize chat: ${message}`); throw new Error(`Failed to initialize chat: ${message}`);
} }
} }
@ -93,14 +104,14 @@ ${folderStructure}
public addMessageToHistory(chat: Chat, message: Content): void { public addMessageToHistory(chat: Chat, message: Content): void {
const history = chat.getHistory(); const history = chat.getHistory();
history.push(message); history.push(message);
this.ai.chats this.ai.chats;
chat chat;
} }
public async* sendMessageStream( public async *sendMessageStream(
chat: Chat, chat: Chat,
request: PartListUnion, request: PartListUnion,
signal?: AbortSignal signal?: AbortSignal,
): GeminiStream { ): GeminiStream {
let currentMessageToSend: PartListUnion = request; let currentMessageToSend: PartListUnion = request;
let turns = 0; let turns = 0;
@ -108,16 +119,24 @@ ${folderStructure}
try { try {
while (turns < this.MAX_TURNS) { while (turns < this.MAX_TURNS) {
turns++; turns++;
const resultStream = await chat.sendMessageStream({ message: currentMessageToSend }); const resultStream = await chat.sendMessageStream({
message: currentMessageToSend,
});
let functionResponseParts: Part[] = []; let functionResponseParts: Part[] = [];
let pendingToolCalls: Array<{ callId: string; name: string; args: Record<string, any> }> = []; let pendingToolCalls: Array<{
callId: string;
name: string;
args: Record<string, any>;
}> = [];
let yieldedTextInTurn = false; let yieldedTextInTurn = false;
const chunksForDebug = []; const chunksForDebug = [];
for await (const chunk of resultStream) { for await (const chunk of resultStream) {
chunksForDebug.push(chunk); chunksForDebug.push(chunk);
if (signal?.aborted) { if (signal?.aborted) {
const abortError = new Error("Request cancelled by user during stream."); const abortError = new Error(
'Request cancelled by user during stream.',
);
abortError.name = 'AbortError'; abortError.name = 'AbortError';
throw abortError; throw abortError;
} }
@ -125,7 +144,9 @@ ${folderStructure}
const functionCalls = chunk.functionCalls; const functionCalls = chunk.functionCalls;
if (functionCalls && functionCalls.length > 0) { if (functionCalls && functionCalls.length > 0) {
for (const call of functionCalls) { for (const call of functionCalls) {
const callId = call.id ?? `${call.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`; const callId =
call.id ??
`${call.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const name = call.name || 'undefined_tool_name'; const name = call.name || 'undefined_tool_name';
const args = (call.args || {}) as Record<string, any>; const args = (call.args || {}) as Record<string, any>;
@ -138,7 +159,7 @@ ${folderStructure}
args, args,
resultDisplay: undefined, resultDisplay: undefined,
confirmationDetails: undefined, confirmationDetails: undefined,
} };
yield { yield {
type: GeminiEventType.ToolCallInfo, type: GeminiEventType.ToolCallInfo,
value: evtValue, value: evtValue,
@ -157,34 +178,55 @@ ${folderStructure}
} }
if (pendingToolCalls.length > 0) { if (pendingToolCalls.length > 0) {
const toolPromises: Promise<ToolExecutionOutcome>[] = pendingToolCalls.map(async pendingToolCall => { const toolPromises: Promise<ToolExecutionOutcome>[] =
pendingToolCalls.map(async (pendingToolCall) => {
const tool = toolRegistry.getTool(pendingToolCall.name); const tool = toolRegistry.getTool(pendingToolCall.name);
if (!tool) { if (!tool) {
// Directly return error outcome if tool not found // Directly return error outcome if tool not found
return { ...pendingToolCall, error: new Error(`Tool "${pendingToolCall.name}" not found or is not registered.`) }; return {
...pendingToolCall,
error: new Error(
`Tool "${pendingToolCall.name}" not found or is not registered.`,
),
};
} }
try { try {
const confirmation = await tool.shouldConfirmExecute(pendingToolCall.args); const confirmation = await tool.shouldConfirmExecute(
pendingToolCall.args,
);
if (confirmation) { if (confirmation) {
return { ...pendingToolCall, confirmationDetails: confirmation }; return {
...pendingToolCall,
confirmationDetails: confirmation,
};
} }
} catch (error) { } catch (error) {
return { ...pendingToolCall, error: new Error(`Tool failed to check tool confirmation: ${error}`) }; return {
...pendingToolCall,
error: new Error(
`Tool failed to check tool confirmation: ${error}`,
),
};
} }
try { try {
const result = await tool.execute(pendingToolCall.args); const result = await tool.execute(pendingToolCall.args);
return { ...pendingToolCall, result }; return { ...pendingToolCall, result };
} catch (error) { } catch (error) {
return { ...pendingToolCall, error: new Error(`Tool failed to execute: ${error}`) }; return {
...pendingToolCall,
error: new Error(`Tool failed to execute: ${error}`),
};
} }
}); });
const toolExecutionOutcomes: ToolExecutionOutcome[] = await Promise.all(toolPromises); const toolExecutionOutcomes: ToolExecutionOutcome[] =
await Promise.all(toolPromises);
for (const executedTool of toolExecutionOutcomes) { for (const executedTool of toolExecutionOutcomes) {
const { callId, name, args, result, error, confirmationDetails } = executedTool; const { callId, name, args, result, error, confirmationDetails } =
executedTool;
if (error) { if (error) {
const errorMessage = error?.message || String(error); const errorMessage = error?.message || String(error);
@ -192,15 +234,30 @@ ${folderStructure}
type: GeminiEventType.Content, type: GeminiEventType.Content,
value: `[Error invoking tool ${name}: ${errorMessage}]`, value: `[Error invoking tool ${name}: ${errorMessage}]`,
}; };
} else if (result && typeof result === 'object' && result !== null && 'error' in result) { } else if (
result &&
typeof result === 'object' &&
result !== null &&
'error' in result
) {
const errorMessage = String(result.error); const errorMessage = String(result.error);
yield { yield {
type: GeminiEventType.Content, type: GeminiEventType.Content,
value: `[Error executing tool ${name}: ${errorMessage}]`, value: `[Error executing tool ${name}: ${errorMessage}]`,
}; };
} else { } else {
const status = confirmationDetails ? ToolCallStatus.Confirming : ToolCallStatus.Invoked; const status = confirmationDetails
const evtValue: ToolCallEvent = { type: 'tool_call', status, callId, name, args, resultDisplay: result?.returnDisplay, confirmationDetails } ? ToolCallStatus.Confirming
: ToolCallStatus.Invoked;
const evtValue: ToolCallEvent = {
type: 'tool_call',
status,
callId,
name,
args,
resultDisplay: result?.returnDisplay,
confirmationDetails,
};
yield { yield {
type: GeminiEventType.ToolCallInfo, type: GeminiEventType.ToolCallInfo,
value: evtValue, value: evtValue,
@ -210,25 +267,42 @@ ${folderStructure}
pendingToolCalls = []; pendingToolCalls = [];
const waitingOnConfirmations = toolExecutionOutcomes.filter(outcome => outcome.confirmationDetails).length > 0; const waitingOnConfirmations =
toolExecutionOutcomes.filter(
(outcome) => outcome.confirmationDetails,
).length > 0;
if (waitingOnConfirmations) { if (waitingOnConfirmations) {
// Stop processing content, wait for user. // Stop processing content, wait for user.
// TODO: Kill token processing once API supports signals. // TODO: Kill token processing once API supports signals.
break; break;
} }
functionResponseParts = toolExecutionOutcomes.map((executedTool: ToolExecutionOutcome): Part => { functionResponseParts = toolExecutionOutcomes.map(
(executedTool: ToolExecutionOutcome): Part => {
const { name, result, error } = executedTool; const { name, result, error } = executedTool;
const output = { "output": result?.llmContent }; const output = { output: result?.llmContent };
let toolOutcomePayload: any; let toolOutcomePayload: any;
if (error) { if (error) {
const errorMessage = error?.message || String(error); const errorMessage = error?.message || String(error);
toolOutcomePayload = { error: `Invocation failed: ${errorMessage}` }; toolOutcomePayload = {
console.error(`[Turn ${turns}] Critical error invoking tool ${name}:`, error); error: `Invocation failed: ${errorMessage}`,
} else if (result && typeof result === 'object' && result !== null && 'error' in result) { };
console.error(
`[Turn ${turns}] Critical error invoking tool ${name}:`,
error,
);
} else if (
result &&
typeof result === 'object' &&
result !== null &&
'error' in result
) {
toolOutcomePayload = output; toolOutcomePayload = output;
console.warn(`[Turn ${turns}] Tool ${name} returned an error structure:`, result.error); console.warn(
`[Turn ${turns}] Tool ${name} returned an error structure:`,
result.error,
);
} else { } else {
toolOutcomePayload = output; toolOutcomePayload = output;
} }
@ -240,7 +314,8 @@ ${folderStructure}
response: toolOutcomePayload, response: toolOutcomePayload,
}, },
}; };
}); },
);
currentMessageToSend = functionResponseParts; currentMessageToSend = functionResponseParts;
} else if (yieldedTextInTurn) { } else if (yieldedTextInTurn) {
const history = chat.getHistory(); const history = chat.getHistory();
@ -280,23 +355,37 @@ Respond *only* in JSON format according to the following schema. Do not include
properties: { properties: {
reasoning: { reasoning: {
type: Type.STRING, type: Type.STRING,
description: "Brief explanation justifying the 'next_speaker' choice based *strictly* on the applicable rule and the content/structure of the preceding turn." description:
"Brief explanation justifying the 'next_speaker' choice based *strictly* on the applicable rule and the content/structure of the preceding turn.",
}, },
next_speaker: { next_speaker: {
type: Type.STRING, type: Type.STRING,
enum: ['user', 'model'], // Enforce the choices enum: ['user', 'model'], // Enforce the choices
description: "Who should speak next based *only* on the preceding turn and the decision rules", description:
'Who should speak next based *only* on the preceding turn and the decision rules',
}, },
}, },
required: ['reasoning', 'next_speaker'] required: ['reasoning', 'next_speaker'],
}; };
try { try {
// Use the new generateJson method, passing the history and the check prompt // Use the new generateJson method, passing the history and the check prompt
const parsedResponse = await this.generateJson([...history, { role: "user", parts: [{ text: checkPrompt }] }], responseSchema); const parsedResponse = await this.generateJson(
[
...history,
{
role: 'user',
parts: [{ text: checkPrompt }],
},
],
responseSchema,
);
// Safely extract the next speaker value // Safely extract the next speaker value
const nextSpeaker: string | undefined = typeof parsedResponse?.next_speaker === 'string' ? parsedResponse.next_speaker : undefined; const nextSpeaker: string | undefined =
typeof parsedResponse?.next_speaker === 'string'
? parsedResponse.next_speaker
: undefined;
if (nextSpeaker === 'model') { if (nextSpeaker === 'model') {
currentMessageToSend = { text: 'alright' }; // Or potentially a more meaningful continuation prompt currentMessageToSend = { text: 'alright' }; // Or potentially a more meaningful continuation prompt
@ -304,30 +393,35 @@ Respond *only* in JSON format according to the following schema. Do not include
// 'user' should speak next, or value is missing/invalid. End the turn. // 'user' should speak next, or value is missing/invalid. End the turn.
break; break;
} }
} catch (error) { } catch (error) {
console.error(`[Turn ${turns}] Failed to get or parse next speaker check:`, error); console.error(
`[Turn ${turns}] Failed to get or parse next speaker check:`,
error,
);
// If the check fails, assume user should speak next to avoid infinite loops // If the check fails, assume user should speak next to avoid infinite loops
break; break;
} }
} else { } else {
console.warn(`[Turn ${turns}] No text or function calls received from Gemini. Ending interaction.`); console.warn(
`[Turn ${turns}] No text or function calls received from Gemini. Ending interaction.`,
);
break; break;
} }
} }
if (turns >= this.MAX_TURNS) { if (turns >= this.MAX_TURNS) {
console.warn("sendMessageStream: Reached maximum tool call turns limit."); console.warn(
'sendMessageStream: Reached maximum tool call turns limit.',
);
yield { yield {
type: GeminiEventType.Content, type: GeminiEventType.Content,
value: "\n\n[System Notice: Maximum interaction turns reached. The conversation may be incomplete.]", value:
'\n\n[System Notice: Maximum interaction turns reached. The conversation may be incomplete.]',
}; };
} }
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
console.log("Gemini stream request aborted by user."); console.log('Gemini stream request aborted by user.');
throw error; throw error;
} else { } else {
console.error(`Error during Gemini stream or tool interaction:`, error); console.error(`Error during Gemini stream or tool interaction:`, error);
@ -348,7 +442,10 @@ Respond *only* in JSON format according to the following schema. Do not include
* @returns A promise that resolves to the parsed JSON object matching the schema. * @returns A promise that resolves to the parsed JSON object matching the schema.
* @throws Throws an error if the API call fails or the response is not valid JSON. * @throws Throws an error if the API call fails or the response is not valid JSON.
*/ */
public async generateJson(contents: Content[], schema: SchemaUnion): Promise<any> { public async generateJson(
contents: Content[],
schema: SchemaUnion,
): Promise<any> {
try { try {
const result = await this.ai.models.generateContent({ const result = await this.ai.models.generateContent({
model: 'gemini-2.0-flash', // Using flash for potentially faster structured output model: 'gemini-2.0-flash', // Using flash for potentially faster structured output
@ -363,7 +460,7 @@ Respond *only* in JSON format according to the following schema. Do not include
const responseText = result.text; const responseText = result.text;
if (!responseText) { if (!responseText) {
throw new Error("API returned an empty response."); throw new Error('API returned an empty response.');
} }
try { try {
@ -371,12 +468,15 @@ Respond *only* in JSON format according to the following schema. Do not include
// TODO: Add schema validation if needed // TODO: Add schema validation if needed
return parsedJson; return parsedJson;
} catch (parseError) { } catch (parseError) {
console.error("Failed to parse JSON response:", responseText); console.error('Failed to parse JSON response:', responseText);
throw new Error(`Failed to parse API response as JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`); throw new Error(
`Failed to parse API response as JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
);
} }
} catch (error) { } catch (error) {
console.error("Error generating JSON content:", error); console.error('Error generating JSON content:', error);
const message = error instanceof Error ? error.message : "Unknown API error."; const message =
error instanceof Error ? error.message : 'Unknown API error.';
throw new Error(`Failed to generate JSON content: ${message}`); throw new Error(`Failed to generate JSON content: ${message}`);
} }
} }

View File

@ -1,7 +1,10 @@
import { ToolCallEvent } from "../ui/types.js"; import { ToolCallEvent } from '../ui/types.js';
import { Part } from '@google/genai'; import { Part } from '@google/genai';
import { HistoryItem } from '../ui/types.js'; import { HistoryItem } from '../ui/types.js';
import { handleToolCallChunk, addErrorMessageToHistory } from './history-updater.js'; import {
handleToolCallChunk,
addErrorMessageToHistory,
} from './history-updater.js';
export enum GeminiEventType { export enum GeminiEventType {
Content, Content,
@ -18,9 +21,7 @@ export interface GeminiToolCallInfoEvent {
value: ToolCallEvent; value: ToolCallEvent;
} }
export type GeminiEvent = export type GeminiEvent = GeminiContentEvent | GeminiToolCallInfoEvent;
| GeminiContentEvent
| GeminiToolCallInfoEvent;
export type GeminiStream = AsyncIterable<GeminiEvent>; export type GeminiStream = AsyncIterable<GeminiEvent>;
@ -33,7 +34,7 @@ interface StreamProcessorParams {
stream: GeminiStream; stream: GeminiStream;
signal: AbortSignal; signal: AbortSignal;
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>; setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>;
submitQuery: (query: Part) => Promise<void>, submitQuery: (query: Part) => Promise<void>;
getNextMessageId: () => number; getNextMessageId: () => number;
addHistoryItem: (itemData: Omit<HistoryItem, 'id'>, id: number) => void; addHistoryItem: (itemData: Omit<HistoryItem, 'id'>, id: number) => void;
currentToolGroupIdRef: React.MutableRefObject<number | null>; currentToolGroupIdRef: React.MutableRefObject<number | null>;
@ -43,7 +44,8 @@ interface StreamProcessorParams {
* Processes the Gemini stream, managing text buffering, adaptive rendering, * Processes the Gemini stream, managing text buffering, adaptive rendering,
* and delegating history updates for tool calls and errors. * and delegating history updates for tool calls and errors.
*/ */
export const processGeminiStream = async ({ // Renamed function for clarity export const processGeminiStream = async ({
// Renamed function for clarity
stream, stream,
signal, signal,
setHistory, setHistory,
@ -62,12 +64,14 @@ export const processGeminiStream = async ({ // Renamed function for clarity
if (currentGeminiMessageId === null) { if (currentGeminiMessageId === null) {
return; return;
} }
setHistory(prev => prev.map(item => setHistory((prev) =>
prev.map((item) =>
item.id === currentGeminiMessageId && item.type === 'gemini' item.id === currentGeminiMessageId && item.type === 'gemini'
? { ...item, text: (item.text ?? '') + content } ? { ...item, text: (item.text ?? '') + content }
: item : item,
)); ),
} );
};
// --- Adaptive Rendering Logic (nested) --- // --- Adaptive Rendering Logic (nested) ---
const renderBufferedText = () => { const renderBufferedText = () => {
if (signal.aborted) { if (signal.aborted) {
@ -81,11 +85,14 @@ export const processGeminiStream = async ({ // Renamed function for clarity
let delay = 50; let delay = 50;
if (bufferLength > 150) { if (bufferLength > 150) {
chunkSize = Math.min(bufferLength, 30); delay = 5; chunkSize = Math.min(bufferLength, 30);
delay = 5;
} else if (bufferLength > 30) { } else if (bufferLength > 30) {
chunkSize = Math.min(bufferLength, 10); delay = 10; chunkSize = Math.min(bufferLength, 10);
delay = 10;
} else if (bufferLength > 0) { } else if (bufferLength > 0) {
chunkSize = 2; delay = 20; chunkSize = 2;
delay = 20;
} }
if (chunkSize > 0) { if (chunkSize > 0) {
@ -124,9 +131,9 @@ export const processGeminiStream = async ({ // Renamed function for clarity
} }
textBuffer += chunk.value; textBuffer += chunk.value;
scheduleRender(); scheduleRender();
} else if (chunk.type === GeminiEventType.ToolCallInfo) { } else if (chunk.type === GeminiEventType.ToolCallInfo) {
if (renderTimeoutId) { // Stop rendering loop if (renderTimeoutId) {
// Stop rendering loop
clearTimeout(renderTimeoutId); clearTimeout(renderTimeoutId);
renderTimeoutId = null; renderTimeoutId = null;
} }
@ -141,15 +148,16 @@ export const processGeminiStream = async ({ // Renamed function for clarity
setHistory, setHistory,
submitQuery, submitQuery,
getNextMessageId, getNextMessageId,
currentToolGroupIdRef currentToolGroupIdRef,
); );
} }
} }
if (signal.aborted) { if (signal.aborted) {
throw new Error("Request cancelled by user"); throw new Error('Request cancelled by user');
} }
} catch (error: any) { } catch (error: any) {
if (renderTimeoutId) { // Ensure render loop stops on error if (renderTimeoutId) {
// Ensure render loop stops on error
clearTimeout(renderTimeoutId); clearTimeout(renderTimeoutId);
renderTimeoutId = null; renderTimeoutId = null;
} }

View File

@ -1,7 +1,15 @@
import { Part } from "@google/genai"; import { Part } from '@google/genai';
import { toolRegistry } from "../tools/tool-registry.js"; import { toolRegistry } from '../tools/tool-registry.js';
import { HistoryItem, IndividualToolCallDisplay, ToolCallEvent, ToolCallStatus, ToolConfirmationOutcome, ToolEditConfirmationDetails, ToolExecuteConfirmationDetails } from "../ui/types.js"; import {
import { ToolResultDisplay } from "../tools/tools.js"; HistoryItem,
IndividualToolCallDisplay,
ToolCallEvent,
ToolCallStatus,
ToolConfirmationOutcome,
ToolEditConfirmationDetails,
ToolExecuteConfirmationDetails,
} from '../ui/types.js';
import { ToolResultDisplay } from '../tools/tools.js';
/** /**
* Processes a tool call chunk and updates the history state accordingly. * Processes a tool call chunk and updates the history state accordingly.
@ -13,7 +21,7 @@ export const handleToolCallChunk = (
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>, setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
submitQuery: (query: Part) => Promise<void>, submitQuery: (query: Part) => Promise<void>,
getNextMessageId: () => number, getNextMessageId: () => number,
currentToolGroupIdRef: React.MutableRefObject<number | null> currentToolGroupIdRef: React.MutableRefObject<number | null>,
): void => { ): void => {
const toolDefinition = toolRegistry.getTool(chunk.name); const toolDefinition = toolRegistry.getTool(chunk.name);
const description = toolDefinition?.getDescription const description = toolDefinition?.getDescription
@ -29,36 +37,75 @@ export const handleToolCallChunk = (
if (outcome === ToolConfirmationOutcome.Cancel) { if (outcome === ToolConfirmationOutcome.Cancel) {
let resultDisplay: ToolResultDisplay | undefined; let resultDisplay: ToolResultDisplay | undefined;
if ('fileDiff' in originalConfirmationDetails) { if ('fileDiff' in originalConfirmationDetails) {
resultDisplay = { fileDiff: (originalConfirmationDetails as ToolEditConfirmationDetails).fileDiff }; resultDisplay = {
fileDiff: (
originalConfirmationDetails as ToolEditConfirmationDetails
).fileDiff,
};
} else { } else {
resultDisplay = `~~${(originalConfirmationDetails as ToolExecuteConfirmationDetails).command}~~`; resultDisplay = `~~${(originalConfirmationDetails as ToolExecuteConfirmationDetails).command}~~`;
} }
handleToolCallChunk({ ...chunk, status: ToolCallStatus.Canceled, confirmationDetails: undefined, resultDisplay, }, setHistory, submitQuery, getNextMessageId, currentToolGroupIdRef); handleToolCallChunk(
{
...chunk,
status: ToolCallStatus.Canceled,
confirmationDetails: undefined,
resultDisplay,
},
setHistory,
submitQuery,
getNextMessageId,
currentToolGroupIdRef,
);
const functionResponse: Part = { const functionResponse: Part = {
functionResponse: { functionResponse: {
name: chunk.name, name: chunk.name,
response: { "error": "User rejected function call." }, response: { error: 'User rejected function call.' },
}, },
} };
await submitQuery(functionResponse); await submitQuery(functionResponse);
} else { } else {
const tool = toolRegistry.getTool(chunk.name) const tool = toolRegistry.getTool(chunk.name);
if (!tool) { if (!tool) {
throw new Error(`Tool "${chunk.name}" not found or is not registered.`); throw new Error(
`Tool "${chunk.name}" not found or is not registered.`,
);
} }
handleToolCallChunk({ ...chunk, status: ToolCallStatus.Invoked, resultDisplay: "Executing...", confirmationDetails: undefined }, setHistory, submitQuery, getNextMessageId, currentToolGroupIdRef); handleToolCallChunk(
{
...chunk,
status: ToolCallStatus.Invoked,
resultDisplay: 'Executing...',
confirmationDetails: undefined,
},
setHistory,
submitQuery,
getNextMessageId,
currentToolGroupIdRef,
);
const result = await tool.execute(chunk.args); const result = await tool.execute(chunk.args);
handleToolCallChunk({ ...chunk, status: ToolCallStatus.Invoked, resultDisplay: result.returnDisplay, confirmationDetails: undefined }, setHistory, submitQuery, getNextMessageId, currentToolGroupIdRef); handleToolCallChunk(
{
...chunk,
status: ToolCallStatus.Invoked,
resultDisplay: result.returnDisplay,
confirmationDetails: undefined,
},
setHistory,
submitQuery,
getNextMessageId,
currentToolGroupIdRef,
);
const functionResponse: Part = { const functionResponse: Part = {
functionResponse: { functionResponse: {
name: chunk.name, name: chunk.name,
id: chunk.callId, id: chunk.callId,
response: { "output": result.llmContent }, response: { output: result.llmContent },
}, },
} };
await submitQuery(functionResponse); await submitQuery(functionResponse);
} }
} };
confirmationDetails = { confirmationDetails = {
...originalConfirmationDetails, ...originalConfirmationDetails,
@ -75,7 +122,7 @@ export const handleToolCallChunk = (
}; };
const activeGroupId = currentToolGroupIdRef.current; const activeGroupId = currentToolGroupIdRef.current;
setHistory(prev => { setHistory((prev) => {
if (chunk.status === ToolCallStatus.Pending) { if (chunk.status === ToolCallStatus.Pending) {
if (activeGroupId === null) { if (activeGroupId === null) {
// Start a new tool group // Start a new tool group
@ -83,38 +130,45 @@ export const handleToolCallChunk = (
currentToolGroupIdRef.current = newGroupId; currentToolGroupIdRef.current = newGroupId;
return [ return [
...prev, ...prev,
{ id: newGroupId, type: 'tool_group', tools: [toolDetail] } as HistoryItem {
id: newGroupId,
type: 'tool_group',
tools: [toolDetail],
} as HistoryItem,
]; ];
} }
// Add to existing tool group // Add to existing tool group
return prev.map(item => return prev.map((item) =>
item.id === activeGroupId && item.type === 'tool_group' item.id === activeGroupId && item.type === 'tool_group'
? item.tools.some(t => t.callId === toolDetail.callId) ? item.tools.some((t) => t.callId === toolDetail.callId)
? item // Tool already listed as pending ? item // Tool already listed as pending
: { ...item, tools: [...item.tools, toolDetail] } : { ...item, tools: [...item.tools, toolDetail] }
: item : item,
); );
} }
// Update the status of a pending tool within the active group // Update the status of a pending tool within the active group
if (activeGroupId === null) { if (activeGroupId === null) {
// Log if an invoked tool arrives without an active group context // Log if an invoked tool arrives without an active group context
console.warn("Received invoked tool status without an active tool group ID:", chunk); console.warn(
'Received invoked tool status without an active tool group ID:',
chunk,
);
return prev; return prev;
} }
return prev.map(item => return prev.map((item) =>
item.id === activeGroupId && item.type === 'tool_group' item.id === activeGroupId && item.type === 'tool_group'
? { ? {
...item, ...item,
tools: item.tools.map(t => tools: item.tools.map((t) =>
t.callId === toolDetail.callId t.callId === toolDetail.callId
? { ...t, ...toolDetail, status: chunk.status } // Update details & status ? { ...t, ...toolDetail, status: chunk.status } // Update details & status
: t : t,
) ),
} }
: item : item,
); );
}); });
}; };
@ -126,7 +180,7 @@ export const handleToolCallChunk = (
export const addErrorMessageToHistory = ( export const addErrorMessageToHistory = (
error: any, error: any,
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>, setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
getNextMessageId: () => number getNextMessageId: () => number,
): void => { ): void => {
const isAbort = error.name === 'AbortError'; const isAbort = error.name === 'AbortError';
const errorType = isAbort ? 'info' : 'error'; const errorType = isAbort ? 'info' : 'error';
@ -134,11 +188,14 @@ export const addErrorMessageToHistory = (
? '[Request cancelled by user]' ? '[Request cancelled by user]'
: `[Error: ${error.message || 'Unknown error'}]`; : `[Error: ${error.message || 'Unknown error'}]`;
setHistory(prev => { setHistory((prev) => {
const reversedHistory = [...prev].reverse(); const reversedHistory = [...prev].reverse();
// Find the last message that isn't from the user to append the error/info to // Find the last message that isn't from the user to append the error/info to
const lastBotMessageIndex = reversedHistory.findIndex(item => item.type !== 'user'); const lastBotMessageIndex = reversedHistory.findIndex(
const originalIndex = lastBotMessageIndex !== -1 ? prev.length - 1 - lastBotMessageIndex : -1; (item) => item.type !== 'user',
);
const originalIndex =
lastBotMessageIndex !== -1 ? prev.length - 1 - lastBotMessageIndex : -1;
if (originalIndex !== -1) { if (originalIndex !== -1) {
// Append error to the last relevant message // Append error to the last relevant message
@ -147,11 +204,17 @@ export const addErrorMessageToHistory = (
let baseText = ''; let baseText = '';
// Determine base text based on item type // Determine base text based on item type
if (item.type === 'gemini') baseText = item.text ?? ''; if (item.type === 'gemini') baseText = item.text ?? '';
else if (item.type === 'tool_group') baseText = `Tool execution (${item.tools.length} calls)`; else if (item.type === 'tool_group')
else if (item.type === 'error' || item.type === 'info') baseText = item.text ?? ''; baseText = `Tool execution (${item.tools.length} calls)`;
else if (item.type === 'error' || item.type === 'info')
baseText = item.text ?? '';
// Safely handle potential undefined text // Safely handle potential undefined text
const updatedText = (baseText + (baseText && !baseText.endsWith('\n') ? '\n' : '') + errorText).trim(); const updatedText = (
baseText +
(baseText && !baseText.endsWith('\n') ? '\n' : '') +
errorText
).trim();
// Reuse existing ID, update type and text // Reuse existing ID, update type and text
return { ...item, type: errorType, text: updatedText }; return { ...item, type: errorType, text: updatedText };
} }
@ -161,7 +224,11 @@ export const addErrorMessageToHistory = (
// No previous message to append to, add a new error item // No previous message to append to, add a new error item
return [ return [
...prev, ...prev,
{ id: getNextMessageId(), type: errorType, text: errorText } as HistoryItem {
id: getNextMessageId(),
type: errorType,
text: errorText,
} as HistoryItem,
]; ];
} }
}); });

View File

@ -1,5 +1,5 @@
import { ReadFileTool } from "../tools/read-file.tool.js"; import { ReadFileTool } from '../tools/read-file.tool.js';
import { TerminalTool } from "../tools/terminal.tool.js"; import { TerminalTool } from '../tools/terminal.tool.js';
const MEMORY_FILE_NAME = 'GEMINI.md'; const MEMORY_FILE_NAME = 'GEMINI.md';

View File

@ -40,7 +40,9 @@ process.on('unhandledRejection', (reason, promise) => {
if (isKnownEscaped429) { if (isKnownEscaped429) {
// Log it differently and DON'T exit, as it's likely already handled visually // Log it differently and DON'T exit, as it's likely already handled visually
console.warn('-----------------------------------------'); console.warn('-----------------------------------------');
console.warn('WORKAROUND: Suppressed known escaped 429 Unhandled Rejection.'); console.warn(
'WORKAROUND: Suppressed known escaped 429 Unhandled Rejection.',
);
console.warn('-----------------------------------------'); console.warn('-----------------------------------------');
console.warn('Reason:', reason); console.warn('Reason:', reason);
// No process.exit(1); // No process.exit(1);
@ -87,4 +89,3 @@ function registerTools(targetDir: string) {
toolRegistry.registerTool(terminalTool); toolRegistry.registerTool(terminalTool);
toolRegistry.registerTool(writeFileTool); toolRegistry.registerTool(writeFileTool);
} }

View File

@ -3,7 +3,11 @@ import path from 'path';
import * as Diff from 'diff'; import * as Diff from 'diff';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
import { BaseTool, ToolResult } from './tools.js'; import { BaseTool, ToolResult } from './tools.js';
import { ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolEditConfirmationDetails } from '../ui/types.js'; import {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolEditConfirmationDetails,
} from '../ui/types.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
import { ReadFileTool } from './read-file.tool.js'; import { ReadFileTool } from './read-file.tool.js';
import { WriteFileTool } from './write-file.tool.js'; import { WriteFileTool } from './write-file.tool.js';
@ -36,14 +40,13 @@ export interface EditToolParams {
/** /**
* Result from the Edit tool * Result from the Edit tool
*/ */
export interface EditToolResult extends ToolResult { export interface EditToolResult extends ToolResult {}
}
interface CalculatedEdit { interface CalculatedEdit {
currentContent: string | null; currentContent: string | null;
newContent: string; newContent: string;
occurrences: number; occurrences: number;
error?: { display: string, raw: string }; error?: { display: string; raw: string };
isNewFile: boolean; isNewFile: boolean;
} }
@ -67,21 +70,24 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
{ {
properties: { properties: {
file_path: { file_path: {
description: 'The absolute path to the file to modify. Must start with /. When creating a new file, ensure the parent directory exists (use the `LS` tool to verify).', description:
type: 'string' 'The absolute path to the file to modify. Must start with /. When creating a new file, ensure the parent directory exists (use the `LS` tool to verify).',
type: 'string',
}, },
old_string: { old_string: {
description: 'The exact text to replace. CRITICAL: Must uniquely identify the single instance to change. Include at least 3-5 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations or does not match exactly, the tool will fail. Use an empty string ("") when creating a new file.', description:
type: 'string' 'The exact text to replace. CRITICAL: Must uniquely identify the single instance to change. Include at least 3-5 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations or does not match exactly, the tool will fail. Use an empty string ("") when creating a new file.',
type: 'string',
}, },
new_string: { new_string: {
description: 'The text to replace the `old_string` with. When creating a new file (using an empty `old_string`), this should contain the full desired content of the new file. Ensure the resulting code is correct and idiomatic.', description:
type: 'string' 'The text to replace the `old_string` with. When creating a new file (using an empty `old_string`), this should contain the full desired content of the new file. Ensure the resulting code is correct and idiomatic.',
} type: 'string',
},
}, },
required: ['file_path', 'old_string', 'new_string'], required: ['file_path', 'old_string', 'new_string'],
type: 'object' type: 'object',
} },
); );
this.rootDirectory = path.resolve(rootDirectory); this.rootDirectory = path.resolve(rootDirectory);
} }
@ -99,7 +105,10 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
? normalizedRoot ? normalizedRoot
: normalizedRoot + path.sep; : normalizedRoot + path.sep;
return normalizedPath === normalizedRoot || normalizedPath.startsWith(rootWithSep); return (
normalizedPath === normalizedRoot ||
normalizedPath.startsWith(rootWithSep)
);
} }
/** /**
@ -108,7 +117,13 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
* @returns True if parameters are valid, false otherwise * @returns True if parameters are valid, false otherwise
*/ */
validateParams(params: EditToolParams): boolean { validateParams(params: EditToolParams): boolean {
if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) { if (
this.schema.parameters &&
!SchemaValidator.validate(
this.schema.parameters as Record<string, unknown>,
params,
)
) {
return false; return false;
} }
@ -120,13 +135,17 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
// Ensure path is within the root directory // Ensure path is within the root directory
if (!this.isWithinRoot(params.file_path)) { if (!this.isWithinRoot(params.file_path)) {
console.error(`File path must be within the root directory (${this.rootDirectory}): ${params.file_path}`); console.error(
`File path must be within the root directory (${this.rootDirectory}): ${params.file_path}`,
);
return false; return false;
} }
// Validate expected_replacements if provided // Validate expected_replacements if provided
if (params.expected_replacements !== undefined && params.expected_replacements < 0) { if (
params.expected_replacements !== undefined &&
params.expected_replacements < 0
) {
console.error('Expected replacements must be a non-negative number'); console.error('Expected replacements must be a non-negative number');
return false; return false;
} }
@ -141,13 +160,16 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
* @throws File system errors if reading the file fails unexpectedly (e.g., permissions) * @throws File system errors if reading the file fails unexpectedly (e.g., permissions)
*/ */
private calculateEdit(params: EditToolParams): CalculatedEdit { private calculateEdit(params: EditToolParams): CalculatedEdit {
const expectedReplacements = params.expected_replacements === undefined ? 1 : params.expected_replacements; const expectedReplacements =
params.expected_replacements === undefined
? 1
: params.expected_replacements;
let currentContent: string | null = null; let currentContent: string | null = null;
let fileExists = false; let fileExists = false;
let isNewFile = false; let isNewFile = false;
let newContent = ''; let newContent = '';
let occurrences = 0; let occurrences = 0;
let error: { display: string, raw: string } | undefined = undefined; let error: { display: string; raw: string } | undefined = undefined;
try { try {
currentContent = fs.readFileSync(params.file_path, 'utf8'); currentContent = fs.readFileSync(params.file_path, 'utf8');
@ -166,7 +188,7 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
} else if (!fileExists) { } else if (!fileExists) {
error = { error = {
display: `File not found.`, display: `File not found.`,
raw: `File not found: ${params.file_path}` raw: `File not found: ${params.file_path}`,
}; };
} else if (currentContent !== null) { } else if (currentContent !== null) {
occurrences = this.countOccurrences(currentContent, params.old_string); occurrences = this.countOccurrences(currentContent, params.old_string);
@ -174,21 +196,25 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
if (occurrences === 0) { if (occurrences === 0) {
error = { error = {
display: `No edits made`, display: `No edits made`,
raw: `Failed to edit, 0 occurrences found` raw: `Failed to edit, 0 occurrences found`,
} };
} else if (occurrences !== expectedReplacements) { } else if (occurrences !== expectedReplacements) {
error = { error = {
display: `Failed to edit, expected ${expectedReplacements} occurrences but found ${occurrences}`, display: `Failed to edit, expected ${expectedReplacements} occurrences but found ${occurrences}`,
raw: `Failed to edit, Expected ${expectedReplacements} occurrences but found ${occurrences} in file: ${params.file_path}` raw: `Failed to edit, Expected ${expectedReplacements} occurrences but found ${occurrences} in file: ${params.file_path}`,
} };
} else { } else {
newContent = this.replaceAll(currentContent, params.old_string, params.new_string); newContent = this.replaceAll(
currentContent,
params.old_string,
params.new_string,
);
} }
} else { } else {
error = { error = {
display: `Failed to read content`, display: `Failed to read content`,
raw: `Failed to read content of existing file: ${params.file_path}` raw: `Failed to read content of existing file: ${params.file_path}`,
} };
} }
return { return {
@ -196,7 +222,7 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
newContent, newContent,
occurrences, occurrences,
error, error,
isNewFile isNewFile,
}; };
} }
@ -206,13 +232,17 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
* @param params Parameters for the potential edit operation * @param params Parameters for the potential edit operation
* @returns Confirmation details object or false if no confirmation is needed/possible. * @returns Confirmation details object or false if no confirmation is needed/possible.
*/ */
async shouldConfirmExecute(params: EditToolParams): Promise<ToolCallConfirmationDetails | false> { async shouldConfirmExecute(
params: EditToolParams,
): Promise<ToolCallConfirmationDetails | false> {
if (this.shouldAlwaysEdit) { if (this.shouldAlwaysEdit) {
return false; return false;
} }
if (!this.validateParams(params)) { if (!this.validateParams(params)) {
console.error("[EditTool] Attempted confirmation with invalid parameters."); console.error(
'[EditTool] Attempted confirmation with invalid parameters.',
);
return false; return false;
} }
@ -220,7 +250,9 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
try { try {
calculatedEdit = this.calculateEdit(params); calculatedEdit = this.calculateEdit(params);
} catch (error) { } catch (error) {
console.error(`Error calculating edit for confirmation: ${error instanceof Error ? error.message : String(error)}`); console.error(
`Error calculating edit for confirmation: ${error instanceof Error ? error.message : String(error)}`,
);
return false; return false;
} }
@ -235,7 +267,7 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
calculatedEdit.newContent, calculatedEdit.newContent,
'Current', 'Current',
'Proposed', 'Proposed',
{ context: 3, ignoreWhitespace: true, } { context: 3, ignoreWhitespace: true },
); );
const confirmationDetails: ToolEditConfirmationDetails = { const confirmationDetails: ToolEditConfirmationDetails = {
@ -253,8 +285,12 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
getDescription(params: EditToolParams): string { getDescription(params: EditToolParams): string {
const relativePath = makeRelative(params.file_path, this.rootDirectory); const relativePath = makeRelative(params.file_path, this.rootDirectory);
const oldStringSnippet = params.old_string.split('\n')[0].substring(0, 30) + (params.old_string.length > 30 ? '...' : ''); const oldStringSnippet =
const newStringSnippet = params.new_string.split('\n')[0].substring(0, 30) + (params.new_string.length > 30 ? '...' : ''); params.old_string.split('\n')[0].substring(0, 30) +
(params.old_string.length > 30 ? '...' : '');
const newStringSnippet =
params.new_string.split('\n')[0].substring(0, 30) +
(params.new_string.length > 30 ? '...' : '');
return `${shortenPath(relativePath)}: ${oldStringSnippet} => ${newStringSnippet}`; return `${shortenPath(relativePath)}: ${oldStringSnippet} => ${newStringSnippet}`;
} }
@ -268,7 +304,7 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
if (!this.validateParams(params)) { if (!this.validateParams(params)) {
return { return {
llmContent: 'Invalid parameters for file edit operation', llmContent: 'Invalid parameters for file edit operation',
returnDisplay: '**Error:** Invalid parameters for file edit operation' returnDisplay: '**Error:** Invalid parameters for file edit operation',
}; };
} }
@ -278,14 +314,14 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
} catch (error) { } catch (error) {
return { return {
llmContent: `Error preparing edit: ${error instanceof Error ? error.message : String(error)}`, llmContent: `Error preparing edit: ${error instanceof Error ? error.message : String(error)}`,
returnDisplay: 'Failed to prepare edit' returnDisplay: 'Failed to prepare edit',
}; };
} }
if (editData.error) { if (editData.error) {
return { return {
llmContent: editData.error.raw, llmContent: editData.error.raw,
returnDisplay: editData.error.display returnDisplay: editData.error.display,
}; };
} }
@ -296,7 +332,7 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
if (editData.isNewFile) { if (editData.isNewFile) {
return { return {
llmContent: `Created new file: ${params.file_path} with provided content.`, llmContent: `Created new file: ${params.file_path} with provided content.`,
returnDisplay: `Created ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}` returnDisplay: `Created ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`,
}; };
} else { } else {
const fileName = path.basename(params.file_path); const fileName = path.basename(params.file_path);
@ -306,18 +342,18 @@ export class EditTool extends BaseTool<EditToolParams, EditToolResult> {
editData.newContent, editData.newContent,
'Current', 'Current',
'Proposed', 'Proposed',
{ context: 3, ignoreWhitespace: true } { context: 3, ignoreWhitespace: true },
); );
return { return {
llmContent: `Successfully modified file: ${params.file_path} (${editData.occurrences} replacements).`, llmContent: `Successfully modified file: ${params.file_path} (${editData.occurrences} replacements).`,
returnDisplay: { fileDiff } returnDisplay: { fileDiff },
}; };
} }
} catch (error) { } catch (error) {
return { return {
llmContent: `Error executing edit: ${error instanceof Error ? error.message : String(error)}`, llmContent: `Error executing edit: ${error instanceof Error ? error.message : String(error)}`,
returnDisplay: `Failed to edit file` returnDisplay: `Failed to edit file`,
}; };
} }
} }

View File

@ -23,8 +23,7 @@ export interface GlobToolParams {
/** /**
* Result from the GlobTool * Result from the GlobTool
*/ */
export interface GlobToolResult extends ToolResult { export interface GlobToolResult extends ToolResult {}
}
/** /**
* Implementation of the GlobTool that finds files matching patterns, * Implementation of the GlobTool that finds files matching patterns,
@ -49,17 +48,19 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
{ {
properties: { properties: {
pattern: { pattern: {
description: 'The glob pattern to match against (e.g., \'*.py\', \'src/**/*.js\', \'docs/*.md\').', description:
type: 'string' "The glob pattern to match against (e.g., '*.py', 'src/**/*.js', 'docs/*.md').",
type: 'string',
}, },
path: { path: {
description: 'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.', description:
type: 'string' 'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
} type: 'string',
},
}, },
required: ['pattern'], required: ['pattern'],
type: 'object' type: 'object',
} },
); );
// Set the root directory // Set the root directory
@ -84,7 +85,10 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
// Check if it's the root itself or starts with the root path followed by a separator. // Check if it's the root itself or starts with the root path followed by a separator.
// This ensures that we don't accidentally allow access to parent directories. // This ensures that we don't accidentally allow access to parent directories.
return normalizedPath === normalizedRoot || normalizedPath.startsWith(rootWithSep); return (
normalizedPath === normalizedRoot ||
normalizedPath.startsWith(rootWithSep)
);
} }
/** /**
@ -94,7 +98,13 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
* @returns An error message string if invalid, null otherwise * @returns An error message string if invalid, null otherwise
*/ */
invalidParams(params: GlobToolParams): string | null { invalidParams(params: GlobToolParams): string | null {
if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) { if (
this.schema.parameters &&
!SchemaValidator.validate(
this.schema.parameters as Record<string, unknown>,
params,
)
) {
return "Parameters failed schema validation. Ensure 'pattern' is a string and 'path' (if provided) is a string."; return "Parameters failed schema validation. Ensure 'pattern' is a string and 'path' (if provided) is a string.";
} }
@ -121,7 +131,11 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
} }
// Validate glob pattern (basic non-empty check) // Validate glob pattern (basic non-empty check)
if (!params.pattern || typeof params.pattern !== 'string' || params.pattern.trim() === '') { if (
!params.pattern ||
typeof params.pattern !== 'string' ||
params.pattern.trim() === ''
) {
return "The 'pattern' parameter cannot be empty."; return "The 'pattern' parameter cannot be empty.";
} }
// Could add more sophisticated glob pattern validation if needed // Could add more sophisticated glob pattern validation if needed
@ -156,7 +170,7 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
if (validationError) { if (validationError) {
return { return {
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
returnDisplay: `**Error:** Failed to execute tool.` returnDisplay: `**Error:** Failed to execute tool.`,
}; };
} }
@ -181,7 +195,7 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
if (!entries || entries.length === 0) { if (!entries || entries.length === 0) {
return { return {
llmContent: `No files found matching pattern "${params.pattern}" within ${searchDirAbsolute}.`, llmContent: `No files found matching pattern "${params.pattern}" within ${searchDirAbsolute}.`,
returnDisplay: `No files found` returnDisplay: `No files found`,
}; };
} }
@ -197,29 +211,38 @@ export class GlobTool extends BaseTool<GlobToolParams, GlobToolResult> {
}); });
// 5. Format Output // 5. Format Output
const sortedAbsolutePaths = entries.map(entry => entry.path); const sortedAbsolutePaths = entries.map((entry) => entry.path);
// Convert absolute paths to relative paths (to rootDir) for clearer display // Convert absolute paths to relative paths (to rootDir) for clearer display
const sortedRelativePaths = sortedAbsolutePaths.map(absPath => makeRelative(absPath, this.rootDirectory)); const sortedRelativePaths = sortedAbsolutePaths.map((absPath) =>
makeRelative(absPath, this.rootDirectory),
);
// Construct the result message // Construct the result message
const fileListDescription = sortedRelativePaths.map(p => ` - ${shortenPath(p)}`).join('\n'); const fileListDescription = sortedRelativePaths
.map((p) => ` - ${shortenPath(p)}`)
.join('\n');
const fileCount = sortedRelativePaths.length; const fileCount = sortedRelativePaths.length;
const relativeSearchDir = makeRelative(searchDirAbsolute, this.rootDirectory); const relativeSearchDir = makeRelative(
const displayPath = shortenPath(relativeSearchDir === '.' ? 'root directory' : relativeSearchDir); searchDirAbsolute,
this.rootDirectory,
);
const displayPath = shortenPath(
relativeSearchDir === '.' ? 'root directory' : relativeSearchDir,
);
return { return {
llmContent: `Found ${fileCount} file(s) matching "${params.pattern}" within ${displayPath}, sorted by modification time (newest first):\n${fileListDescription}`, llmContent: `Found ${fileCount} file(s) matching "${params.pattern}" within ${displayPath}, sorted by modification time (newest first):\n${fileListDescription}`,
returnDisplay: `Found ${fileCount} matching file(s)` returnDisplay: `Found ${fileCount} matching file(s)`,
}; };
} catch (error) { } catch (error) {
// Catch unexpected errors during glob execution (less likely with suppressErrors=true, but possible) // Catch unexpected errors during glob execution (less likely with suppressErrors=true, but possible)
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`GlobTool execute Error: ${errorMessage}`, error); console.error(`GlobTool execute Error: ${errorMessage}`, error);
return { return {
llmContent: `Error during glob search operation: ${errorMessage}`, llmContent: `Error during glob search operation: ${errorMessage}`,
returnDisplay: `**Error:** An unexpected error occurred.` returnDisplay: `**Error:** An unexpected error occurred.`,
}; };
} }
} }

View File

@ -42,8 +42,7 @@ interface GrepMatch {
/** /**
* Result from the GrepTool * Result from the GrepTool
*/ */
export interface GrepToolResult extends ToolResult { export interface GrepToolResult extends ToolResult {}
}
// --- GrepTool Class --- // --- GrepTool Class ---
@ -65,21 +64,24 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
{ {
properties: { properties: {
pattern: { pattern: {
description: 'The regular expression (regex) pattern to search for within file contents (e.g., \'function\\s+myFunction\', \'import\\s+\\{.*\\}\\s+from\\s+.*\').', description:
type: 'string' "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
type: 'string',
}, },
path: { path: {
description: 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.', description:
type: 'string' 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
type: 'string',
}, },
include: { include: {
description: 'Optional: A glob pattern to filter which files are searched (e.g., \'*.js\', \'*.{ts,tsx}\', \'src/**\'). If omitted, searches all files (respecting potential global ignores).', description:
type: 'string' "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
} type: 'string',
},
}, },
required: ['pattern'], required: ['pattern'],
type: 'object' type: 'object',
} },
); );
// Ensure rootDirectory is absolute and normalized // Ensure rootDirectory is absolute and normalized
this.rootDirectory = path.resolve(rootDirectory); this.rootDirectory = path.resolve(rootDirectory);
@ -97,8 +99,13 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
const targetPath = path.resolve(this.rootDirectory, relativePath || '.'); const targetPath = path.resolve(this.rootDirectory, relativePath || '.');
// Security Check: Ensure the resolved path is still within the root directory. // Security Check: Ensure the resolved path is still within the root directory.
if (!targetPath.startsWith(this.rootDirectory) && targetPath !== this.rootDirectory) { if (
throw new Error(`Path validation failed: Attempted path "${relativePath || '.'}" resolves outside the allowed root directory "${this.rootDirectory}".`); !targetPath.startsWith(this.rootDirectory) &&
targetPath !== this.rootDirectory
) {
throw new Error(
`Path validation failed: Attempted path "${relativePath || '.'}" resolves outside the allowed root directory "${this.rootDirectory}".`,
);
} }
// Check existence and type after resolving // Check existence and type after resolving
@ -111,7 +118,9 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
if (err.code === 'ENOENT') { if (err.code === 'ENOENT') {
throw new Error(`Path does not exist: ${targetPath}`); throw new Error(`Path does not exist: ${targetPath}`);
} }
throw new Error(`Failed to access path stats for ${targetPath}: ${err.message}`); throw new Error(
`Failed to access path stats for ${targetPath}: ${err.message}`,
);
} }
return targetPath; return targetPath;
@ -123,8 +132,14 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
* @returns An error message string if invalid, null otherwise * @returns An error message string if invalid, null otherwise
*/ */
invalidParams(params: GrepToolParams): string | null { invalidParams(params: GrepToolParams): string | null {
if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) { if (
return "Parameters failed schema validation."; this.schema.parameters &&
!SchemaValidator.validate(
this.schema.parameters as Record<string, unknown>,
params,
)
) {
return 'Parameters failed schema validation.';
} }
try { try {
@ -142,7 +157,6 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
return null; // Parameters are valid return null; // Parameters are valid
} }
// --- Core Execution --- // --- Core Execution ---
/** /**
@ -156,7 +170,7 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
console.error(`GrepTool Parameter Validation Failed: ${validationError}`); console.error(`GrepTool Parameter Validation Failed: ${validationError}`);
return { return {
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
returnDisplay: `**Error:** Failed to execute tool.` returnDisplay: `**Error:** Failed to execute tool.`,
}; };
} }
@ -177,40 +191,49 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
return { llmContent: noMatchMsg, returnDisplay: noMatchUser }; return { llmContent: noMatchMsg, returnDisplay: noMatchUser };
} }
const matchesByFile = matches.reduce((acc, match) => { const matchesByFile = matches.reduce(
const relativeFilePath = path.relative(searchDirAbs, path.resolve(searchDirAbs, match.filePath)) || path.basename(match.filePath); (acc, match) => {
const relativeFilePath =
path.relative(
searchDirAbs,
path.resolve(searchDirAbs, match.filePath),
) || path.basename(match.filePath);
if (!acc[relativeFilePath]) { if (!acc[relativeFilePath]) {
acc[relativeFilePath] = []; acc[relativeFilePath] = [];
} }
acc[relativeFilePath].push(match); acc[relativeFilePath].push(match);
acc[relativeFilePath].sort((a, b) => a.lineNumber - b.lineNumber); acc[relativeFilePath].sort((a, b) => a.lineNumber - b.lineNumber);
return acc; return acc;
}, {} as Record<string, GrepMatch[]>); },
{} as Record<string, GrepMatch[]>,
);
let llmContent = `Found ${matches.length} match(es) for pattern "${params.pattern}" in path "${searchDirDisplay}"${params.include ? ` (filter: "${params.include}")` : ''}:\n---\n`; let llmContent = `Found ${matches.length} match(es) for pattern "${params.pattern}" in path "${searchDirDisplay}"${params.include ? ` (filter: "${params.include}")` : ''}:\n---\n`;
for (const filePath in matchesByFile) { for (const filePath in matchesByFile) {
llmContent += `File: ${filePath}\n`; llmContent += `File: ${filePath}\n`;
matchesByFile[filePath].forEach(match => { matchesByFile[filePath].forEach((match) => {
const trimmedLine = match.line.trim(); const trimmedLine = match.line.trim();
llmContent += `L${match.lineNumber}: ${trimmedLine}\n`; llmContent += `L${match.lineNumber}: ${trimmedLine}\n`;
}); });
llmContent += '---\n'; llmContent += '---\n';
} }
return { llmContent: llmContent.trim(), returnDisplay: `Found ${matches.length} matche(s)` }; return {
llmContent: llmContent.trim(),
returnDisplay: `Found ${matches.length} matche(s)`,
};
} catch (error) { } catch (error) {
console.error(`Error during GrepTool execution: ${error}`); console.error(`Error during GrepTool execution: ${error}`);
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage =
error instanceof Error ? error.message : String(error);
return { return {
llmContent: `Error during grep search operation: ${errorMessage}`, llmContent: `Error during grep search operation: ${errorMessage}`,
returnDisplay: errorMessage returnDisplay: errorMessage,
}; };
} }
} }
// --- Inlined Grep Logic and Helpers --- // --- Inlined Grep Logic and Helpers ---
/** /**
@ -221,9 +244,13 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
private isCommandAvailable(command: string): Promise<boolean> { private isCommandAvailable(command: string): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const checkCommand = process.platform === 'win32' ? 'where' : 'command'; const checkCommand = process.platform === 'win32' ? 'where' : 'command';
const checkArgs = process.platform === 'win32' ? [command] : ['-v', command]; const checkArgs =
process.platform === 'win32' ? [command] : ['-v', command];
try { try {
const child = spawn(checkCommand, checkArgs, { stdio: 'ignore', shell: process.platform === 'win32' }); const child = spawn(checkCommand, checkArgs, {
stdio: 'ignore',
shell: process.platform === 'win32',
});
child.on('close', (code) => resolve(code === 0)); child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false)); child.on('error', () => resolve(false));
} catch (e) { } catch (e) {
@ -252,7 +279,9 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
return false; return false;
} catch (err: any) { } catch (err: any) {
if (err.code !== 'ENOENT') { if (err.code !== 'ENOENT') {
console.error(`Error checking for .git in ${currentPath}: ${err.message}`); console.error(
`Error checking for .git in ${currentPath}: ${err.message}`,
);
return false; return false;
} }
} }
@ -263,7 +292,9 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
currentPath = path.dirname(currentPath); currentPath = path.dirname(currentPath);
} }
} catch (err: any) { } catch (err: any) {
console.error(`Error traversing directory structure upwards from ${dirPath}: ${err instanceof Error ? err.message : String(err)}`); console.error(
`Error traversing directory structure upwards from ${dirPath}: ${err instanceof Error ? err.message : String(err)}`,
);
} }
return false; return false;
} }
@ -302,7 +333,10 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
// Extract parts based on the found colon indices // Extract parts based on the found colon indices
const filePathRaw = line.substring(0, firstColonIndex); const filePathRaw = line.substring(0, firstColonIndex);
const lineNumberStr = line.substring(firstColonIndex + 1, secondColonIndex); const lineNumberStr = line.substring(
firstColonIndex + 1,
secondColonIndex,
);
// The rest of the line, starting after the second colon, is the content. // The rest of the line, starting after the second colon, is the content.
const lineContent = line.substring(secondColonIndex + 1); const lineContent = line.substring(secondColonIndex + 1);
@ -363,37 +397,59 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
try { try {
// --- Strategy 1: git grep --- // --- Strategy 1: git grep ---
const isGit = await this.isGitRepository(absolutePath); const isGit = await this.isGitRepository(absolutePath);
const gitAvailable = isGit && await this.isCommandAvailable('git'); const gitAvailable = isGit && (await this.isCommandAvailable('git'));
if (gitAvailable) { if (gitAvailable) {
strategyUsed = 'git grep'; strategyUsed = 'git grep';
const gitArgs = ['grep', '--untracked', '-n', '-E', '--ignore-case', pattern]; const gitArgs = [
'grep',
'--untracked',
'-n',
'-E',
'--ignore-case',
pattern,
];
if (include) { if (include) {
gitArgs.push('--', include); gitArgs.push('--', include);
} }
try { try {
const output = await new Promise<string>((resolve, reject) => { const output = await new Promise<string>((resolve, reject) => {
const child = spawn('git', gitArgs, { cwd: absolutePath, windowsHide: true }); const child = spawn('git', gitArgs, {
cwd: absolutePath,
windowsHide: true,
});
const stdoutChunks: Buffer[] = []; const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = []; const stderrChunks: Buffer[] = [];
child.stdout.on('data', (chunk) => { stdoutChunks.push(chunk); }); child.stdout.on('data', (chunk) => {
child.stderr.on('data', (chunk) => { stderrChunks.push(chunk); }); stdoutChunks.push(chunk);
});
child.stderr.on('data', (chunk) => {
stderrChunks.push(chunk);
});
child.on('error', (err) => reject(new Error(`Failed to start git grep: ${err.message}`))); child.on('error', (err) =>
reject(new Error(`Failed to start git grep: ${err.message}`)),
);
child.on('close', (code) => { child.on('close', (code) => {
const stdoutData = Buffer.concat(stdoutChunks).toString('utf8'); const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
const stderrData = Buffer.concat(stderrChunks).toString('utf8'); const stderrData = Buffer.concat(stderrChunks).toString('utf8');
if (code === 0) resolve(stdoutData); if (code === 0) resolve(stdoutData);
else if (code === 1) resolve(''); // No matches is not an error else if (code === 1)
else reject(new Error(`git grep exited with code ${code}: ${stderrData}`)); resolve(''); // No matches is not an error
else
reject(
new Error(`git grep exited with code ${code}: ${stderrData}`),
);
}); });
}); });
return this.parseGrepOutput(output, absolutePath); return this.parseGrepOutput(output, absolutePath);
} catch (gitError: any) { } catch (gitError: any) {
console.error(`GrepTool: git grep strategy failed: ${gitError.message}. Falling back...`); console.error(
`GrepTool: git grep strategy failed: ${gitError.message}. Falling back...`,
);
} }
} }
@ -403,7 +459,7 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
strategyUsed = 'system grep'; strategyUsed = 'system grep';
const grepArgs = ['-r', '-n', '-H', '-E']; const grepArgs = ['-r', '-n', '-H', '-E'];
const commonExcludes = ['.git', 'node_modules', 'bower_components']; const commonExcludes = ['.git', 'node_modules', 'bower_components'];
commonExcludes.forEach(dir => grepArgs.push(`--exclude-dir=${dir}`)); commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`));
if (include) { if (include) {
grepArgs.push(`--include=${include}`); grepArgs.push(`--include=${include}`);
} }
@ -412,41 +468,67 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
try { try {
const output = await new Promise<string>((resolve, reject) => { const output = await new Promise<string>((resolve, reject) => {
const child = spawn('grep', grepArgs, { cwd: absolutePath, windowsHide: true }); const child = spawn('grep', grepArgs, {
cwd: absolutePath,
windowsHide: true,
});
const stdoutChunks: Buffer[] = []; const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = []; const stderrChunks: Buffer[] = [];
child.stdout.on('data', (chunk) => { stdoutChunks.push(chunk); }); child.stdout.on('data', (chunk) => {
stdoutChunks.push(chunk);
});
child.stderr.on('data', (chunk) => { child.stderr.on('data', (chunk) => {
const stderrStr = chunk.toString(); const stderrStr = chunk.toString();
if (!stderrStr.includes('Permission denied') && !/grep:.*: Is a directory/i.test(stderrStr)) { if (
!stderrStr.includes('Permission denied') &&
!/grep:.*: Is a directory/i.test(stderrStr)
) {
stderrChunks.push(chunk); stderrChunks.push(chunk);
} }
}); });
child.on('error', (err) => reject(new Error(`Failed to start system grep: ${err.message}`))); child.on('error', (err) =>
reject(new Error(`Failed to start system grep: ${err.message}`)),
);
child.on('close', (code) => { child.on('close', (code) => {
const stdoutData = Buffer.concat(stdoutChunks).toString('utf8'); const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
const stderrData = Buffer.concat(stderrChunks).toString('utf8').trim(); const stderrData = Buffer.concat(stderrChunks)
.toString('utf8')
.trim();
if (code === 0) resolve(stdoutData); if (code === 0) resolve(stdoutData);
else if (code === 1) resolve(''); // No matches else if (code === 1)
resolve(''); // No matches
else { else {
if (stderrData) reject(new Error(`System grep exited with code ${code}: ${stderrData}`)); if (stderrData)
reject(
new Error(
`System grep exited with code ${code}: ${stderrData}`,
),
);
else resolve(''); else resolve('');
} }
}); });
}); });
return this.parseGrepOutput(output, absolutePath); return this.parseGrepOutput(output, absolutePath);
} catch (grepError: any) { } catch (grepError: any) {
console.error(`GrepTool: System grep strategy failed: ${grepError.message}. Falling back...`); console.error(
`GrepTool: System grep strategy failed: ${grepError.message}. Falling back...`,
);
} }
} }
// --- Strategy 3: Pure JavaScript Fallback --- // --- Strategy 3: Pure JavaScript Fallback ---
strategyUsed = 'javascript fallback'; strategyUsed = 'javascript fallback';
const globPattern = include ? include : '**/*'; const globPattern = include ? include : '**/*';
const ignorePatterns = ['.git', 'node_modules', 'bower_components', '.svn', '.hg']; const ignorePatterns = [
'.git',
'node_modules',
'bower_components',
'.svn',
'.hg',
];
const filesStream = fastGlob.stream(globPattern, { const filesStream = fastGlob.stream(globPattern, {
cwd: absolutePath, cwd: absolutePath,
@ -469,7 +551,9 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
lines.forEach((line, index) => { lines.forEach((line, index) => {
if (regex.test(line)) { if (regex.test(line)) {
allMatches.push({ allMatches.push({
filePath: path.relative(absolutePath, fileAbsolutePath) || path.basename(fileAbsolutePath), filePath:
path.relative(absolutePath, fileAbsolutePath) ||
path.basename(fileAbsolutePath),
lineNumber: index + 1, lineNumber: index + 1,
line: line, line: line,
}); });
@ -477,15 +561,18 @@ export class GrepTool extends BaseTool<GrepToolParams, GrepToolResult> {
}); });
} catch (readError: any) { } catch (readError: any) {
if (readError.code !== 'ENOENT') { if (readError.code !== 'ENOENT') {
console.error(`GrepTool: Could not read or process file ${fileAbsolutePath}: ${readError.message}`); console.error(
`GrepTool: Could not read or process file ${fileAbsolutePath}: ${readError.message}`,
);
} }
} }
} }
return allMatches; return allMatches;
} catch (error: any) { } catch (error: any) {
console.error(`GrepTool: Error during performGrepSearch (Strategy: ${strategyUsed}): ${error.message}`); console.error(
`GrepTool: Error during performGrepSearch (Strategy: ${strategyUsed}): ${error.message}`,
);
throw error; // Re-throw to be caught by the execute method's handler throw error; // Re-throw to be caught by the execute method's handler
} }
} }

View File

@ -91,20 +91,21 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
{ {
properties: { properties: {
path: { path: {
description: 'The absolute path to the directory to list (must be absolute, not relative)', description:
type: 'string' 'The absolute path to the directory to list (must be absolute, not relative)',
type: 'string',
}, },
ignore: { ignore: {
description: 'List of glob patterns to ignore', description: 'List of glob patterns to ignore',
items: { items: {
type: 'string' type: 'string',
},
type: 'array',
}, },
type: 'array'
}
}, },
required: ['path'], required: ['path'],
type: 'object' type: 'object',
} },
); );
// Set the root directory // Set the root directory
@ -123,7 +124,10 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
const rootWithSep = normalizedRoot.endsWith(path.sep) const rootWithSep = normalizedRoot.endsWith(path.sep)
? normalizedRoot ? normalizedRoot
: normalizedRoot + path.sep; : normalizedRoot + path.sep;
return normalizedPath === normalizedRoot || normalizedPath.startsWith(rootWithSep); return (
normalizedPath === normalizedRoot ||
normalizedPath.startsWith(rootWithSep)
);
} }
/** /**
@ -132,8 +136,14 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
* @returns An error message string if invalid, null otherwise * @returns An error message string if invalid, null otherwise
*/ */
invalidParams(params: LSToolParams): string | null { invalidParams(params: LSToolParams): string | null {
if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) { if (
return "Parameters failed schema validation."; this.schema.parameters &&
!SchemaValidator.validate(
this.schema.parameters as Record<string, unknown>,
params,
)
) {
return 'Parameters failed schema validation.';
} }
// Ensure path is absolute // Ensure path is absolute
if (!path.isAbsolute(params.path)) { if (!path.isAbsolute(params.path)) {
@ -194,7 +204,7 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
listedPath: params.path, listedPath: params.path,
totalEntries: 0, totalEntries: 0,
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
returnDisplay: "**Error:** Failed to execute tool." returnDisplay: '**Error:** Failed to execute tool.',
}; };
} }
@ -206,7 +216,7 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
listedPath: params.path, listedPath: params.path,
totalEntries: 0, totalEntries: 0,
llmContent: `Directory does not exist: ${params.path}`, llmContent: `Directory does not exist: ${params.path}`,
returnDisplay: `Directory does not exist` returnDisplay: `Directory does not exist`,
}; };
} }
// Check if path is a directory // Check if path is a directory
@ -217,7 +227,7 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
listedPath: params.path, listedPath: params.path,
totalEntries: 0, totalEntries: 0,
llmContent: `Path is not a directory: ${params.path}`, llmContent: `Path is not a directory: ${params.path}`,
returnDisplay: `Path is not a directory` returnDisplay: `Path is not a directory`,
}; };
} }
@ -230,7 +240,7 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
listedPath: params.path, listedPath: params.path,
totalEntries: 0, totalEntries: 0,
llmContent: `Directory is empty: ${params.path}`, llmContent: `Directory is empty: ${params.path}`,
returnDisplay: `Directory is empty.` returnDisplay: `Directory is empty.`,
}; };
} }
@ -248,7 +258,7 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
path: fullPath, path: fullPath,
isDirectory: isDir, isDirectory: isDir,
size: isDir ? 0 : stats.size, size: isDir ? 0 : stats.size,
modifiedTime: stats.mtime modifiedTime: stats.mtime,
}); });
} catch (error) { } catch (error) {
// Skip entries that can't be accessed // Skip entries that can't be accessed
@ -264,18 +274,20 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
}); });
// Create formatted content for display // Create formatted content for display
const directoryContent = entries.map(entry => { const directoryContent = entries
.map((entry) => {
const typeIndicator = entry.isDirectory ? 'd' : '-'; const typeIndicator = entry.isDirectory ? 'd' : '-';
const sizeInfo = entry.isDirectory ? '' : ` (${entry.size} bytes)`; const sizeInfo = entry.isDirectory ? '' : ` (${entry.size} bytes)`;
return `${typeIndicator} ${entry.name}${sizeInfo}`; return `${typeIndicator} ${entry.name}${sizeInfo}`;
}).join('\n'); })
.join('\n');
return { return {
entries, entries,
listedPath: params.path, listedPath: params.path,
totalEntries: entries.length, totalEntries: entries.length,
llmContent: `Directory listing for ${params.path}:\n${directoryContent}`, llmContent: `Directory listing for ${params.path}:\n${directoryContent}`,
returnDisplay: `Found ${entries.length} item(s).` returnDisplay: `Found ${entries.length} item(s).`,
}; };
} catch (error) { } catch (error) {
const errorMessage = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`; const errorMessage = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`;
@ -284,7 +296,7 @@ export class LSTool extends BaseTool<LSToolParams, LSToolResult> {
listedPath: params.path, listedPath: params.path,
totalEntries: 0, totalEntries: 0,
llmContent: errorMessage, llmContent: errorMessage,
returnDisplay: `**Error:** ${errorMessage}` returnDisplay: `**Error:** ${errorMessage}`,
}; };
} }
} }

View File

@ -27,13 +27,15 @@ export interface ReadFileToolParams {
/** /**
* Standardized result from the ReadFile tool * Standardized result from the ReadFile tool
*/ */
export interface ReadFileToolResult extends ToolResult { export interface ReadFileToolResult extends ToolResult {}
}
/** /**
* Implementation of the ReadFile tool that reads files from the filesystem * Implementation of the ReadFile tool that reads files from the filesystem
*/ */
export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResult> { export class ReadFileTool extends BaseTool<
ReadFileToolParams,
ReadFileToolResult
> {
public static readonly Name: string = 'read_file'; public static readonly Name: string = 'read_file';
// Maximum number of lines to read by default // Maximum number of lines to read by default
@ -60,21 +62,24 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
{ {
properties: { properties: {
file_path: { file_path: {
description: 'The absolute path to the file to read (e.g., \'/home/user/project/file.txt\'). Relative paths are not supported.', description:
type: 'string' "The absolute path to the file to read (e.g., '/home/user/project/file.txt'). Relative paths are not supported.",
type: 'string',
}, },
offset: { offset: {
description: 'Optional: The 0-based line number to start reading from. Requires \'limit\' to be set. Use for paginating through large files.', description:
type: 'number' "Optional: The 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.",
type: 'number',
}, },
limit: { limit: {
description: 'Optional: Maximum number of lines to read. Use with \'offset\' to paginate through large files. If omitted, reads the entire file (if feasible).', description:
type: 'number' "Optional: Maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible).",
} type: 'number',
},
}, },
required: ['file_path'], required: ['file_path'],
type: 'object' type: 'object',
} },
); );
// Set the root directory // Set the root directory
@ -95,7 +100,10 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
? normalizedRoot ? normalizedRoot
: normalizedRoot + path.sep; : normalizedRoot + path.sep;
return normalizedPath === normalizedRoot || normalizedPath.startsWith(rootWithSep); return (
normalizedPath === normalizedRoot ||
normalizedPath.startsWith(rootWithSep)
);
} }
/** /**
@ -104,8 +112,14 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
* @returns True if parameters are valid, false otherwise * @returns True if parameters are valid, false otherwise
*/ */
invalidParams(params: ReadFileToolParams): string | null { invalidParams(params: ReadFileToolParams): string | null {
if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) { if (
return "Parameters failed schema validation."; this.schema.parameters &&
!SchemaValidator.validate(
this.schema.parameters as Record<string, unknown>,
params,
)
) {
return 'Parameters failed schema validation.';
} }
const filePath = params.file_path; const filePath = params.file_path;
if (!path.isAbsolute(filePath)) { if (!path.isAbsolute(filePath)) {
@ -151,7 +165,7 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
} }
// If more than 30% are non-printable, likely binary // If more than 30% are non-printable, likely binary
return (nonPrintableCount / bytesRead) > 0.3; return nonPrintableCount / bytesRead > 0.3;
} catch (error) { } catch (error) {
return false; return false;
} }
@ -166,7 +180,9 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
// Common image formats // Common image formats
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'].includes(ext)) { if (
['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'].includes(ext)
) {
return 'image'; return 'image';
} }
@ -204,7 +220,7 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
if (validationError) { if (validationError) {
return { return {
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
returnDisplay: "**Error:** Failed to execute tool." returnDisplay: '**Error:** Failed to execute tool.',
}; };
} }
@ -245,14 +261,15 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResul
const formattedLines = selectedLines.map((line) => { const formattedLines = selectedLines.map((line) => {
let processedLine = line; let processedLine = line;
if (line.length > ReadFileTool.MAX_LINE_LENGTH) { if (line.length > ReadFileTool.MAX_LINE_LENGTH) {
processedLine = line.substring(0, ReadFileTool.MAX_LINE_LENGTH) + '... [truncated]'; processedLine =
line.substring(0, ReadFileTool.MAX_LINE_LENGTH) + '... [truncated]';
truncated = true; truncated = true;
} }
return processedLine; return processedLine;
}); });
const contentTruncated = (endLine < lines.length) || truncated; const contentTruncated = endLine < lines.length || truncated;
let llmContent = ''; let llmContent = '';
if (contentTruncated) { if (contentTruncated) {

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,9 @@ class ToolRegistry {
registerTool(tool: Tool): void { registerTool(tool: Tool): void {
if (this.tools.has(tool.name)) { if (this.tools.has(tool.name)) {
// Decide on behavior: throw error, log warning, or allow overwrite // Decide on behavior: throw error, log warning, or allow overwrite
console.warn(`Tool with name "${tool.name}" is already registered. Overwriting.`); console.warn(
`Tool with name "${tool.name}" is already registered. Overwriting.`,
);
} }
this.tools.set(tool.name, tool); this.tools.set(tool.name, tool);
} }
@ -22,7 +24,7 @@ class ToolRegistry {
*/ */
getToolSchemas(): ToolListUnion { getToolSchemas(): ToolListUnion {
const declarations: FunctionDeclaration[] = []; const declarations: FunctionDeclaration[] = [];
this.tools.forEach(tool => { this.tools.forEach((tool) => {
declarations.push(tool.schema); declarations.push(tool.schema);
}); });

View File

@ -1,10 +1,13 @@
import { FunctionDeclaration, Schema } from "@google/genai"; import { FunctionDeclaration, Schema } from '@google/genai';
import { ToolCallConfirmationDetails } from "../ui/types.js"; import { ToolCallConfirmationDetails } from '../ui/types.js';
/** /**
* Interface representing the base Tool functionality * Interface representing the base Tool functionality
*/ */
export interface Tool<TParams = unknown, TResult extends ToolResult = ToolResult> { export interface Tool<
TParams = unknown,
TResult extends ToolResult = ToolResult,
> {
/** /**
* The internal name of the tool (used for API calls) * The internal name of the tool (used for API calls)
*/ */
@ -45,7 +48,9 @@ export interface Tool<TParams = unknown, TResult extends ToolResult = ToolResult
* @param params Parameters for the tool execution * @param params Parameters for the tool execution
* @returns Whether execute should be confirmed. * @returns Whether execute should be confirmed.
*/ */
shouldConfirmExecute(params: TParams): Promise<ToolCallConfirmationDetails | false>; shouldConfirmExecute(
params: TParams,
): Promise<ToolCallConfirmationDetails | false>;
/** /**
* Executes the tool with the given parameters * Executes the tool with the given parameters
@ -55,11 +60,14 @@ export interface Tool<TParams = unknown, TResult extends ToolResult = ToolResult
execute(params: TParams): Promise<TResult>; execute(params: TParams): Promise<TResult>;
} }
/** /**
* Base implementation for tools with common functionality * Base implementation for tools with common functionality
*/ */
export abstract class BaseTool<TParams = unknown, TResult extends ToolResult = ToolResult> implements Tool<TParams, TResult> { export abstract class BaseTool<
TParams = unknown,
TResult extends ToolResult = ToolResult,
> implements Tool<TParams, TResult>
{
/** /**
* Creates a new instance of BaseTool * Creates a new instance of BaseTool
* @param name Internal name of the tool (used for API calls) * @param name Internal name of the tool (used for API calls)
@ -71,7 +79,7 @@ export abstract class BaseTool<TParams = unknown, TResult extends ToolResult = T
public readonly name: string, public readonly name: string,
public readonly displayName: string, public readonly displayName: string,
public readonly description: string, public readonly description: string,
public readonly parameterSchema: Record<string, unknown> public readonly parameterSchema: Record<string, unknown>,
) {} ) {}
/** /**
@ -81,7 +89,7 @@ export abstract class BaseTool<TParams = unknown, TResult extends ToolResult = T
return { return {
name: this.name, name: this.name,
description: this.description, description: this.description,
parameters: this.parameterSchema as Schema parameters: this.parameterSchema as Schema,
}; };
} }
@ -112,7 +120,9 @@ export abstract class BaseTool<TParams = unknown, TResult extends ToolResult = T
* @param params Parameters for the tool execution * @param params Parameters for the tool execution
* @returns Whether or not execute should be confirmed by the user. * @returns Whether or not execute should be confirmed by the user.
*/ */
shouldConfirmExecute(params: TParams): Promise<ToolCallConfirmationDetails | false> { shouldConfirmExecute(
params: TParams,
): Promise<ToolCallConfirmationDetails | false> {
return Promise.resolve(false); return Promise.resolve(false);
} }
@ -125,7 +135,6 @@ export abstract class BaseTool<TParams = unknown, TResult extends ToolResult = T
abstract execute(params: TParams): Promise<TResult>; abstract execute(params: TParams): Promise<TResult>;
} }
export interface ToolResult { export interface ToolResult {
/** /**
* Content meant to be included in LLM history. * Content meant to be included in LLM history.
@ -143,5 +152,5 @@ export interface ToolResult {
export type ToolResultDisplay = string | FileDiff; export type ToolResultDisplay = string | FileDiff;
export interface FileDiff { export interface FileDiff {
fileDiff: string fileDiff: string;
} }

View File

@ -3,7 +3,11 @@ import path from 'path';
import { BaseTool, ToolResult } from './tools.js'; import { BaseTool, ToolResult } from './tools.js';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
import { ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolEditConfirmationDetails } from '../ui/types.js'; import {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolEditConfirmationDetails,
} from '../ui/types.js';
import * as Diff from 'diff'; import * as Diff from 'diff';
/** /**
@ -24,13 +28,15 @@ export interface WriteFileToolParams {
/** /**
* Standardized result from the WriteFile tool * Standardized result from the WriteFile tool
*/ */
export interface WriteFileToolResult extends ToolResult { export interface WriteFileToolResult extends ToolResult {}
}
/** /**
* Implementation of the WriteFile tool that writes files to the filesystem * Implementation of the WriteFile tool that writes files to the filesystem
*/ */
export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolResult> { export class WriteFileTool extends BaseTool<
WriteFileToolParams,
WriteFileToolResult
> {
public static readonly Name: string = 'write_file'; public static readonly Name: string = 'write_file';
private shouldAlwaysWrite = false; private shouldAlwaysWrite = false;
@ -52,17 +58,18 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
{ {
properties: { properties: {
filePath: { filePath: {
description: 'The absolute path to the file to write to (e.g., \'/home/user/project/file.txt\'). Relative paths are not supported.', description:
type: 'string' "The absolute path to the file to write to (e.g., '/home/user/project/file.txt'). Relative paths are not supported.",
type: 'string',
}, },
content: { content: {
description: 'The content to write to the file.', description: 'The content to write to the file.',
type: 'string' type: 'string',
} },
}, },
required: ['filePath', 'content'], required: ['filePath', 'content'],
type: 'object' type: 'object',
} },
); );
// Set the root directory // Set the root directory
@ -83,7 +90,10 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
? normalizedRoot ? normalizedRoot
: normalizedRoot + path.sep; : normalizedRoot + path.sep;
return normalizedPath === normalizedRoot || normalizedPath.startsWith(rootWithSep); return (
normalizedPath === normalizedRoot ||
normalizedPath.startsWith(rootWithSep)
);
} }
/** /**
@ -92,7 +102,13 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
* @returns True if parameters are valid, false otherwise * @returns True if parameters are valid, false otherwise
*/ */
invalidParams(params: WriteFileToolParams): string | null { invalidParams(params: WriteFileToolParams): string | null {
if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) { if (
this.schema.parameters &&
!SchemaValidator.validate(
this.schema.parameters as Record<string, unknown>,
params,
)
) {
return 'Parameters failed schema validation.'; return 'Parameters failed schema validation.';
} }
@ -114,7 +130,9 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
* @param params Parameters for the tool execution * @param params Parameters for the tool execution
* @returns Whether or not execute should be confirmed by the user. * @returns Whether or not execute should be confirmed by the user.
*/ */
async shouldConfirmExecute(params: WriteFileToolParams): Promise<ToolCallConfirmationDetails | false> { async shouldConfirmExecute(
params: WriteFileToolParams,
): Promise<ToolCallConfirmationDetails | false> {
if (this.shouldAlwaysWrite) { if (this.shouldAlwaysWrite) {
return false; return false;
} }
@ -135,7 +153,7 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
params.content, params.content,
'Current', 'Current',
'Proposed', 'Proposed',
{ context: 3, ignoreWhitespace: true} { context: 3, ignoreWhitespace: true },
); );
const confirmationDetails: ToolEditConfirmationDetails = { const confirmationDetails: ToolEditConfirmationDetails = {
@ -171,7 +189,7 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
if (validationError) { if (validationError) {
return { return {
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
returnDisplay: '**Error:** Failed to execute tool.' returnDisplay: '**Error:** Failed to execute tool.',
}; };
} }
@ -187,13 +205,13 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, WriteFileToolRe
return { return {
llmContent: `Successfully wrote to file: ${params.file_path}`, llmContent: `Successfully wrote to file: ${params.file_path}`,
returnDisplay: `Wrote to ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}` returnDisplay: `Wrote to ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`,
}; };
} catch (error) { } catch (error) {
const errorMsg = `Error writing to file: ${error instanceof Error ? error.message : String(error)}`; const errorMsg = `Error writing to file: ${error instanceof Error ? error.message : String(error)}`;
return { return {
llmContent: `Error writing to file ${params.file_path}: ${errorMsg}`, llmContent: `Error writing to file ${params.file_path}: ${errorMsg}`,
returnDisplay: `Failed to write to file: ${errorMsg}` returnDisplay: `Failed to write to file: ${errorMsg}`,
}; };
} }
} }

View File

@ -19,46 +19,79 @@ interface AppProps {
const App = ({ directory }: AppProps) => { const App = ({ directory }: AppProps) => {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [history, setHistory] = useState<HistoryItem[]>([]); const [history, setHistory] = useState<HistoryItem[]>([]);
const { streamingState, submitQuery, initError } = useGeminiStream(setHistory); const { streamingState, submitQuery, initError } =
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(streamingState); useGeminiStream(setHistory);
const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState);
const handleInputSubmit = (value: PartListUnion) => { const handleInputSubmit = (value: PartListUnion) => {
submitQuery(value).then(() => { submitQuery(value)
.then(() => {
setQuery(''); setQuery('');
}).catch(() => { })
.catch(() => {
setQuery(''); setQuery('');
}); });
}; };
useEffect(() => { useEffect(() => {
if (initError && !history.some(item => item.type === 'error' && item.text?.includes(initError))) { if (
setHistory(prev => [ initError &&
!history.some(
(item) => item.type === 'error' && item.text?.includes(initError),
)
) {
setHistory((prev) => [
...prev, ...prev,
{ id: Date.now(), type: 'error', text: `Initialization Error: ${initError}. Please check API key and configuration.` } as HistoryItem {
id: Date.now(),
type: 'error',
text: `Initialization Error: ${initError}. Please check API key and configuration.`,
} as HistoryItem,
]); ]);
} }
}, [initError, history]); }, [initError, history]);
const isWaitingForToolConfirmation = history.some(item => const isWaitingForToolConfirmation = history.some(
item.type === 'tool_group' && item.tools.some(tool => tool.confirmationDetails !== undefined) (item) =>
item.type === 'tool_group' &&
item.tools.some((tool) => tool.confirmationDetails !== undefined),
); );
const isInputActive = streamingState === StreamingState.Idle && !initError; const isInputActive = streamingState === StreamingState.Idle && !initError;
return ( return (
<Box flexDirection="column" padding={1} marginBottom={1} width="100%"> <Box flexDirection="column" padding={1} marginBottom={1} width="100%">
<Header cwd={directory} /> <Header cwd={directory} />
<Tips /> <Tips />
{initError && streamingState !== StreamingState.Responding && !isWaitingForToolConfirmation && ( {initError &&
<Box borderStyle="round" borderColor="red" paddingX={1} marginBottom={1}> streamingState !== StreamingState.Responding &&
{history.find(item => item.type === 'error' && item.text?.includes(initError))?.text ? ( !isWaitingForToolConfirmation && (
<Text color="red">{history.find(item => item.type === 'error' && item.text?.includes(initError))?.text}</Text> <Box
borderStyle="round"
borderColor="red"
paddingX={1}
marginBottom={1}
>
{history.find(
(item) => item.type === 'error' && item.text?.includes(initError),
)?.text ? (
<Text color="red">
{
history.find(
(item) =>
item.type === 'error' && item.text?.includes(initError),
)?.text
}
</Text>
) : ( ) : (
<> <>
<Text color="red">Initialization Error: {initError}</Text> <Text color="red">Initialization Error: {initError}</Text>
<Text color="red"> Please check API key and configuration.</Text> <Text color="red">
{' '}
Please check API key and configuration.
</Text>
</> </>
)} )}
</Box> </Box>

View File

@ -9,9 +9,7 @@ const Footer: React.FC<FooterProps> = ({ queryLength }) => {
return ( return (
<Box marginTop={1} justifyContent="space-between"> <Box marginTop={1} justifyContent="space-between">
<Box minWidth={15}> <Box minWidth={15}>
<Text color="gray"> <Text color="gray">{queryLength === 0 ? '? for shortcuts' : ''}</Text>
{queryLength === 0 ? "? for shortcuts" : ""}
</Text>
</Box> </Box>
<Text color="blue">Gemini</Text> <Text color="blue">Gemini</Text>
</Box> </Box>

View File

@ -29,7 +29,9 @@ const Header: React.FC<HeaderProps> = ({ cwd }) => {
marginBottom={1} marginBottom={1}
width={UI_WIDTH} width={UI_WIDTH}
> >
<Box paddingLeft={2}><Text color="gray">cwd: {shortenPath(cwd, /*maxLength*/ 70)}</Text></Box> <Box paddingLeft={2}>
<Text color="gray">cwd: {shortenPath(cwd, /*maxLength*/ 70)}</Text>
</Box>
</Box> </Box>
</> </>
); );

View File

@ -14,7 +14,10 @@ interface HistoryDisplayProps {
onSubmit: (value: PartListUnion) => void; onSubmit: (value: PartListUnion) => void;
} }
const HistoryDisplay: React.FC<HistoryDisplayProps> = ({ history, onSubmit }) => { const HistoryDisplay: React.FC<HistoryDisplayProps> = ({
history,
onSubmit,
}) => {
// No grouping logic needed here anymore // No grouping logic needed here anymore
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">

View File

@ -15,12 +15,7 @@ const InputPrompt: React.FC<InputPromptProps> = ({
onSubmit, onSubmit,
}) => { }) => {
return ( return (
<Box <Box marginTop={1} borderStyle="round" borderColor={'white'} paddingX={1}>
marginTop={1}
borderStyle="round"
borderColor={'white'}
paddingX={1}
>
<Text color={'white'}>&gt; </Text> <Text color={'white'}>&gt; </Text>
<Box flexGrow={1}> <Box flexGrow={1}>
<TextInput <TextInput

View File

@ -22,7 +22,9 @@ const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<Box marginRight={1}> <Box marginRight={1}>
<Spinner type="dots" /> <Spinner type="dots" />
</Box> </Box>
<Text color="cyan">{currentLoadingPhrase} ({elapsedTime}s)</Text> <Text color="cyan">
{currentLoadingPhrase} ({elapsedTime}s)
</Text>
<Box flexGrow={1}>{/* Spacer */}</Box> <Box flexGrow={1}>{/* Spacer */}</Box>
<Text color="gray">(ESC to cancel)</Text> <Text color="gray">(ESC to cancel)</Text>
</Box> </Box>

View File

@ -6,8 +6,13 @@ const Tips: React.FC = () => {
return ( return (
<Box flexDirection="column" marginBottom={1} width={UI_WIDTH}> <Box flexDirection="column" marginBottom={1} width={UI_WIDTH}>
<Text>Tips for getting started:</Text> <Text>Tips for getting started:</Text>
<Text>1. <Text bold>/help</Text> for more information.</Text> <Text>
<Text>2. <Text bold>/init</Text> to create a GEMINI.md for instructions & context.</Text> 1. <Text bold>/help</Text> for more information.
</Text>
<Text>
2. <Text bold>/init</Text> to create a GEMINI.md for instructions &
context.
</Text>
<Text>3. Ask coding questions, edit code or run commands.</Text> <Text>3. Ask coding questions, edit code or run commands.</Text>
<Text>4. Be specific for the best results.</Text> <Text>4. Be specific for the best results.</Text>
</Box> </Box>

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink' import { Box, Text } from 'ink';
interface DiffLine { interface DiffLine {
type: 'add' | 'del' | 'context' | 'hunk' | 'other'; type: 'add' | 'del' | 'context' | 'hunk' | 'other';
@ -31,28 +31,52 @@ function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
} }
if (!inHunk) { if (!inHunk) {
// Skip standard Git header lines more robustly // Skip standard Git header lines more robustly
if (line.startsWith('--- ') || line.startsWith('+++ ') || line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('similarity index') || line.startsWith('rename from') || line.startsWith('rename to') || line.startsWith('new file mode') || line.startsWith('deleted file mode')) continue; if (
line.startsWith('--- ') ||
line.startsWith('+++ ') ||
line.startsWith('diff --git') ||
line.startsWith('index ') ||
line.startsWith('similarity index') ||
line.startsWith('rename from') ||
line.startsWith('rename to') ||
line.startsWith('new file mode') ||
line.startsWith('deleted file mode')
)
continue;
// If it's not a hunk or header, skip (or handle as 'other' if needed) // If it's not a hunk or header, skip (or handle as 'other' if needed)
continue; continue;
} }
if (line.startsWith('+')) { if (line.startsWith('+')) {
currentNewLine++; // Increment before pushing currentNewLine++; // Increment before pushing
result.push({ type: 'add', newLine: currentNewLine, content: line.substring(1) }); result.push({
type: 'add',
newLine: currentNewLine,
content: line.substring(1),
});
} else if (line.startsWith('-')) { } else if (line.startsWith('-')) {
currentOldLine++; // Increment before pushing currentOldLine++; // Increment before pushing
result.push({ type: 'del', oldLine: currentOldLine, content: line.substring(1) }); result.push({
type: 'del',
oldLine: currentOldLine,
content: line.substring(1),
});
} else if (line.startsWith(' ')) { } else if (line.startsWith(' ')) {
currentOldLine++; // Increment before pushing currentOldLine++; // Increment before pushing
currentNewLine++; currentNewLine++;
result.push({ type: 'context', oldLine: currentOldLine, newLine: currentNewLine, content: line.substring(1) }); result.push({
} else if (line.startsWith('\\')) { // Handle "\ No newline at end of file" type: 'context',
oldLine: currentOldLine,
newLine: currentNewLine,
content: line.substring(1),
});
} else if (line.startsWith('\\')) {
// Handle "\ No newline at end of file"
result.push({ type: 'other', content: line }); result.push({ type: 'other', content: line });
} }
} }
return result; return result;
} }
interface DiffRendererProps { interface DiffRendererProps {
diffContent: string; diffContent: string;
filename?: string; filename?: string;
@ -61,7 +85,10 @@ interface DiffRendererProps {
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEFAULT_TAB_WIDTH }) => { const DiffRenderer: React.FC<DiffRendererProps> = ({
diffContent,
tabWidth = DEFAULT_TAB_WIDTH,
}) => {
if (!diffContent || typeof diffContent !== 'string') { if (!diffContent || typeof diffContent !== 'string') {
return <Text color="yellow">No diff content.</Text>; return <Text color="yellow">No diff content.</Text>;
} }
@ -69,14 +96,15 @@ const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEF
const parsedLines = parseDiffWithLineNumbers(diffContent); const parsedLines = parseDiffWithLineNumbers(diffContent);
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing // 1. Normalize whitespace (replace tabs with spaces) *before* further processing
const normalizedLines = parsedLines.map(line => ({ const normalizedLines = parsedLines.map((line) => ({
...line, ...line,
content: line.content.replace(/\t/g, ' '.repeat(tabWidth)) content: line.content.replace(/\t/g, ' '.repeat(tabWidth)),
})); }));
// Filter out non-displayable lines (hunks, potentially 'other') using the normalized list // Filter out non-displayable lines (hunks, potentially 'other') using the normalized list
const displayableLines = normalizedLines.filter(l => l.type !== 'hunk' && l.type !== 'other'); const displayableLines = normalizedLines.filter(
(l) => l.type !== 'hunk' && l.type !== 'other',
);
if (displayableLines.length === 0) { if (displayableLines.length === 0) {
return ( return (
@ -93,7 +121,7 @@ const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEF
if (line.content.trim() === '') continue; if (line.content.trim() === '') continue;
const firstCharIndex = line.content.search(/\S/); // Find index of first non-whitespace char const firstCharIndex = line.content.search(/\S/); // Find index of first non-whitespace char
const currentIndent = (firstCharIndex === -1) ? 0 : firstCharIndex; // Indent is 0 if no non-whitespace found const currentIndent = firstCharIndex === -1 ? 0 : firstCharIndex; // Indent is 0 if no non-whitespace found
baseIndentation = Math.min(baseIndentation, currentIndent); baseIndentation = Math.min(baseIndentation, currentIndent);
} }
// If baseIndentation remained Infinity (e.g., no displayable lines with content), default to 0 // If baseIndentation remained Infinity (e.g., no displayable lines with content), default to 0
@ -102,7 +130,6 @@ const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEF
} }
// --- End Modification --- // --- End Modification ---
return ( return (
<Box borderStyle="round" borderColor="gray" flexDirection="column"> <Box borderStyle="round" borderColor="gray" flexDirection="column">
{/* Iterate over the lines that should be displayed (already normalized) */} {/* Iterate over the lines that should be displayed (already normalized) */}
@ -140,8 +167,12 @@ const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEF
// Using your original rendering structure // Using your original rendering structure
<Box key={key} flexDirection="row"> <Box key={key} flexDirection="row">
<Text color="gray">{gutterNumStr} </Text> <Text color="gray">{gutterNumStr} </Text>
<Text color={color} dimColor={dim}>{prefixSymbol} </Text> <Text color={color} dimColor={dim}>
<Text color={color} dimColor={dim} wrap="wrap">{displayContent}</Text> {prefixSymbol}{' '}
</Text>
<Text color={color} dimColor={dim} wrap="wrap">
{displayContent}
</Text>
</Box> </Box>
); );
})} })}

View File

@ -15,7 +15,9 @@ const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
<Text color="red">{prefix}</Text> <Text color="red">{prefix}</Text>
</Box> </Box>
<Box flexGrow={1}> <Box flexGrow={1}>
<Text wrap="wrap" color="red">{text}</Text> <Text wrap="wrap" color="red">
{text}
</Text>
</Box> </Box>
</Box> </Box>
); );

View File

@ -15,7 +15,9 @@ const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
<Text color="yellow">{prefix}</Text> <Text color="yellow">{prefix}</Text>
</Box> </Box>
<Box flexGrow={1}> <Box flexGrow={1}>
<Text wrap="wrap" color="yellow">{text}</Text> <Text wrap="wrap" color="yellow">
{text}
</Text>
</Box> </Box>
</Box> </Box>
); );

View File

@ -1,7 +1,12 @@
import React from 'react'; import React from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput } from 'ink';
import SelectInput from 'ink-select-input'; import SelectInput from 'ink-select-input';
import { ToolCallConfirmationDetails, ToolEditConfirmationDetails, ToolConfirmationOutcome, ToolExecuteConfirmationDetails } from '../../types.js'; // Adjust path as needed import {
ToolCallConfirmationDetails,
ToolEditConfirmationDetails,
ToolConfirmationOutcome,
ToolExecuteConfirmationDetails,
} from '../../types.js'; // Adjust path as needed
import { PartListUnion } from '@google/genai'; import { PartListUnion } from '@google/genai';
import DiffRenderer from './DiffRenderer.js'; import DiffRenderer from './DiffRenderer.js';
import { UI_WIDTH } from '../../constants.js'; import { UI_WIDTH } from '../../constants.js';
@ -11,7 +16,9 @@ export interface ToolConfirmationMessageProps {
onSubmit: (value: PartListUnion) => void; onSubmit: (value: PartListUnion) => void;
} }
function isEditDetails(props: ToolCallConfirmationDetails): props is ToolEditConfirmationDetails { function isEditDetails(
props: ToolCallConfirmationDetails,
): props is ToolEditConfirmationDetails {
return (props as ToolEditConfirmationDetails).fileName !== undefined; return (props as ToolEditConfirmationDetails).fileName !== undefined;
} }
@ -20,7 +27,9 @@ interface InternalOption {
value: ToolConfirmationOutcome; value: ToolConfirmationOutcome;
} }
const ToolConfirmationMessage: React.FC<ToolConfirmationMessageProps> = ({ confirmationDetails }) => { const ToolConfirmationMessage: React.FC<ToolConfirmationMessageProps> = ({
confirmationDetails,
}) => {
const { onConfirm } = confirmationDetails; const { onConfirm } = confirmationDetails;
useInput((_, key) => { useInput((_, key) => {
@ -39,24 +48,28 @@ const ToolConfirmationMessage: React.FC<ToolConfirmationMessageProps> = ({ confi
const options: InternalOption[] = []; const options: InternalOption[] = [];
if (isEditDetails(confirmationDetails)) { if (isEditDetails(confirmationDetails)) {
title = "Edit"; // Title for the outer box title = 'Edit'; // Title for the outer box
// Body content is now the DiffRenderer, passing filename to it // Body content is now the DiffRenderer, passing filename to it
// The bordered box is removed from here and handled within DiffRenderer // The bordered box is removed from here and handled within DiffRenderer
bodyContent = ( bodyContent = <DiffRenderer diffContent={confirmationDetails.fileDiff} />;
<DiffRenderer diffContent={confirmationDetails.fileDiff} />
);
question = `Apply this change?`; question = `Apply this change?`;
options.push( options.push(
{ label: '1. Yes, apply change', value: ToolConfirmationOutcome.ProceedOnce }, {
{ label: "2. Yes, always apply file edits", value: ToolConfirmationOutcome.ProceedAlways }, label: '1. Yes, apply change',
{ label: '3. No (esc)', value: ToolConfirmationOutcome.Cancel } value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: '2. Yes, always apply file edits',
value: ToolConfirmationOutcome.ProceedAlways,
},
{ label: '3. No (esc)', value: ToolConfirmationOutcome.Cancel },
); );
} else { } else {
const executionProps = confirmationDetails as ToolExecuteConfirmationDetails; const executionProps =
title = "Execute Command"; // Title for the outer box confirmationDetails as ToolExecuteConfirmationDetails;
title = 'Execute Command'; // Title for the outer box
// For execution, we still need context display and description // For execution, we still need context display and description
const commandDisplay = <Text color="cyan">{executionProps.command}</Text>; const commandDisplay = <Text color="cyan">{executionProps.command}</Text>;
@ -64,16 +77,24 @@ const ToolConfirmationMessage: React.FC<ToolConfirmationMessageProps> = ({ confi
// Combine command and description into bodyContent for layout consistency // Combine command and description into bodyContent for layout consistency
bodyContent = ( bodyContent = (
<Box flexDirection="column"> <Box flexDirection="column">
<Box paddingX={1} marginLeft={1}>{commandDisplay}</Box> <Box paddingX={1} marginLeft={1}>
{commandDisplay}
</Box>
</Box> </Box>
); );
question = `Allow execution?`; question = `Allow execution?`;
const alwaysLabel = `2. Yes, always allow '${executionProps.rootCommand}' commands`; const alwaysLabel = `2. Yes, always allow '${executionProps.rootCommand}' commands`;
options.push( options.push(
{ label: '1. Yes, allow once', value: ToolConfirmationOutcome.ProceedOnce }, {
{ label: alwaysLabel, value: ToolConfirmationOutcome.ProceedAlways }, label: '1. Yes, allow once',
{ label: '3. No (esc)', value: ToolConfirmationOutcome.Cancel } value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: alwaysLabel,
value: ToolConfirmationOutcome.ProceedAlways,
},
{ label: '3. No (esc)', value: ToolConfirmationOutcome.Cancel },
); );
} }

View File

@ -11,16 +11,15 @@ interface ToolGroupMessageProps {
} }
// Main component renders the border and maps the tools using ToolMessage // Main component renders the border and maps the tools using ToolMessage
const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ toolCalls, onSubmit }) => { const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const hasPending = toolCalls.some(t => t.status === ToolCallStatus.Pending); toolCalls,
const borderColor = hasPending ? "yellow" : "blue"; onSubmit,
}) => {
const hasPending = toolCalls.some((t) => t.status === ToolCallStatus.Pending);
const borderColor = hasPending ? 'yellow' : 'blue';
return ( return (
<Box <Box flexDirection="column" borderStyle="round" borderColor={borderColor}>
flexDirection="column"
borderStyle="round"
borderColor={borderColor}
>
{toolCalls.map((tool) => { {toolCalls.map((tool) => {
return ( return (
<React.Fragment key={tool.callId}> <React.Fragment key={tool.callId}>
@ -31,8 +30,12 @@ const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ toolCalls, onSubmit
resultDisplay={tool.resultDisplay} resultDisplay={tool.resultDisplay}
status={tool.status} status={tool.status}
/> />
{tool.status === ToolCallStatus.Confirming && tool.confirmationDetails && ( {tool.status === ToolCallStatus.Confirming &&
<ToolConfirmationMessage confirmationDetails={tool.confirmationDetails} onSubmit={onSubmit}></ToolConfirmationMessage> tool.confirmationDetails && (
<ToolConfirmationMessage
confirmationDetails={tool.confirmationDetails}
onSubmit={onSubmit}
></ToolConfirmationMessage>
)} )}
</React.Fragment> </React.Fragment>
); );

View File

@ -13,9 +13,17 @@ interface ToolMessageProps {
status: ToolCallStatus; status: ToolCallStatus;
} }
const ToolMessage: React.FC<ToolMessageProps> = ({ name, description, resultDisplay, status }) => { const ToolMessage: React.FC<ToolMessageProps> = ({
name,
description,
resultDisplay,
status,
}) => {
const statusIndicatorWidth = 3; const statusIndicatorWidth = 3;
const hasResult = (status === ToolCallStatus.Invoked || status === ToolCallStatus.Canceled) && resultDisplay && resultDisplay.toString().trim().length > 0; const hasResult =
(status === ToolCallStatus.Invoked || status === ToolCallStatus.Canceled) &&
resultDisplay &&
resultDisplay.toString().trim().length > 0;
return ( return (
<Box paddingX={1} paddingY={0} flexDirection="column"> <Box paddingX={1} paddingY={0} flexDirection="column">
@ -26,11 +34,18 @@ const ToolMessage: React.FC<ToolMessageProps> = ({ name, description, resultDisp
{status === ToolCallStatus.Pending && <Spinner type="dots" />} {status === ToolCallStatus.Pending && <Spinner type="dots" />}
{status === ToolCallStatus.Invoked && <Text color="green"></Text>} {status === ToolCallStatus.Invoked && <Text color="green"></Text>}
{status === ToolCallStatus.Confirming && <Text color="blue">?</Text>} {status === ToolCallStatus.Confirming && <Text color="blue">?</Text>}
{status === ToolCallStatus.Canceled && <Text color="red" bold>-</Text>} {status === ToolCallStatus.Canceled && (
<Text color="red" bold>
-
</Text>
)}
</Box> </Box>
<Box> <Box>
<Text color="blue" wrap="truncate-end" strikethrough={status === ToolCallStatus.Canceled}> <Text
color="blue"
wrap="truncate-end"
strikethrough={status === ToolCallStatus.Canceled}
>
<Text bold>{name}</Text> <Text color="gray">{description}</Text> <Text bold>{name}</Text> <Text color="gray">{description}</Text>
</Text> </Text>
</Box> </Box>
@ -41,8 +56,14 @@ const ToolMessage: React.FC<ToolMessageProps> = ({ name, description, resultDisp
<Box flexShrink={1} flexDirection="row"> <Box flexShrink={1} flexDirection="row">
<Text color="gray"> </Text> <Text color="gray"> </Text>
{/* Use default text color (white) or gray instead of dimColor */} {/* Use default text color (white) or gray instead of dimColor */}
{typeof resultDisplay === 'string' && <Box flexDirection='column'>{MarkdownRenderer.render(resultDisplay)}</Box>} {typeof resultDisplay === 'string' && (
{typeof resultDisplay === 'object' && <DiffRenderer diffContent={resultDisplay.fileDiff} />} <Box flexDirection="column">
{MarkdownRenderer.render(resultDisplay)}
</Box>
)}
{typeof resultDisplay === 'object' && (
<DiffRenderer diffContent={resultDisplay.fileDiff} />
)}
</Box> </Box>
</Box> </Box>
)} )}

View File

@ -3,7 +3,8 @@ const BoxBorderWidth = 1;
export const BOX_PADDING_X = 1; export const BOX_PADDING_X = 1;
// Calculate width based on art, padding, and border // Calculate width based on art, padding, and border
export const UI_WIDTH = EstimatedArtWidth + (BOX_PADDING_X * 2) + (BoxBorderWidth * 2); // ~63 export const UI_WIDTH =
EstimatedArtWidth + BOX_PADDING_X * 2 + BoxBorderWidth * 2; // ~63
export const WITTY_LOADING_PHRASES = [ export const WITTY_LOADING_PHRASES = [
'Consulting the digital spirits...', 'Consulting the digital spirits...',
@ -12,7 +13,7 @@ export const WITTY_LOADING_PHRASES = [
'Asking the magic conch shell...', 'Asking the magic conch shell...',
'Generating witty retort...', 'Generating witty retort...',
'Polishing the algorithms...', 'Polishing the algorithms...',
'Don\'t rush perfection (or my code)...', "Don't rush perfection (or my code)...",
'Brewing fresh bytes...', 'Brewing fresh bytes...',
'Counting electrons...', 'Counting electrons...',
'Engaging cognitive processors...', 'Engaging cognitive processors...',

View File

@ -9,7 +9,7 @@ import { StreamingState } from '../../core/gemini-stream.js';
const addHistoryItem = ( const addHistoryItem = (
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>, setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
itemData: Omit<HistoryItem, 'id'>, itemData: Omit<HistoryItem, 'id'>,
id: number id: number,
) => { ) => {
setHistory((prevHistory) => [ setHistory((prevHistory) => [
...prevHistory, ...prevHistory,
@ -20,7 +20,9 @@ const addHistoryItem = (
export const useGeminiStream = ( export const useGeminiStream = (
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>, setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
) => { ) => {
const [streamingState, setStreamingState] = useState<StreamingState>(StreamingState.Idle); const [streamingState, setStreamingState] = useState<StreamingState>(
StreamingState.Idle,
);
const [initError, setInitError] = useState<string | null>(null); const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const currentToolGroupIdRef = useRef<number | null>(null); const currentToolGroupIdRef = useRef<number | null>(null);
@ -35,7 +37,9 @@ export const useGeminiStream = (
try { try {
geminiClientRef.current = new GeminiClient(); geminiClientRef.current = new GeminiClient();
} catch (error: any) { } catch (error: any) {
setInitError(`Failed to initialize client: ${error.message || 'Unknown error'}`); setInitError(
`Failed to initialize client: ${error.message || 'Unknown error'}`,
);
} }
} }
}, []); }, []);
@ -54,7 +58,8 @@ export const useGeminiStream = (
}, []); }, []);
// Submit Query Callback (updated to call processGeminiStream) // Submit Query Callback (updated to call processGeminiStream)
const submitQuery = useCallback(async (query: PartListUnion) => { const submitQuery = useCallback(
async (query: PartListUnion) => {
if (streamingState === StreamingState.Responding) { if (streamingState === StreamingState.Responding) {
// No-op if already going. // No-op if already going.
return; return;
@ -67,7 +72,7 @@ export const useGeminiStream = (
const userMessageTimestamp = Date.now(); const userMessageTimestamp = Date.now();
const client = geminiClientRef.current; const client = geminiClientRef.current;
if (!client) { if (!client) {
setInitError("Gemini client is not available."); setInitError('Gemini client is not available.');
return; return;
} }
@ -86,7 +91,11 @@ export const useGeminiStream = (
// Add user message // Add user message
if (typeof query === 'string') { if (typeof query === 'string') {
const trimmedQuery = query.toString(); const trimmedQuery = query.toString();
addHistoryItem(setHistory, { type: 'user', text: trimmedQuery }, userMessageTimestamp); addHistoryItem(
setHistory,
{ type: 'user', text: trimmedQuery },
userMessageTimestamp,
);
} else if ( } else if (
// HACK to detect errored function responses. // HACK to detect errored function responses.
typeof query === 'object' && typeof query === 'object' &&
@ -109,7 +118,10 @@ export const useGeminiStream = (
const stream = client.sendMessageStream(chat, query, signal); const stream = client.sendMessageStream(chat, query, signal);
const addHistoryItemFromStream = (itemData: Omit<HistoryItem, 'id'>, id: number) => { const addHistoryItemFromStream = (
itemData: Omit<HistoryItem, 'id'>,
id: number,
) => {
addHistoryItem(setHistory, itemData, id); addHistoryItem(setHistory, itemData, id);
}; };
const getStreamMessageId = () => getNextMessageId(userMessageTimestamp); const getStreamMessageId = () => getNextMessageId(userMessageTimestamp);
@ -126,17 +138,26 @@ export const useGeminiStream = (
}); });
} catch (error: any) { } catch (error: any) {
// (Error handling for stream initiation remains the same) // (Error handling for stream initiation remains the same)
console.error("Error initiating stream:", error); console.error('Error initiating stream:', error);
if (error.name !== 'AbortError') { if (error.name !== 'AbortError') {
// Use historyUpdater's function potentially? Or keep addHistoryItem here? // Use historyUpdater's function potentially? Or keep addHistoryItem here?
// Keeping addHistoryItem here for direct errors from this scope. // Keeping addHistoryItem here for direct errors from this scope.
addHistoryItem(setHistory, { type: 'error', text: `[Error starting stream: ${error.message}]` }, getNextMessageId(userMessageTimestamp)); addHistoryItem(
setHistory,
{
type: 'error',
text: `[Error starting stream: ${error.message}]`,
},
getNextMessageId(userMessageTimestamp),
);
} }
} finally { } finally {
abortControllerRef.current = null; abortControllerRef.current = null;
setStreamingState(StreamingState.Idle); setStreamingState(StreamingState.Idle);
} }
}, [setStreamingState, setHistory, initError, getNextMessageId]); },
[setStreamingState, setHistory, initError, getNextMessageId],
);
return { streamingState, submitQuery, initError }; return { streamingState, submitQuery, initError };
}; };

View File

@ -1,10 +1,15 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { WITTY_LOADING_PHRASES, PHRASE_CHANGE_INTERVAL_MS } from '../constants.js'; import {
WITTY_LOADING_PHRASES,
PHRASE_CHANGE_INTERVAL_MS,
} from '../constants.js';
import { StreamingState } from '../../core/gemini-stream.js'; import { StreamingState } from '../../core/gemini-stream.js';
export const useLoadingIndicator = (streamingState: StreamingState) => { export const useLoadingIndicator = (streamingState: StreamingState) => {
const [elapsedTime, setElapsedTime] = useState(0); const [elapsedTime, setElapsedTime] = useState(0);
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(WITTY_LOADING_PHRASES[0]); const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(
WITTY_LOADING_PHRASES[0],
);
const timerRef = useRef<NodeJS.Timeout | null>(null); const timerRef = useRef<NodeJS.Timeout | null>(null);
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null); const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
const currentPhraseIndexRef = useRef<number>(0); const currentPhraseIndexRef = useRef<number>(0);
@ -34,8 +39,11 @@ export const useLoadingIndicator = (streamingState: StreamingState) => {
currentPhraseIndexRef.current = 0; currentPhraseIndexRef.current = 0;
setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[0]); setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[0]);
phraseIntervalRef.current = setInterval(() => { phraseIntervalRef.current = setInterval(() => {
currentPhraseIndexRef.current = (currentPhraseIndexRef.current + 1) % WITTY_LOADING_PHRASES.length; currentPhraseIndexRef.current =
setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[currentPhraseIndexRef.current]); (currentPhraseIndexRef.current + 1) % WITTY_LOADING_PHRASES.length;
setCurrentLoadingPhrase(
WITTY_LOADING_PHRASES[currentPhraseIndexRef.current],
);
}, PHRASE_CHANGE_INTERVAL_MS); }, PHRASE_CHANGE_INTERVAL_MS);
} else if (phraseIntervalRef.current) { } else if (phraseIntervalRef.current) {
clearInterval(phraseIntervalRef.current); clearInterval(phraseIntervalRef.current);

View File

@ -1,4 +1,4 @@
import { ToolResultDisplay } from "../tools/tools.js"; import { ToolResultDisplay } from '../tools/tools.js';
export enum ToolCallStatus { export enum ToolCallStatus {
Pending, Pending,
@ -31,25 +31,28 @@ export interface HistoryItemBase {
text?: string; // Text content for user/gemini/info/error messages text?: string; // Text content for user/gemini/info/error messages
} }
export type HistoryItem = HistoryItemBase & ( export type HistoryItem = HistoryItemBase &
(
| { type: 'user'; text: string } | { type: 'user'; text: string }
| { type: 'gemini'; text: string } | { type: 'gemini'; text: string }
| { type: 'info'; text: string } | { type: 'info'; text: string }
| { type: 'error'; text: string } | { type: 'error'; text: string }
| { type: 'tool_group'; tools: IndividualToolCallDisplay[]; } | { type: 'tool_group'; tools: IndividualToolCallDisplay[] }
); );
export interface ToolCallConfirmationDetails { export interface ToolCallConfirmationDetails {
title: string; title: string;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>; onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
} }
export interface ToolEditConfirmationDetails extends ToolCallConfirmationDetails { export interface ToolEditConfirmationDetails
extends ToolCallConfirmationDetails {
fileName: string; fileName: string;
fileDiff: string; fileDiff: string;
} }
export interface ToolExecuteConfirmationDetails extends ToolCallConfirmationDetails { export interface ToolExecuteConfirmationDetails
extends ToolCallConfirmationDetails {
command: string; command: string;
rootCommand: string; rootCommand: string;
description: string; description: string;

View File

@ -7,7 +7,6 @@ import { Text, Box } from 'ink';
* and inline styles (bold, italic, strikethrough, code, links). * and inline styles (bold, italic, strikethrough, code, links).
*/ */
export class MarkdownRenderer { export class MarkdownRenderer {
/** /**
* Renders INLINE markdown elements using an iterative approach. * Renders INLINE markdown elements using an iterative approach.
* Supports: **bold**, *italic*, _italic_, ~~strike~~, [link](url), `code`, ``code``, <u>underline</u> * Supports: **bold**, *italic*, _italic_, ~~strike~~, [link](url), `code`, ``code``, <u>underline</u>
@ -18,13 +17,18 @@ export class MarkdownRenderer {
const nodes: React.ReactNode[] = []; const nodes: React.ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
// UPDATED Regex: Added <u>.*?<\/u> pattern // UPDATED Regex: Added <u>.*?<\/u> pattern
const inlineRegex = /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>)/g; const inlineRegex =
/(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>)/g;
let match; let match;
while ((match = inlineRegex.exec(text)) !== null) { while ((match = inlineRegex.exec(text)) !== null) {
// 1. Add plain text before the match // 1. Add plain text before the match
if (match.index > lastIndex) { if (match.index > lastIndex) {
nodes.push(<Text key={`t-${lastIndex}`}>{text.slice(lastIndex, match.index)}</Text>); nodes.push(
<Text key={`t-${lastIndex}`}>
{text.slice(lastIndex, match.index)}
</Text>,
);
} }
const fullMatch = match[0]; const fullMatch = match[0];
@ -33,22 +37,63 @@ export class MarkdownRenderer {
// 2. Determine type of match and render accordingly // 2. Determine type of match and render accordingly
try { try {
if (fullMatch.startsWith('**') && fullMatch.endsWith('**') && fullMatch.length > 4) { if (
renderedNode = <Text key={key} bold>{fullMatch.slice(2, -2)}</Text>; fullMatch.startsWith('**') &&
} else if (((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && fullMatch.length > 2) { fullMatch.endsWith('**') &&
renderedNode = <Text key={key} italic>{fullMatch.slice(1, -1)}</Text>; fullMatch.length > 4
} else if (fullMatch.startsWith('~~') && fullMatch.endsWith('~~') && fullMatch.length > 4) { ) {
renderedNode = (
<Text key={key} bold>
{fullMatch.slice(2, -2)}
</Text>
);
} else if (
((fullMatch.startsWith('*') && fullMatch.endsWith('*')) ||
(fullMatch.startsWith('_') && fullMatch.endsWith('_'))) &&
fullMatch.length > 2
) {
renderedNode = (
<Text key={key} italic>
{fullMatch.slice(1, -1)}
</Text>
);
} else if (
fullMatch.startsWith('~~') &&
fullMatch.endsWith('~~') &&
fullMatch.length > 4
) {
// Strikethrough as gray text // Strikethrough as gray text
renderedNode = <Text key={key} strikethrough>{fullMatch.slice(2, -2)}</Text>; renderedNode = (
} else if (fullMatch.startsWith('`') && fullMatch.endsWith('`') && fullMatch.length > 1) { <Text key={key} strikethrough>
{fullMatch.slice(2, -2)}
</Text>
);
} else if (
fullMatch.startsWith('`') &&
fullMatch.endsWith('`') &&
fullMatch.length > 1
) {
// Code: Try to match varying numbers of backticks // Code: Try to match varying numbers of backticks
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
if (codeMatch && codeMatch[2]) { if (codeMatch && codeMatch[2]) {
renderedNode = <Text key={key} color="yellow">{codeMatch[2]}</Text>; renderedNode = (
} else { // Fallback for simple or non-matching cases <Text key={key} color="yellow">
renderedNode = <Text key={key} color="yellow">{fullMatch.slice(1, -1)}</Text>; {codeMatch[2]}
</Text>
);
} else {
// Fallback for simple or non-matching cases
renderedNode = (
<Text key={key} color="yellow">
{fullMatch.slice(1, -1)}
</Text>
);
} }
} else if (fullMatch.startsWith('[') && fullMatch.includes('](') && fullMatch.endsWith(')')) { } else if (
fullMatch.startsWith('[') &&
fullMatch.includes('](') &&
fullMatch.endsWith(')')
) {
// Link: Extract text and URL // Link: Extract text and URL
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) { if (linkMatch) {
@ -62,18 +107,25 @@ export class MarkdownRenderer {
</Text> </Text>
); );
} }
} else if (fullMatch.startsWith('<u>') && fullMatch.endsWith('</u>') && fullMatch.length > 6) { } else if (
fullMatch.startsWith('<u>') &&
fullMatch.endsWith('</u>') &&
fullMatch.length > 6
) {
// ***** NEW: Handle underline tag ***** // ***** NEW: Handle underline tag *****
// Use slice(3, -4) to remove <u> and </u> // Use slice(3, -4) to remove <u> and </u>
renderedNode = <Text key={key} underline>{fullMatch.slice(3, -4)}</Text>; renderedNode = (
<Text key={key} underline>
{fullMatch.slice(3, -4)}
</Text>
);
} }
} catch (e) { } catch (e) {
// In case of regex or slicing errors, fallback to literal rendering // In case of regex or slicing errors, fallback to literal rendering
console.error("Error parsing inline markdown part:", fullMatch, e); console.error('Error parsing inline markdown part:', fullMatch, e);
renderedNode = null; // Ensure fallback below is used renderedNode = null; // Ensure fallback below is used
} }
// 3. Add the rendered node or the literal text if parsing failed // 3. Add the rendered node or the literal text if parsing failed
nodes.push(renderedNode ?? <Text key={key}>{fullMatch}</Text>); nodes.push(renderedNode ?? <Text key={key}>{fullMatch}</Text>);
lastIndex = inlineRegex.lastIndex; // Move index past the current match lastIndex = inlineRegex.lastIndex; // Move index past the current match
@ -85,16 +137,26 @@ export class MarkdownRenderer {
} }
// Filter out potential nulls if any error occurred without fallback // Filter out potential nulls if any error occurred without fallback
return nodes.filter(node => node !== null); return nodes.filter((node) => node !== null);
} }
/** /**
* Helper to render a code block. * Helper to render a code block.
*/ */
private static _renderCodeBlock(key: string, content: string[], lang: string | null): React.ReactNode { private static _renderCodeBlock(
key: string,
content: string[],
lang: string | null,
): React.ReactNode {
// Basic styling for code block // Basic styling for code block
return ( return (
<Box key={key} borderStyle="round" paddingX={1} borderColor="gray" flexDirection="column"> <Box
key={key}
borderStyle="round"
paddingX={1}
borderColor="gray"
flexDirection="column"
>
{lang && <Text dimColor> {lang}</Text>} {lang && <Text dimColor> {lang}</Text>}
{/* Render each line preserving whitespace (within Text component) */} {/* Render each line preserving whitespace (within Text component) */}
{content.map((line, idx) => ( {content.map((line, idx) => (
@ -107,7 +169,12 @@ export class MarkdownRenderer {
/** /**
* Helper to render a list item (ordered or unordered). * Helper to render a list item (ordered or unordered).
*/ */
private static _renderListItem(key: string, text: string, type: 'ul' | 'ol', marker: string): React.ReactNode { private static _renderListItem(
key: string,
text: string,
type: 'ul' | 'ol',
marker: string,
): React.ReactNode {
const renderedText = MarkdownRenderer._renderInline(text); // Allow inline styles in list items const renderedText = MarkdownRenderer._renderInline(text); // Allow inline styles in list items
const prefix = type === 'ol' ? `${marker} ` : `${marker} `; // e.g., "1. " or "* " const prefix = type === 'ol' ? `${marker} ` : `${marker} `; // e.g., "1. " or "* "
const prefixWidth = prefix.length; const prefixWidth = prefix.length;
@ -124,7 +191,6 @@ export class MarkdownRenderer {
); );
} }
/** /**
* Renders a full markdown string, handling block elements (headers, lists, code blocks) * Renders a full markdown string, handling block elements (headers, lists, code blocks)
* and applying inline styles. This is the main public static method. * and applying inline styles. This is the main public static method.
@ -157,9 +223,19 @@ export class MarkdownRenderer {
if (inCodeBlock) { if (inCodeBlock) {
const fenceMatch = line.match(codeFenceRegex); const fenceMatch = line.match(codeFenceRegex);
// Check for closing fence, matching the opening one and length // Check for closing fence, matching the opening one and length
if (fenceMatch && fenceMatch[1].startsWith(codeBlockFence[0]) && fenceMatch[1].length >= codeBlockFence.length) { if (
fenceMatch &&
fenceMatch[1].startsWith(codeBlockFence[0]) &&
fenceMatch[1].length >= codeBlockFence.length
) {
// End of code block - render it // End of code block - render it
contentBlocks.push(MarkdownRenderer._renderCodeBlock(key, codeBlockContent, codeBlockLang)); contentBlocks.push(
MarkdownRenderer._renderCodeBlock(
key,
codeBlockContent,
codeBlockLang,
),
);
// Reset state // Reset state
inCodeBlock = false; inCodeBlock = false;
codeBlockContent = []; codeBlockContent = [];
@ -189,18 +265,42 @@ export class MarkdownRenderer {
} else if (hrMatch) { } else if (hrMatch) {
// Render Horizontal Rule (simple dashed line) // Render Horizontal Rule (simple dashed line)
// Use box with height and border character, or just Text with dashes // Use box with height and border character, or just Text with dashes
contentBlocks.push(<Box key={key}><Text dimColor>---</Text></Box>); contentBlocks.push(
<Box key={key}>
<Text dimColor>---</Text>
</Box>,
);
inListType = null; // HR breaks list inListType = null; // HR breaks list
} else if (headerMatch) { } else if (headerMatch) {
const level = headerMatch[1].length; const level = headerMatch[1].length;
const headerText = headerMatch[2]; const headerText = headerMatch[2];
const renderedHeaderText = MarkdownRenderer._renderInline(headerText); const renderedHeaderText = MarkdownRenderer._renderInline(headerText);
let headerNode: React.ReactNode = null; let headerNode: React.ReactNode = null;
switch (level) { /* ... (header styling as before) ... */ switch (level /* ... (header styling as before) ... */) {
case 1: headerNode = <Text bold color="cyan">{renderedHeaderText}</Text>; break; case 1:
case 2: headerNode = <Text bold color="blue">{renderedHeaderText}</Text>; break; headerNode = (
case 3: headerNode = <Text bold>{renderedHeaderText}</Text>; break; <Text bold color="cyan">
case 4: headerNode = <Text italic color="gray">{renderedHeaderText}</Text>; break; {renderedHeaderText}
</Text>
);
break;
case 2:
headerNode = (
<Text bold color="blue">
{renderedHeaderText}
</Text>
);
break;
case 3:
headerNode = <Text bold>{renderedHeaderText}</Text>;
break;
case 4:
headerNode = (
<Text italic color="gray">
{renderedHeaderText}
</Text>
);
break;
} }
if (headerNode) contentBlocks.push(<Box key={key}>{headerNode}</Box>); if (headerNode) contentBlocks.push(<Box key={key}>{headerNode}</Box>);
inListType = null; // Header breaks list inListType = null; // Header breaks list
@ -208,12 +308,16 @@ export class MarkdownRenderer {
const marker = ulMatch[1]; // *, -, or + const marker = ulMatch[1]; // *, -, or +
const itemText = ulMatch[2]; const itemText = ulMatch[2];
// If previous line was not UL, maybe add spacing? For now, just render item. // If previous line was not UL, maybe add spacing? For now, just render item.
contentBlocks.push(MarkdownRenderer._renderListItem(key, itemText, 'ul', marker)); contentBlocks.push(
MarkdownRenderer._renderListItem(key, itemText, 'ul', marker),
);
inListType = 'ul'; // Set/maintain list context inListType = 'ul'; // Set/maintain list context
} else if (olMatch) { } else if (olMatch) {
const marker = olMatch[1]; // The number const marker = olMatch[1]; // The number
const itemText = olMatch[2]; const itemText = olMatch[2];
contentBlocks.push(MarkdownRenderer._renderListItem(key, itemText, 'ol', marker)); contentBlocks.push(
MarkdownRenderer._renderListItem(key, itemText, 'ol', marker),
);
inListType = 'ol'; // Set/maintain list context inListType = 'ol'; // Set/maintain list context
} else { } else {
// --- Regular line (Paragraph or Empty line) --- // --- Regular line (Paragraph or Empty line) ---
@ -221,15 +325,18 @@ export class MarkdownRenderer {
// Render line content if it's not blank, applying inline styles // Render line content if it's not blank, applying inline styles
const renderedLine = MarkdownRenderer._renderInline(line); const renderedLine = MarkdownRenderer._renderInline(line);
if (renderedLine.length > 0 || line.length > 0) { // Render lines with content or only whitespace if (renderedLine.length > 0 || line.length > 0) {
// Render lines with content or only whitespace
contentBlocks.push( contentBlocks.push(
<Box key={key}> <Box key={key}>
<Text wrap="wrap">{renderedLine}</Text> <Text wrap="wrap">{renderedLine}</Text>
</Box> </Box>,
); );
} else if (line.trim().length === 0) { // Handle specifically empty lines } else if (line.trim().length === 0) {
// Handle specifically empty lines
// Add minimal space for blank lines between paragraphs/blocks // Add minimal space for blank lines between paragraphs/blocks
if (contentBlocks.length > 0 && !inCodeBlock) { // Avoid adding space inside code block state (handled above) if (contentBlocks.length > 0 && !inCodeBlock) {
// Avoid adding space inside code block state (handled above)
const previousBlock = contentBlocks[contentBlocks.length - 1]; const previousBlock = contentBlocks[contentBlocks.length - 1];
// Avoid adding multiple blank lines consecutively easily - check if previous was also blank? // Avoid adding multiple blank lines consecutively easily - check if previous was also blank?
// For now, add a minimal spacer for any blank line outside code blocks. // For now, add a minimal spacer for any blank line outside code blocks.
@ -241,7 +348,13 @@ export class MarkdownRenderer {
// Handle unclosed code block at the end of the input // Handle unclosed code block at the end of the input
if (inCodeBlock) { if (inCodeBlock) {
contentBlocks.push(MarkdownRenderer._renderCodeBlock(`line-eof`, codeBlockContent, codeBlockLang)); contentBlocks.push(
MarkdownRenderer._renderCodeBlock(
`line-eof`,
codeBlockContent,
codeBlockLang,
),
);
} }
return contentBlocks; return contentBlocks;

View File

@ -1,18 +1,17 @@
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { SchemaUnion, Type } from "@google/genai"; // Assuming these types exist import { SchemaUnion, Type } from '@google/genai'; // Assuming these types exist
import { GeminiClient } from "../core/gemini-client.js"; // Assuming this path import { GeminiClient } from '../core/gemini-client.js'; // Assuming this path
import { exec } from 'child_process'; // Needed for Windows process check import { exec } from 'child_process'; // Needed for Windows process check
import { promisify } from 'util'; // To promisify exec import { promisify } from 'util'; // To promisify exec
// Promisify child_process.exec for easier async/await usage // Promisify child_process.exec for easier async/await usage
const execAsync = promisify(exec); const execAsync = promisify(exec);
// Define the expected interface for the AI client dependency // Define the expected interface for the AI client dependency
export interface AiClient { export interface AiClient {
generateJson( generateJson(
prompt: any[], // Keep flexible or define a stricter prompt structure type prompt: any[], // Keep flexible or define a stricter prompt structure type
schema: SchemaUnion schema: SchemaUnion,
): Promise<any>; // Ideally, specify the expected JSON structure TAnalysisResult | TAnalysisFailure ): Promise<any>; // Ideally, specify the expected JSON structure TAnalysisResult | TAnalysisFailure
} }
@ -33,7 +32,9 @@ export interface AnalysisFailure {
} }
// Type guard to check if the result is a failure object // Type guard to check if the result is a failure object
function isAnalysisFailure(result: AnalysisResult | AnalysisFailure): result is AnalysisFailure { function isAnalysisFailure(
result: AnalysisResult | AnalysisFailure,
): result is AnalysisFailure {
return (result as AnalysisFailure).inferredStatus === 'AnalysisFailed'; return (result as AnalysisFailure).inferredStatus === 'AnalysisFailed';
} }
@ -54,10 +55,10 @@ export class BackgroundTerminalAnalyzer {
constructor( constructor(
aiClient?: AiClient, // Allow injecting AiClient, default to GeminiClient aiClient?: AiClient, // Allow injecting AiClient, default to GeminiClient
options: { options: {
pollIntervalMs?: number, pollIntervalMs?: number;
maxAttempts?: number, maxAttempts?: number;
initialDelayMs?: number initialDelayMs?: number;
} = {} // Provide default options } = {}, // Provide default options
) { ) {
this.ai = aiClient || new GeminiClient(); // Use injected client or default this.ai = aiClient || new GeminiClient(); // Use injected client or default
this.pollIntervalMs = options.pollIntervalMs ?? 5000; // Default 5 seconds this.pollIntervalMs = options.pollIntervalMs ?? 5000; // Default 5 seconds
@ -78,13 +79,12 @@ export class BackgroundTerminalAnalyzer {
pid: ProcessHandle, pid: ProcessHandle,
tempStdoutFilePath: string, tempStdoutFilePath: string,
tempStderrFilePath: string, tempStderrFilePath: string,
command: string command: string,
): Promise<FinalAnalysisOutcome> { ): Promise<FinalAnalysisOutcome> {
// --- Initial Delay --- // --- Initial Delay ---
// Wait briefly before the first check to allow the process to initialize // Wait briefly before the first check to allow the process to initialize
// and potentially write initial output. // and potentially write initial output.
await new Promise(resolve => setTimeout(resolve, this.initialDelayMs)); await new Promise((resolve) => setTimeout(resolve, this.initialDelayMs));
let attempts = 0; let attempts = 0;
let lastAnalysisResult: AnalysisResult | AnalysisFailure | null = null; let lastAnalysisResult: AnalysisResult | AnalysisFailure | null = null;
@ -100,14 +100,18 @@ export class BackgroundTerminalAnalyzer {
} catch (error: any) { } catch (error: any) {
// If file doesn't exist yet or isn't readable, treat as empty, but log warning // If file doesn't exist yet or isn't readable, treat as empty, but log warning
if (error.code !== 'ENOENT') { if (error.code !== 'ENOENT') {
console.warn(`Attempt ${attempts}: Failed to read stdout file ${tempStdoutFilePath}: ${error.message}`); console.warn(
`Attempt ${attempts}: Failed to read stdout file ${tempStdoutFilePath}: ${error.message}`,
);
} }
} }
try { try {
currentStderr = await fs.readFile(tempStderrFilePath, 'utf-8'); currentStderr = await fs.readFile(tempStderrFilePath, 'utf-8');
} catch (error: any) { } catch (error: any) {
if (error.code !== 'ENOENT') { if (error.code !== 'ENOENT') {
console.warn(`Attempt ${attempts}: Failed to read stderr file ${tempStderrFilePath}: ${error.message}`); console.warn(
`Attempt ${attempts}: Failed to read stderr file ${tempStderrFilePath}: ${error.message}`,
);
} }
} }
@ -118,62 +122,106 @@ export class BackgroundTerminalAnalyzer {
isRunning = await this.isProcessRunning(pid); isRunning = await this.isProcessRunning(pid);
if (!isRunning) { if (!isRunning) {
// Reread files one last time in case output was written just before exit // Reread files one last time in case output was written just before exit
try { currentStdout = await fs.readFile(tempStdoutFilePath, 'utf-8'); } catch {} try {
try { currentStderr = await fs.readFile(tempStderrFilePath, 'utf-8'); } catch {} currentStdout = await fs.readFile(tempStdoutFilePath, 'utf-8');
} catch {}
try {
currentStderr = await fs.readFile(tempStderrFilePath, 'utf-8');
} catch {}
lastAnalysisResult = await this.analyzeOutputWithLLM(currentStdout, currentStderr, command); lastAnalysisResult = await this.analyzeOutputWithLLM(
currentStdout,
currentStderr,
command,
);
if (isAnalysisFailure(lastAnalysisResult)) { if (isAnalysisFailure(lastAnalysisResult)) {
return { status: 'ProcessEnded_AnalysisFailed', summary: `Process ended. Final analysis failed: ${lastAnalysisResult.error}` }; return {
status: 'ProcessEnded_AnalysisFailed',
summary: `Process ended. Final analysis failed: ${lastAnalysisResult.error}`,
};
} }
// Append ProcessEnded to the status determined by the final analysis // Append ProcessEnded to the status determined by the final analysis
return { status: 'ProcessEnded_' + lastAnalysisResult.inferredStatus, summary: `Process ended. Final analysis summary: ${lastAnalysisResult.summary}` }; return {
status: 'ProcessEnded_' + lastAnalysisResult.inferredStatus,
summary: `Process ended. Final analysis summary: ${lastAnalysisResult.summary}`,
};
} }
} catch (procCheckError: any) { } catch (procCheckError: any) {
// Log the error but allow polling to continue, as log analysis might still be useful // Log the error but allow polling to continue, as log analysis might still be useful
console.warn(`Could not check process status for PID ${pid} on attempt ${attempts}: ${procCheckError.message}`); console.warn(
`Could not check process status for PID ${pid} on attempt ${attempts}: ${procCheckError.message}`,
);
// Decide if you want to bail out here or continue analysis based on logs only // Decide if you want to bail out here or continue analysis based on logs only
// For now, we continue. // For now, we continue.
} }
// --- LLM Analysis --- // --- LLM Analysis ---
lastAnalysisResult = await this.analyzeOutputWithLLM(currentStdout, currentStderr, command); lastAnalysisResult = await this.analyzeOutputWithLLM(
currentStdout,
currentStderr,
command,
);
if (isAnalysisFailure(lastAnalysisResult)) { if (isAnalysisFailure(lastAnalysisResult)) {
console.error(`LLM Analysis failed for PID ${pid} on attempt ${attempts}:`, lastAnalysisResult.error); console.error(
`LLM Analysis failed for PID ${pid} on attempt ${attempts}:`,
lastAnalysisResult.error,
);
// Stop polling on analysis failure, returning the specific failure status // Stop polling on analysis failure, returning the specific failure status
return { status: lastAnalysisResult.inferredStatus, summary: lastAnalysisResult.error }; return {
status: lastAnalysisResult.inferredStatus,
summary: lastAnalysisResult.error,
};
} }
// --- Exit Conditions --- // --- Exit Conditions ---
if (lastAnalysisResult.inferredStatus === 'SuccessReported' || lastAnalysisResult.inferredStatus === 'ErrorReported') { if (
return { status: lastAnalysisResult.inferredStatus, summary: lastAnalysisResult.summary }; lastAnalysisResult.inferredStatus === 'SuccessReported' ||
lastAnalysisResult.inferredStatus === 'ErrorReported'
) {
return {
status: lastAnalysisResult.inferredStatus,
summary: lastAnalysisResult.summary,
};
} }
// Heuristic: If the process seems stable and 'Running' after several checks, // Heuristic: If the process seems stable and 'Running' after several checks,
// return that status without waiting for the full timeout. Adjust threshold as needed. // return that status without waiting for the full timeout. Adjust threshold as needed.
const runningExitThreshold = Math.floor(this.maxAttempts / 3) + 1; // e.g., exit after attempt 4 if maxAttempts is 6 const runningExitThreshold = Math.floor(this.maxAttempts / 3) + 1; // e.g., exit after attempt 4 if maxAttempts is 6
if (attempts >= runningExitThreshold && lastAnalysisResult.inferredStatus === 'Running') { if (
return { status: lastAnalysisResult.inferredStatus, summary: lastAnalysisResult.summary }; attempts >= runningExitThreshold &&
lastAnalysisResult.inferredStatus === 'Running'
) {
return {
status: lastAnalysisResult.inferredStatus,
summary: lastAnalysisResult.summary,
};
} }
// --- Wait before next poll --- // --- Wait before next poll ---
if (attempts < this.maxAttempts) { if (attempts < this.maxAttempts) {
await new Promise(resolve => setTimeout(resolve, this.pollIntervalMs)); await new Promise((resolve) =>
setTimeout(resolve, this.pollIntervalMs),
);
} }
} // End while loop } // End while loop
// --- Timeout Condition --- // --- Timeout Condition ---
console.warn(`Polling timed out for PID ${pid} after ${this.maxAttempts} attempts.`); console.warn(
`Polling timed out for PID ${pid} after ${this.maxAttempts} attempts.`,
);
// Determine final status based on the last successful analysis (if any) // Determine final status based on the last successful analysis (if any)
const finalStatus = (lastAnalysisResult && !isAnalysisFailure(lastAnalysisResult)) const finalStatus =
lastAnalysisResult && !isAnalysisFailure(lastAnalysisResult)
? `TimedOut_${lastAnalysisResult.inferredStatus}` // e.g., TimedOut_Running ? `TimedOut_${lastAnalysisResult.inferredStatus}` // e.g., TimedOut_Running
: 'TimedOut_AnalysisFailed'; // If last attempt failed or no analysis succeeded : 'TimedOut_AnalysisFailed'; // If last attempt failed or no analysis succeeded
const finalSummary = (lastAnalysisResult && !isAnalysisFailure(lastAnalysisResult)) const finalSummary =
lastAnalysisResult && !isAnalysisFailure(lastAnalysisResult)
? `Polling timed out after ${this.maxAttempts} attempts. Last known summary: ${lastAnalysisResult.summary}` ? `Polling timed out after ${this.maxAttempts} attempts. Last known summary: ${lastAnalysisResult.summary}`
: (lastAnalysisResult && isAnalysisFailure(lastAnalysisResult)) : lastAnalysisResult && isAnalysisFailure(lastAnalysisResult)
? `Polling timed out; last analysis attempt failed: ${lastAnalysisResult}` ? `Polling timed out; last analysis attempt failed: ${lastAnalysisResult}`
: `Polling timed out after ${this.maxAttempts} attempts without any successful analysis.`; : `Polling timed out after ${this.maxAttempts} attempts without any successful analysis.`;
@ -189,7 +237,9 @@ export class BackgroundTerminalAnalyzer {
*/ */
private async isProcessRunning(pid: ProcessHandle): Promise<boolean> { private async isProcessRunning(pid: ProcessHandle): Promise<boolean> {
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) { if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
console.warn(`isProcessRunning: Invalid PID provided (${pid}). Assuming not running.`); console.warn(
`isProcessRunning: Invalid PID provided (${pid}). Assuming not running.`,
);
return false; return false;
} }
@ -210,14 +260,19 @@ export class BackgroundTerminalAnalyzer {
if (error.code === 'ESRCH') { if (error.code === 'ESRCH') {
// ESRCH: Standard error code for "No such process" on Unix-like systems // ESRCH: Standard error code for "No such process" on Unix-like systems
return false; return false;
} else if (process.platform === 'win32' && error.message.includes('No tasks are running')) { } else if (
process.platform === 'win32' &&
error.message.includes('No tasks are running')
) {
// tasklist specific error when PID doesn't exist // tasklist specific error when PID doesn't exist
return false; return false;
} else { } else {
// Other errors (e.g., EPERM - permission denied) mean we couldn't determine status. // Other errors (e.g., EPERM - permission denied) mean we couldn't determine status.
// Re-throwing might be appropriate depending on desired behavior. // Re-throwing might be appropriate depending on desired behavior.
// Here, we log it and cautiously return true, assuming it *might* still be running. // Here, we log it and cautiously return true, assuming it *might* still be running.
console.warn(`isProcessRunning(${pid}) encountered error: ${error.message}. Assuming process might still exist.`); console.warn(
`isProcessRunning(${pid}) encountered error: ${error.message}. Assuming process might still exist.`,
);
// Or you could throw the error: throw new Error(`Failed to check process status for PID ${pid}: ${error.message}`); // Or you could throw the error: throw new Error(`Failed to check process status for PID ${pid}: ${error.message}`);
return true; // Cautious assumption return true; // Cautious assumption
} }
@ -228,23 +283,25 @@ export class BackgroundTerminalAnalyzer {
private async analyzeOutputWithLLM( private async analyzeOutputWithLLM(
stdout: string, stdout: string,
stderr: string, stderr: string,
command: string command: string,
): Promise<AnalysisResult | AnalysisFailure> { ): Promise<AnalysisResult | AnalysisFailure> {
try { try {
const schema: SchemaUnion = { /* ... schema definition remains the same ... */ const schema: SchemaUnion = {
type: Type.OBJECT, /* ... schema definition remains the same ... */ type: Type.OBJECT,
properties: { properties: {
summary: { summary: {
type: Type.STRING, type: Type.STRING,
description: "A concise interpretation of significant events, progress, final results, or errors found in the process's stdout and stderr. Summarizes what the logs indicate happened. Should be formatted as markdown." description:
"A concise interpretation of significant events, progress, final results, or errors found in the process's stdout and stderr. Summarizes what the logs indicate happened. Should be formatted as markdown.",
}, },
inferredStatus: { inferredStatus: {
type: Type.STRING, type: Type.STRING,
description: "The inferred status based *only* on analyzing the provided log content. Possible values: 'Running' (logs show ongoing activity without completion/error), 'SuccessReported' (logs indicate successful completion or final positive result), 'ErrorReported' (logs indicate an error or failure), 'Unknown' (status cannot be clearly determined from the log content).", description:
enum: ['Running', 'SuccessReported', 'ErrorReported', 'Unknown'] "The inferred status based *only* on analyzing the provided log content. Possible values: 'Running' (logs show ongoing activity without completion/error), 'SuccessReported' (logs indicate successful completion or final positive result), 'ErrorReported' (logs indicate an error or failure), 'Unknown' (status cannot be clearly determined from the log content).",
} enum: ['Running', 'SuccessReported', 'ErrorReported', 'Unknown'],
}, },
required: ['summary', 'inferredStatus'] },
required: ['summary', 'inferredStatus'],
}; };
const prompt = `**Analyze Background Process Logs** const prompt = `**Analyze Background Process Logs**
@ -277,48 +334,84 @@ Based *only* on the provided stdout and stderr:
3. **Format Output:** Return the results as a JSON object adhering strictly to the following schema: 3. **Format Output:** Return the results as a JSON object adhering strictly to the following schema:
\`\`\`json \`\`\`json
${JSON.stringify({ // Generate the schema JSON string for the prompt context ${JSON.stringify(
type: "object", {
// Generate the schema JSON string for the prompt context
type: 'object',
properties: { properties: {
summary: { type: "string", description: "Concise markdown summary of log interpretation." }, summary: {
inferredStatus: { type: "string", enum: ["Running", "SuccessReported", "ErrorReported", "Unknown"], description: "Status inferred *only* from log content." } type: 'string',
description: 'Concise markdown summary of log interpretation.',
}, },
required: ["summary", "inferredStatus"] inferredStatus: {
}, null, 2)} type: 'string',
enum: ['Running', 'SuccessReported', 'ErrorReported', 'Unknown'],
description: 'Status inferred *only* from log content.',
},
},
required: ['summary', 'inferredStatus'],
},
null,
2,
)}
\`\`\` \`\`\`
**Instructions:** **Instructions:**
* The \`summary\` must be an interpretation of the logs, focusing on key outcomes or activities. Prioritize recent events if logs are extensive. * The \`summary\` must be an interpretation of the logs, focusing on key outcomes or activities. Prioritize recent events if logs are extensive.
* The \`inferredStatus\` should reflect the most likely state *deduced purely from the log text provided*. Ensure it is one of the specified enum values.`; * The \`inferredStatus\` should reflect the most likely state *deduced purely from the log text provided*. Ensure it is one of the specified enum values.`;
const response = await this.ai.generateJson([{ role: "user", parts: [{ text: prompt }] }], schema); const response = await this.ai.generateJson(
[{ role: 'user', parts: [{ text: prompt }] }],
schema,
);
// --- Enhanced Validation --- // --- Enhanced Validation ---
if (typeof response !== 'object' || response === null) { if (typeof response !== 'object' || response === null) {
throw new Error(`LLM returned non-object response: ${JSON.stringify(response)}`); throw new Error(
`LLM returned non-object response: ${JSON.stringify(response)}`,
);
} }
if (typeof response.summary !== 'string' || response.summary.trim() === '') { if (
typeof response.summary !== 'string' ||
response.summary.trim() === ''
) {
// Ensure summary is a non-empty string // Ensure summary is a non-empty string
console.warn("LLM response validation warning: 'summary' field is missing, empty or not a string. Raw response:", response); console.warn(
"LLM response validation warning: 'summary' field is missing, empty or not a string. Raw response:",
response,
);
// Decide how to handle: throw error, or assign default? Let's throw for now. // Decide how to handle: throw error, or assign default? Let's throw for now.
throw new Error(`LLM response missing or invalid 'summary'. Got: ${JSON.stringify(response.summary)}`); throw new Error(
`LLM response missing or invalid 'summary'. Got: ${JSON.stringify(response.summary)}`,
);
} }
if (!['Running', 'SuccessReported', 'ErrorReported', 'Unknown'].includes(response.inferredStatus)) { if (
console.warn(`LLM response validation warning: 'inferredStatus' is invalid ('${response.inferredStatus}'). Raw response:`, response); !['Running', 'SuccessReported', 'ErrorReported', 'Unknown'].includes(
response.inferredStatus,
)
) {
console.warn(
`LLM response validation warning: 'inferredStatus' is invalid ('${response.inferredStatus}'). Raw response:`,
response,
);
// Decide how to handle: throw error, or default to 'Unknown'? Let's throw. // Decide how to handle: throw error, or default to 'Unknown'? Let's throw.
throw new Error(`LLM returned invalid 'inferredStatus': ${JSON.stringify(response.inferredStatus)}`); throw new Error(
`LLM returned invalid 'inferredStatus': ${JSON.stringify(response.inferredStatus)}`,
);
} }
return response as AnalysisResult; // Cast after validation return response as AnalysisResult; // Cast after validation
} catch (error: any) { } catch (error: any) {
console.error(`LLM analysis call failed for command "${command}":`, error); console.error(
`LLM analysis call failed for command "${command}":`,
error,
);
// Ensure the error message passed back is helpful // Ensure the error message passed back is helpful
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage =
error instanceof Error ? error.message : String(error);
return { return {
error: `LLM analysis call encountered an error: ${errorMessage}`, error: `LLM analysis call encountered an error: ${errorMessage}`,
inferredStatus: 'AnalysisFailed' inferredStatus: 'AnalysisFailed',
}; };
} }
} }

View File

@ -18,11 +18,12 @@ interface FolderStructureOptions {
} }
// Define a type for the merged options where fileIncludePattern remains optional // Define a type for the merged options where fileIncludePattern remains optional
type MergedFolderStructureOptions = Required<Omit<FolderStructureOptions, 'fileIncludePattern'>> & { type MergedFolderStructureOptions = Required<
Omit<FolderStructureOptions, 'fileIncludePattern'>
> & {
fileIncludePattern?: RegExp; fileIncludePattern?: RegExp;
}; };
/** Represents the full, unfiltered information about a folder and its contents. */ /** Represents the full, unfiltered information about a folder and its contents. */
interface FullFolderInfo { interface FullFolderInfo {
name: string; name: string;
@ -55,7 +56,7 @@ interface ReducedFolderNode {
*/ */
async function readFullStructure( async function readFullStructure(
folderPath: string, folderPath: string,
options: MergedFolderStructureOptions options: MergedFolderStructureOptions,
): Promise<FullFolderInfo | null> { ): Promise<FullFolderInfo | null> {
const name = path.basename(folderPath); const name = path.basename(folderPath);
// Initialize with isIgnored: false // Initialize with isIgnored: false
@ -99,7 +100,12 @@ async function readFullStructure(
// If not ignored, recurse as before // If not ignored, recurse as before
const subFolderInfo = await readFullStructure(subFolderPath, options); const subFolderInfo = await readFullStructure(subFolderPath, options);
// Add non-empty folders OR explicitly ignored folders // Add non-empty folders OR explicitly ignored folders
if (subFolderInfo && (subFolderInfo.totalChildren > 0 || subFolderInfo.files.length > 0 || subFolderInfo.isIgnored)) { if (
subFolderInfo &&
(subFolderInfo.totalChildren > 0 ||
subFolderInfo.files.length > 0 ||
subFolderInfo.isIgnored)
) {
folderInfo.subFolders.push(subFolderInfo); folderInfo.subFolders.push(subFolderInfo);
} }
} }
@ -110,7 +116,10 @@ async function readFullStructure(
if (entry.isFile()) { if (entry.isFile()) {
const fileName = entry.name; const fileName = entry.name;
// Include if no pattern or if pattern matches // Include if no pattern or if pattern matches
if (!options.fileIncludePattern || options.fileIncludePattern.test(fileName)) { if (
!options.fileIncludePattern ||
options.fileIncludePattern.test(fileName)
) {
folderInfo.files.push(fileName); folderInfo.files.push(fileName);
} }
} }
@ -118,13 +127,19 @@ async function readFullStructure(
// Calculate totals *after* processing children // Calculate totals *after* processing children
// Ignored folders contribute 0 to counts here because we didn't look inside. // Ignored folders contribute 0 to counts here because we didn't look inside.
totalFileCount = folderInfo.files.length + folderInfo.subFolders.reduce((sum, sf) => sum + sf.totalFiles, 0); totalFileCount =
folderInfo.files.length +
folderInfo.subFolders.reduce((sum, sf) => sum + sf.totalFiles, 0);
// Count the ignored folder itself as one child item in the parent's count. // Count the ignored folder itself as one child item in the parent's count.
totalChildrenCount = folderInfo.files.length + folderInfo.subFolders.length + folderInfo.subFolders.reduce((sum, sf) => sum + sf.totalChildren, 0); totalChildrenCount =
folderInfo.files.length +
folderInfo.subFolders.length +
folderInfo.subFolders.reduce((sum, sf) => sum + sf.totalChildren, 0);
} catch (error: any) { } catch (error: any) {
if (error.code === 'EACCES' || error.code === 'ENOENT') { if (error.code === 'EACCES' || error.code === 'ENOENT') {
console.warn(`Warning: Could not read directory ${folderPath}: ${error.message}`); console.warn(
`Warning: Could not read directory ${folderPath}: ${error.message}`,
);
return null; return null;
} }
throw error; throw error;
@ -148,10 +163,18 @@ async function readFullStructure(
function reduceStructure( function reduceStructure(
fullInfo: FullFolderInfo, fullInfo: FullFolderInfo,
maxItems: number, maxItems: number,
ignoredFolders: Set<string> // Pass ignoredFolders for checking ignoredFolders: Set<string>, // Pass ignoredFolders for checking
): ReducedFolderNode { ): ReducedFolderNode {
const rootReducedNode: ReducedFolderNode = { name: fullInfo.name, files: [], subFolders: [], isRoot: true }; const rootReducedNode: ReducedFolderNode = {
const queue: Array<{ original: FullFolderInfo; reduced: ReducedFolderNode }> = []; name: fullInfo.name,
files: [],
subFolders: [],
isRoot: true,
};
const queue: Array<{
original: FullFolderInfo;
reduced: ReducedFolderNode;
}> = [];
// Don't count the root itself towards the limit initially // Don't count the root itself towards the limit initially
queue.push({ original: fullInfo, reduced: rootReducedNode }); queue.push({ original: fullInfo, reduced: rootReducedNode });
@ -212,10 +235,13 @@ function reduceStructure(
}; };
reducedFolder.subFolders.push(ignoredReducedNode); reducedFolder.subFolders.push(ignoredReducedNode);
// DO NOT add the ignored folder to the queue for further processing // DO NOT add the ignored folder to the queue for further processing
} } else {
else {
// If not ignored and within limit, create the reduced node and add to queue // If not ignored and within limit, create the reduced node and add to queue
const reducedSubFolder: ReducedFolderNode = { name: subFolder.name, files: [], subFolders: [] }; const reducedSubFolder: ReducedFolderNode = {
name: subFolder.name,
files: [],
subFolders: [],
};
reducedFolder.subFolders.push(reducedSubFolder); reducedFolder.subFolders.push(reducedSubFolder);
queue.push({ original: subFolder, reduced: reducedSubFolder }); queue.push({ original: subFolder, reduced: reducedSubFolder });
} }
@ -235,7 +261,10 @@ function countReducedItems(node: ReducedFolderNode): number {
count += node.subFolders.length; count += node.subFolders.length;
for (const sub of node.subFolders) { for (const sub of node.subFolders) {
// Check if it's a placeholder ignored/truncated node // Check if it's a placeholder ignored/truncated node
const isTruncatedPlaceholder = (sub.files.length === 1 && sub.files[0] === TRUNCATION_INDICATOR && sub.subFolders.length === 0); const isTruncatedPlaceholder =
sub.files.length === 1 &&
sub.files[0] === TRUNCATION_INDICATOR &&
sub.subFolders.length === 0;
if (!isTruncatedPlaceholder) { if (!isTruncatedPlaceholder) {
count += countReducedItems(sub); count += countReducedItems(sub);
@ -245,7 +274,6 @@ function countReducedItems(node: ReducedFolderNode): number {
return count; return count;
} }
/** /**
* Formats the reduced folder structure into a tree-like string. * Formats the reduced folder structure into a tree-like string.
* (No changes needed in this function) * (No changes needed in this function)
@ -258,9 +286,9 @@ function formatReducedStructure(
node: ReducedFolderNode, node: ReducedFolderNode,
indent: string, indent: string,
isLast: boolean, isLast: boolean,
builder: string[] builder: string[],
): void { ): void {
const connector = isLast ? "└───" : "├───"; const connector = isLast ? '└───' : '├───';
const linePrefix = indent + connector; const linePrefix = indent + connector;
// Don't print the root node's name directly, only its contents // Don't print the root node's name directly, only its contents
@ -268,13 +296,13 @@ function formatReducedStructure(
builder.push(`${linePrefix}${node.name}/`); builder.push(`${linePrefix}${node.name}/`);
} }
const childIndent = indent + (isLast || node.isRoot ? " " : "│ "); // Use " " if last, "│" otherwise const childIndent = indent + (isLast || node.isRoot ? ' ' : '│ '); // Use " " if last, "│" otherwise
// Render files // Render files
const fileCount = node.files.length; const fileCount = node.files.length;
for (let i = 0; i < fileCount; i++) { for (let i = 0; i < fileCount; i++) {
const isLastFile = i === fileCount - 1 && node.subFolders.length === 0; const isLastFile = i === fileCount - 1 && node.subFolders.length === 0;
const fileConnector = isLastFile ? "└───" : "├───"; const fileConnector = isLastFile ? '└───' : '├───';
builder.push(`${childIndent}${fileConnector}${node.files[i]}`); builder.push(`${childIndent}${fileConnector}${node.files[i]}`);
} }
@ -299,7 +327,7 @@ function formatReducedStructure(
*/ */
export async function getFolderStructure( export async function getFolderStructure(
directory: string, directory: string,
options?: FolderStructureOptions options?: FolderStructureOptions,
): Promise<string> { ): Promise<string> {
const resolvedPath = path.resolve(directory); const resolvedPath = path.resolve(directory);
const mergedOptions: MergedFolderStructureOptions = { const mergedOptions: MergedFolderStructureOptions = {
@ -317,31 +345,38 @@ export async function getFolderStructure(
} }
// 2. Reduce the structure (handles ignored folders specifically) // 2. Reduce the structure (handles ignored folders specifically)
const reducedRoot = reduceStructure(fullInfo, mergedOptions.maxItems, mergedOptions.ignoredFolders); const reducedRoot = reduceStructure(
fullInfo,
mergedOptions.maxItems,
mergedOptions.ignoredFolders,
);
// 3. Count items in the *reduced* structure for the summary // 3. Count items in the *reduced* structure for the summary
const rootNodeItselfCount = 0; // Don't count the root node in the items summary const rootNodeItselfCount = 0; // Don't count the root node in the items summary
const reducedItemCount = countReducedItems(reducedRoot) - rootNodeItselfCount; const reducedItemCount =
countReducedItems(reducedRoot) - rootNodeItselfCount;
// 4. Format the reduced structure into a string // 4. Format the reduced structure into a string
const structureLines: string[] = []; const structureLines: string[] = [];
formatReducedStructure(reducedRoot, "", true, structureLines); formatReducedStructure(reducedRoot, '', true, structureLines);
// 5. Build the final output string // 5. Build the final output string
const displayPath = resolvedPath.replace(/\\/g, '/'); const displayPath = resolvedPath.replace(/\\/g, '/');
const totalOriginalChildren = fullInfo.totalChildren; const totalOriginalChildren = fullInfo.totalChildren;
let disclaimer = ""; let disclaimer = '';
// Check if any truncation happened OR if ignored folders were present // Check if any truncation happened OR if ignored folders were present
if (reducedItemCount < totalOriginalChildren || fullInfo.subFolders.some(sf => sf.isIgnored)) { if (
reducedItemCount < totalOriginalChildren ||
fullInfo.subFolders.some((sf) => sf.isIgnored)
) {
disclaimer = `Folders or files indicated with ${TRUNCATION_INDICATOR} contain more items not shown or were ignored.`; disclaimer = `Folders or files indicated with ${TRUNCATION_INDICATOR} contain more items not shown or were ignored.`;
} }
const summary = `Showing ${reducedItemCount} of ${totalOriginalChildren} items (files + folders). ${disclaimer}`.trim(); const summary =
`Showing ${reducedItemCount} of ${totalOriginalChildren} items (files + folders). ${disclaimer}`.trim();
return `${summary}\n\n${displayPath}/\n${structureLines.join('\n')}`; return `${summary}\n\n${displayPath}/\n${structureLines.join('\n')}`;
} catch (error: any) { } catch (error: any) {
console.error(`Error getting folder structure for ${resolvedPath}:`, error); console.error(`Error getting folder structure for ${resolvedPath}:`, error);
return `Error processing directory "${resolvedPath}": ${error.message}`; return `Error processing directory "${resolvedPath}": ${error.message}`;

View File

@ -23,7 +23,7 @@ export function shortenPath(filePath: string, maxLen: number = 35): string {
// Get segments of the path *after* the root // Get segments of the path *after* the root
const relativePath = filePath.substring(root.length); const relativePath = filePath.substring(root.length);
const segments = relativePath.split(separator).filter(s => s !== ''); // Filter out empty segments const segments = relativePath.split(separator).filter((s) => s !== ''); // Filter out empty segments
// Handle cases with no segments after root (e.g., "/", "C:\") or only one segment // Handle cases with no segments after root (e.g., "/", "C:\") or only one segment
if (segments.length <= 1) { if (segments.length <= 1) {
@ -78,7 +78,6 @@ export function shortenPath(filePath: string, maxLen: number = 35): string {
return `${start}...${end}`; return `${start}...${end}`;
} }
return result; return result;
} }
@ -91,7 +90,10 @@ export function shortenPath(filePath: string, maxLen: number = 35): string {
* @param rootDirectory The absolute path of the directory to make the target path relative to. * @param rootDirectory The absolute path of the directory to make the target path relative to.
* @returns The relative path from rootDirectory to targetPath. * @returns The relative path from rootDirectory to targetPath.
*/ */
export function makeRelative(targetPath: string, rootDirectory: string): string { export function makeRelative(
targetPath: string,
rootDirectory: string,
): string {
const resolvedTargetPath = path.resolve(targetPath); const resolvedTargetPath = path.resolve(targetPath);
const resolvedRootDirectory = path.resolve(rootDirectory); const resolvedRootDirectory = path.resolve(rootDirectory);

View File

@ -34,10 +34,14 @@ export class SchemaValidator {
for (const [key, prop] of Object.entries(properties)) { for (const [key, prop] of Object.entries(properties)) {
if (dataObj[key] !== undefined && prop.type) { if (dataObj[key] !== undefined && prop.type) {
const expectedType = prop.type; const expectedType = prop.type;
const actualType = Array.isArray(dataObj[key]) ? 'array' : typeof dataObj[key]; const actualType = Array.isArray(dataObj[key])
? 'array'
: typeof dataObj[key];
if (expectedType !== actualType) { if (expectedType !== actualType) {
console.error(`Type mismatch for property "${key}": expected ${expectedType}, got ${actualType}`); console.error(
`Type mismatch for property "${key}": expected ${expectedType}, got ${actualType}`,
);
return false; return false;
} }
} }

View File

@ -4,19 +4,10 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"jsx": "react", "jsx": "react",
"lib": [ "lib": ["DOM", "DOM.Iterable", "ES2020"],
"DOM",
"DOM.Iterable",
"ES2020"
],
"module": "Node16", "module": "Node16",
"target": "ES2020", "target": "ES2020"
}, },
"exclude": [ "exclude": ["node_modules", "dist"],
"node_modules", "include": ["src"]
"dist"
],
"include": [
"src"
]
} }