From 775b09b6a3938faff682ddfefc8f9f0f9f31df72 Mon Sep 17 00:00:00 2001 From: dal Date: Fri, 3 Oct 2025 09:07:20 -0600 Subject: [PATCH] edit file tools --- apps/cli/src/components/edit-message.tsx | 165 ++++++++++++++++++ apps/cli/src/components/typed-message.tsx | 5 + .../services/analytics-engineer-handler.ts | 18 ++ .../analytics-engineer-agent.ts | 2 + .../edit-file-tool/edit-file-tool-execute.ts | 33 +++- .../edit-file-tool/edit-file-tool.ts | 1 + .../multi-edit-file-tool-execute.ts | 33 +++- .../multi-edit-file-tool.ts | 1 + 8 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 apps/cli/src/components/edit-message.tsx diff --git a/apps/cli/src/components/edit-message.tsx b/apps/cli/src/components/edit-message.tsx new file mode 100644 index 000000000..4139cf4d4 --- /dev/null +++ b/apps/cli/src/components/edit-message.tsx @@ -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; +} + +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 ( + + + + UPDATE + + ({relativePath}) + + + + ↳ {result.success ? result.message || 'File updated' : result.errorMessage || 'Update failed'} + + + + ); + } + + // 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 ( + + {/* UPDATE badge with file summary */} + + + UPDATE + + + {' '}({relativePath}) + + + + {/* Summary line */} + + + ↳ Updated {relativePath} with {additions} addition{additions !== 1 ? 's' : ''} and {removals} removal{removals !== 1 ? 's' : ''} + + + + {/* Diff lines - always show with indentation */} + {displayLines.length > 0 && ( + + {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 ( + + {lineNum} {prefix} {line.content} + + ); + })} + + )} + + {/* Expansion hint if diff is long */} + {lines.length > 10 && ( + + + {isExpanded ? '(Press Ctrl+O to collapse)' : `... +${lines.length - 10} lines (Press Ctrl+O to expand)`} + + + )} + + ); +} diff --git a/apps/cli/src/components/typed-message.tsx b/apps/cli/src/components/typed-message.tsx index 2c8802220..90f137190 100644 --- a/apps/cli/src/components/typed-message.tsx +++ b/apps/cli/src/components/typed-message.tsx @@ -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 ; + case 'edit': + // For edit operations, use the EditMessage component + return ; + default: return null; } diff --git a/apps/cli/src/services/analytics-engineer-handler.ts b/apps/cli/src/services/analytics-engineer-handler.ts index 354221138..6c09435eb 100644 --- a/apps/cli/src/services/analytics-engineer-handler.ts +++ b/apps/cli/src/services/analytics-engineer-handler.ts @@ -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 + }, + }); + } }, }); diff --git a/packages/ai/src/agents/analytics-engineer-agent/analytics-engineer-agent.ts b/packages/ai/src/agents/analytics-engineer-agent/analytics-engineer-agent.ts index 5cfbe045e..222bf4369 100644 --- a/packages/ai/src/agents/analytics-engineer-agent/analytics-engineer-agent.ts +++ b/packages/ai/src/agents/analytics-engineer-agent/analytics-engineer-agent.ts @@ -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, diff --git a/packages/ai/src/tools/file-tools/edit-file-tool/edit-file-tool-execute.ts b/packages/ai/src/tools/file-tools/edit-file-tool/edit-file-tool-execute.ts index c5b6bb252..42de2158b 100644 --- a/packages/ai/src/tools/file-tools/edit-file-tool/edit-file-tool-execute.ts +++ b/packages/ai/src/tools/file-tools/edit-file-tool/edit-file-tool-execute.ts @@ -580,11 +580,18 @@ export function createEditFileToolExecute(context: EditFileToolContext) { return async function execute( input: EditFileToolInput ): Promise { - 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; } }; } diff --git a/packages/ai/src/tools/file-tools/edit-file-tool/edit-file-tool.ts b/packages/ai/src/tools/file-tools/edit-file-tool/edit-file-tool.ts index 0d0152e7a..f2a225c1c 100644 --- a/packages/ai/src/tools/file-tools/edit-file-tool/edit-file-tool.ts +++ b/packages/ai/src/tools/file-tools/edit-file-tool/edit-file-tool.ts @@ -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; diff --git a/packages/ai/src/tools/file-tools/multi-edit-file-tool/multi-edit-file-tool-execute.ts b/packages/ai/src/tools/file-tools/multi-edit-file-tool/multi-edit-file-tool-execute.ts index 1c910c1c1..23ad7b5a7 100644 --- a/packages/ai/src/tools/file-tools/multi-edit-file-tool/multi-edit-file-tool-execute.ts +++ b/packages/ai/src/tools/file-tools/multi-edit-file-tool/multi-edit-file-tool-execute.ts @@ -77,13 +77,20 @@ export function createMultiEditFileToolExecute( return async function execute( input: MultiEditFileToolInput ): Promise { - 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; } }; } diff --git a/packages/ai/src/tools/file-tools/multi-edit-file-tool/multi-edit-file-tool.ts b/packages/ai/src/tools/file-tools/multi-edit-file-tool/multi-edit-file-tool.ts index 6af5f047a..05586317d 100644 --- a/packages/ai/src/tools/file-tools/multi-edit-file-tool/multi-edit-file-tool.ts +++ b/packages/ai/src/tools/file-tools/multi-edit-file-tool/multi-edit-file-tool.ts @@ -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;