edit file tools

This commit is contained in:
dal 2025-10-03 09:07:20 -06:00
parent d90fdbac04
commit 775b09b6a3
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
8 changed files with 252 additions and 6 deletions

View File

@ -0,0 +1,165 @@
import { Box, Text, useInput } from 'ink';
import React, { useState } from 'react';
import path from 'node:path';
import type { AgentMessage } from '../services/analytics-engineer-handler';
interface EditMessageProps {
message: Extract<AgentMessage, { kind: 'edit' }>;
}
interface ParsedDiffLine {
lineNumber?: number;
type: 'add' | 'remove' | 'context';
content: string;
}
/**
* Parse unified diff format into structured line data
*/
function parseDiff(diff: string): { lines: ParsedDiffLine[]; additions: number; removals: number } {
const lines: ParsedDiffLine[] = [];
let additions = 0;
let removals = 0;
let oldLineNumber = 0;
let newLineNumber = 0;
const diffLines = diff.split('\n');
for (const line of diffLines) {
// Skip header lines
if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('@@')) {
// Extract line numbers from @@ marker
if (line.startsWith('@@')) {
const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/);
if (match) {
oldLineNumber = parseInt(match[1] || '1', 10);
newLineNumber = parseInt(match[2] || '1', 10);
}
}
continue;
}
if (line.startsWith('+')) {
lines.push({ lineNumber: newLineNumber++, type: 'add', content: line.slice(1) });
additions++;
} else if (line.startsWith('-')) {
lines.push({ lineNumber: oldLineNumber++, type: 'remove', content: line.slice(1) });
removals++;
} else if (line.startsWith(' ')) {
lines.push({ lineNumber: newLineNumber++, type: 'context', content: line.slice(1) });
oldLineNumber++;
}
}
return { lines, additions, removals };
}
/**
* Component for displaying file edit operations with diff view
* Shows UPDATE badge, file summary, and colored diff
* Supports expansion with Ctrl+O to show full diff
*/
export function EditMessage({ message }: EditMessageProps) {
const [isExpanded, setIsExpanded] = useState(false);
// Handle Ctrl+O to toggle expansion
useInput((input, key) => {
if (key.ctrl && input === 'o') {
setIsExpanded((prev) => !prev);
}
});
const { args, result } = message;
if (!result) {
return null;
}
// Get relative path from cwd
const relativePath = path.relative(process.cwd(), result.filePath);
// Get diff (either from single edit or multi-edit)
const diffString = result.diff || result.finalDiff;
if (!diffString) {
return (
<Box flexDirection="column">
<Box flexDirection="row">
<Text bold color="white" backgroundColor="cyan">
UPDATE
</Text>
<Text color="#94a3b8"> ({relativePath})</Text>
</Box>
<Box paddingLeft={2}>
<Text color={result.success ? '#64748b' : 'red'} dimColor>
{result.success ? result.message || 'File updated' : result.errorMessage || 'Update failed'}
</Text>
</Box>
</Box>
);
}
// Parse the diff
const { lines, additions, removals } = parseDiff(diffString);
// Show first 10 lines when not expanded, all lines when expanded
const displayLines = isExpanded ? lines : lines.slice(0, 10);
return (
<Box flexDirection="column">
{/* UPDATE badge with file summary */}
<Box flexDirection="row">
<Text bold color="white" backgroundColor="cyan">
UPDATE
</Text>
<Text color="#94a3b8">
{' '}({relativePath})
</Text>
</Box>
{/* Summary line */}
<Box paddingLeft={2}>
<Text color="#64748b" dimColor>
Updated {relativePath} with {additions} addition{additions !== 1 ? 's' : ''} and {removals} removal{removals !== 1 ? 's' : ''}
</Text>
</Box>
{/* Diff lines - always show with indentation */}
{displayLines.length > 0 && (
<Box flexDirection="column" paddingLeft={2}>
{displayLines.map((line, idx) => {
// Format line number if present
const lineNum = line.lineNumber ? `${line.lineNumber}`.padStart(4, ' ') : ' ';
// Choose background color and prefix based on line type
let backgroundColor: string | undefined = undefined;
let prefix = ' ';
if (line.type === 'add') {
backgroundColor = '#10b981'; // green background for additions
prefix = '+';
} else if (line.type === 'remove') {
backgroundColor = '#ef4444'; // red background for removals
prefix = '-';
}
return (
<Text key={idx} color="#e0e7ff" backgroundColor={backgroundColor}>
{lineNum} {prefix} {line.content}
</Text>
);
})}
</Box>
)}
{/* Expansion hint if diff is long */}
{lines.length > 10 && (
<Box paddingLeft={2}>
<Text color="#64748b" dimColor>
{isExpanded ? '(Press Ctrl+O to collapse)' : `... +${lines.length - 10} lines (Press Ctrl+O to expand)`}
</Text>
</Box>
)}
</Box>
);
}

View File

@ -3,6 +3,7 @@ import React from 'react';
import type { AgentMessage } from '../services/analytics-engineer-handler';
import { ExecuteMessage } from './execute-message';
import { WriteMessage } from './write-message';
import { EditMessage } from './edit-message';
interface AgentMessageComponentProps {
message: AgentMessage;
@ -47,6 +48,10 @@ export function AgentMessageComponent({ message }: AgentMessageComponentProps) {
// For write operations, use the WriteMessage component
return <WriteMessage message={message} />;
case 'edit':
// For edit operations, use the EditMessage component
return <EditMessage message={message} />;
default:
return null;
}

View File

@ -42,6 +42,12 @@ export type AgentMessage =
event: 'start' | 'complete';
args: { files: { path: string; content: string }[] };
result?: { results: Array<{ status: 'success' | 'error'; filePath: string; errorMessage?: string }> };
}
| {
kind: 'edit';
event: 'start' | 'complete';
args: { filePath: string; oldString?: string; newString?: string; edits?: Array<{ oldString: string; newString: string }> };
result?: { success: boolean; filePath: string; diff?: string; finalDiff?: string; message?: string; errorMessage?: string };
};
export interface DocsAgentMessage {
@ -140,6 +146,18 @@ export async function runDocsAgent(params: RunDocsAgentParams) {
},
});
}
// Handle edit tool events (both editFileTool and multiEditFileTool) - only show complete to avoid duplicates
if (event.tool === 'editFileTool' && event.event === 'complete') {
onMessage({
message: {
kind: 'edit',
event: 'complete',
args: event.args,
result: event.result, // Type-safe: EditFileToolOutput or MultiEditFileToolOutput
},
});
}
},
});

View File

@ -83,10 +83,12 @@ export function createAnalyticsEngineerAgent(analyticsEngineerAgentOptions: Anal
const editFileTool = createEditFileTool({
messageId: analyticsEngineerAgentOptions.messageId,
projectDirectory: analyticsEngineerAgentOptions.folder_structure,
onToolEvent: analyticsEngineerAgentOptions.onToolEvent,
});
const multiEditFileTool = createMultiEditFileTool({
messageId: analyticsEngineerAgentOptions.messageId,
projectDirectory: analyticsEngineerAgentOptions.folder_structure,
onToolEvent: analyticsEngineerAgentOptions.onToolEvent,
});
const lsTool = createLsTool({
messageId: analyticsEngineerAgentOptions.messageId,

View File

@ -580,11 +580,18 @@ export function createEditFileToolExecute(context: EditFileToolContext) {
return async function execute(
input: EditFileToolInput
): Promise<EditFileToolOutput> {
const { messageId, projectDirectory } = context;
const { messageId, projectDirectory, onToolEvent } = context;
const { filePath, oldString, newString, replaceAll } = input;
console.info(`Editing file ${filePath} for message ${messageId}`);
// Emit start event
onToolEvent?.({
tool: 'editFileTool',
event: 'start',
args: input,
});
try {
// Convert to absolute path if relative
const absolutePath = path.isAbsolute(filePath)
@ -629,21 +636,41 @@ export function createEditFileToolExecute(context: EditFileToolContext) {
console.info(`Successfully edited file: ${absolutePath}`);
return {
const output = {
success: true,
filePath: absolutePath,
message: `Successfully replaced "${oldString}" with "${newString}" in ${filePath}`,
diff,
};
// Emit complete event
onToolEvent?.({
tool: 'editFileTool',
event: 'complete',
result: output,
args: input,
});
return output;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(`Error editing file ${filePath}:`, errorMessage);
return {
const output = {
success: false,
filePath,
errorMessage,
};
// Emit complete event even on error
onToolEvent?.({
tool: 'editFileTool',
event: 'complete',
result: output,
args: input,
});
return output;
}
};
}

View File

@ -27,6 +27,7 @@ export const EditFileToolOutputSchema = z.object({
export const EditFileToolContextSchema = z.object({
messageId: z.string().describe('The message ID for database updates'),
projectDirectory: z.string().describe('The root directory of the project'),
onToolEvent: z.any().optional().describe('Callback for tool events'),
});
export type EditFileToolInput = z.infer<typeof EditFileToolInputSchema>;

View File

@ -77,13 +77,20 @@ export function createMultiEditFileToolExecute(
return async function execute(
input: MultiEditFileToolInput
): Promise<MultiEditFileToolOutput> {
const { messageId, projectDirectory } = context;
const { messageId, projectDirectory, onToolEvent } = context;
const { filePath, edits } = input;
console.info(
`Applying ${edits.length} edit(s) to ${filePath} for message ${messageId}`
);
// Emit start event
onToolEvent?.({
tool: 'editFileTool',
event: 'start',
args: input,
});
try {
// Convert to absolute path if relative
const absolutePath = path.isAbsolute(filePath)
@ -199,24 +206,44 @@ export function createMultiEditFileToolExecute(
console.info(`Successfully applied all ${edits.length} edit(s) to ${absolutePath}`);
return {
const output = {
success: true,
filePath: absolutePath,
editResults,
finalDiff,
message: `Successfully applied ${edits.length} edit(s) to ${filePath}`,
};
// Emit complete event
onToolEvent?.({
tool: 'editFileTool',
event: 'complete',
result: output,
args: input,
});
return output;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
console.error(`Error during multi-edit operation on ${filePath}:`, errorMessage);
return {
const output = {
success: false,
filePath,
editResults: [],
errorMessage,
};
// Emit complete event even on error
onToolEvent?.({
tool: 'editFileTool',
event: 'complete',
result: output,
args: input,
});
return output;
}
};
}

View File

@ -50,6 +50,7 @@ export const MultiEditFileToolOutputSchema = z.object({
export const MultiEditFileToolContextSchema = z.object({
messageId: z.string().describe('The message ID for database updates'),
projectDirectory: z.string().describe('The root directory of the project'),
onToolEvent: z.any().optional().describe('Callback for tool events'),
});
export type MultiEditFileToolInput = z.infer<typeof MultiEditFileToolInputSchema>;