mirror of https://github.com/buster-so/buster.git
edit file tools
This commit is contained in:
parent
d90fdbac04
commit
775b09b6a3
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
Loading…
Reference in New Issue