diff --git a/aider/change_tracker.py b/aider/change_tracker.py new file mode 100644 index 000000000..d06460e99 --- /dev/null +++ b/aider/change_tracker.py @@ -0,0 +1,118 @@ +import time +import uuid +from collections import defaultdict +from datetime import datetime + +class ChangeTracker: + """ + Tracks changes made to files for the undo functionality. + This enables granular editing operations with the ability to undo specific changes. + """ + + def __init__(self): + self.changes = {} # change_id -> change_info + self.files_changed = defaultdict(list) # file_path -> [change_ids] + + def track_change(self, file_path, change_type, original_content, new_content, + metadata=None, change_id=None): + """ + Record a change to enable future undo operations. + + Parameters: + - file_path: Path to the file that was changed + - change_type: Type of change (e.g., 'replacetext', 'insertlines') + - original_content: Original content before the change + - new_content: New content after the change + - metadata: Additional information about the change (line numbers, positions, etc.) + - change_id: Optional custom ID for the change (if None, one will be generated) + + Returns: + - change_id: Unique identifier for the change + """ + if change_id is None: + change_id = self._generate_change_id() + + change = { + 'id': change_id, + 'file_path': file_path, + 'type': change_type, + 'original': original_content, + 'new': new_content, + 'metadata': metadata or {}, + 'timestamp': time.time() + } + + self.changes[change_id] = change + self.files_changed[file_path].append(change_id) + return change_id + + def undo_change(self, change_id): + """ + Get information needed to reverse a specific change by ID. + + Parameters: + - change_id: ID of the change to undo + + Returns: + - (success, message, change_info): Tuple with success flag, message, and change information + """ + if change_id not in self.changes: + return False, f"Change ID {change_id} not found", None + + change = self.changes[change_id] + + # Mark this change as undone by removing it from the tracking dictionaries + self.files_changed[change['file_path']].remove(change_id) + if not self.files_changed[change['file_path']]: + del self.files_changed[change['file_path']] + + # Keep the change in the changes dict but mark it as undone + change['undone'] = True + change['undone_at'] = time.time() + + return True, f"Undid change {change_id} in {change['file_path']}", change + + def get_last_change(self, file_path): + """ + Get the most recent change for a specific file. + + Parameters: + - file_path: Path to the file + + Returns: + - change_id or None if no changes found + """ + changes = self.files_changed.get(file_path, []) + if not changes: + return None + return changes[-1] + + def list_changes(self, file_path=None, limit=10): + """ + List recent changes, optionally filtered by file. + + Parameters: + - file_path: Optional path to filter changes by file + - limit: Maximum number of changes to list + + Returns: + - List of change dictionaries + """ + if file_path: + # Get changes only for the specified file + change_ids = self.files_changed.get(file_path, []) + changes = [self.changes[cid] for cid in change_ids if cid in self.changes] + else: + # Get all changes + changes = list(self.changes.values()) + + # Filter out undone changes and sort by timestamp (most recent first) + changes = [c for c in changes if not c.get('undone', False)] + changes = sorted(changes, key=lambda c: c['timestamp'], reverse=True) + + # Apply limit + return changes[:limit] + + def _generate_change_id(self): + """Generate a unique ID for a change.""" + return str(uuid.uuid4())[:8] # Short, readable ID \ No newline at end of file diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 551039f8f..355634366 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -43,10 +43,6 @@ from ..dump import dump # noqa: F401 from .chat_chunks import ChatChunks -# Pattern to detect fenced search/replace blocks -SEARCH_REPLACE_FENCE = re.compile(r"```search_replace\n", re.MULTILINE) - - class UnknownEditFormat(ValueError): def __init__(self, edit_format, valid_formats): self.edit_format = edit_format diff --git a/aider/coders/navigator_coder.py b/aider/coders/navigator_coder.py index 042622865..40f669b64 100644 --- a/aider/coders/navigator_coder.py +++ b/aider/coders/navigator_coder.py @@ -20,6 +20,8 @@ from aider.repo import ANY_GIT_ERROR from aider import urls # Import run_cmd_subprocess directly for non-interactive execution from aider.run_cmd import run_cmd_subprocess +# Import the change tracker +from aider.change_tracker import ChangeTracker class NavigatorCoder(Coder): """Mode where the LLM autonomously manages which files are in context.""" @@ -43,6 +45,8 @@ class NavigatorCoder(Coder): # Enable context management by default only in navigator mode self.context_management_enabled = True # Enabled by default for navigator mode + # Initialize change tracker for granular editing + self.change_tracker = ChangeTracker() # Track files added during current exploration self.files_added_in_exploration = set() @@ -466,6 +470,17 @@ class NavigatorCoder(Coder): # Extract value based on AST node type if isinstance(value_node, ast.Constant): value = value_node.value + # Check if this is a multiline string and trim whitespace + if isinstance(value, str) and '\n' in value: + # Get the source line(s) for this node to check if it's a triple-quoted string + lineno = value_node.lineno if hasattr(value_node, 'lineno') else 0 + end_lineno = value_node.end_lineno if hasattr(value_node, 'end_lineno') else lineno + if end_lineno > lineno: # It's a multiline string + # Trim exactly one leading and one trailing newline if present + if value.startswith('\n'): + value = value[1:] + if value.endswith('\n'): + value = value[:-1] elif isinstance(value_node, ast.Name): # Handle unquoted values like True/False/None or variables (though variables are unlikely here) value = value_node.id # Add more types if needed (e.g., ast.List, ast.Dict) @@ -562,6 +577,118 @@ class NavigatorCoder(Coder): result_message = self._execute_command(command_string) else: result_message = "Error: Missing 'command_string' parameter for Command" + + # Granular editing tools + elif norm_tool_name == 'replacetext': + file_path = params.get('file_path') + find_text = params.get('find_text') + replace_text = params.get('replace_text') + near_context = params.get('near_context') + occurrence = params.get('occurrence', 1) + change_id = params.get('change_id') + + if file_path is not None and find_text is not None and replace_text is not None: + result_message = self._execute_replace_text( + file_path, find_text, replace_text, near_context, occurrence, change_id + ) + else: + result_message = "Error: Missing required parameters for ReplaceText" + + elif norm_tool_name == 'replaceall': + file_path = params.get('file_path') + find_text = params.get('find_text') + replace_text = params.get('replace_text') + change_id = params.get('change_id') + + if file_path is not None and find_text is not None and replace_text is not None: + result_message = self._execute_replace_all( + file_path, find_text, replace_text, change_id + ) + else: + result_message = "Error: Missing required parameters for ReplaceAll" + + elif norm_tool_name == 'insertblock': + file_path = params.get('file_path') + content = params.get('content') + after_pattern = params.get('after_pattern') + before_pattern = params.get('before_pattern') + change_id = params.get('change_id') + + if file_path is not None and content is not None and (after_pattern is not None or before_pattern is not None): + result_message = self._execute_insert_block( + file_path, content, after_pattern, before_pattern, change_id + ) + else: + result_message = "Error: Missing required parameters for InsertBlock" + + elif norm_tool_name == 'deleteblock': + file_path = params.get('file_path') + start_pattern = params.get('start_pattern') + end_pattern = params.get('end_pattern') + line_count = params.get('line_count') + change_id = params.get('change_id') + + if file_path is not None and start_pattern is not None: + result_message = self._execute_delete_block( + file_path, start_pattern, end_pattern, line_count, change_id + ) + else: + result_message = "Error: Missing required parameters for DeleteBlock" + + elif norm_tool_name == 'replaceline': + file_path = params.get('file_path') + line_number = params.get('line_number') + new_content = params.get('new_content') + change_id = params.get('change_id') + + if file_path is not None and line_number is not None and new_content is not None: + result_message = self._execute_replace_line( + file_path, line_number, new_content, change_id + ) + else: + result_message = "Error: Missing required parameters for ReplaceLine" + + elif norm_tool_name == 'replacelines': + file_path = params.get('file_path') + start_line = params.get('start_line') + end_line = params.get('end_line') + new_content = params.get('new_content') + change_id = params.get('change_id') + + if file_path is not None and start_line is not None and end_line is not None and new_content is not None: + result_message = self._execute_replace_lines( + file_path, start_line, end_line, new_content, change_id + ) + else: + result_message = "Error: Missing required parameters for ReplaceLines" + + elif norm_tool_name == 'indentlines': + file_path = params.get('file_path') + start_pattern = params.get('start_pattern') + end_pattern = params.get('end_pattern') + line_count = params.get('line_count') + indent_levels = params.get('indent_levels', 1) + change_id = params.get('change_id') + + if file_path is not None and start_pattern is not None: + result_message = self._execute_indent_lines( + file_path, start_pattern, end_pattern, line_count, indent_levels, change_id + ) + else: + result_message = "Error: Missing required parameters for IndentLines" + + elif norm_tool_name == 'undochange': + change_id = params.get('change_id') + last_file = params.get('last_file') + + result_message = self._execute_undo_change(change_id, last_file) + + elif norm_tool_name == 'listchanges': + file_path = params.get('file_path') + limit = params.get('limit', 10) + + result_message = self._execute_list_changes(file_path, limit) + else: result_message = f"Error: Unknown tool name '{tool_name}'" @@ -1506,3 +1633,1020 @@ Just reply with fixed versions of the {blocks} above that failed to match. self.io.tool_output("Enhanced context blocks are now OFF - directory structure and git status will not be included.") return True + + # ------------------- Granular Editing Tools ------------------- + + def _execute_replace_text(self, file_path, find_text, replace_text, near_context=None, occurrence=1, change_id=None): + """ + Replace specific text with new text, optionally using nearby context for disambiguation. + + Parameters: + - file_path: Path to the file to modify + - find_text: Text to find and replace + - replace_text: Text to replace it with + - near_context: Optional text nearby to help locate the correct instance + - occurrence: Which occurrence to replace (1-based index, or -1 for last) + - change_id: Optional ID for tracking the change + + Returns a result message. + """ + try: + # Get absolute file path + abs_path = self.abs_root_path(file_path) + rel_path = self.get_rel_fname(abs_path) + + # Check if file exists + if not os.path.isfile(abs_path): + self.io.tool_error(f"File '{file_path}' not found") + return f"Error: File not found" + + # Check if file is in editable context + if abs_path not in self.abs_fnames: + if abs_path in self.abs_read_only_fnames: + self.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.") + return f"Error: File is read-only. Use MakeEditable first." + else: + self.io.tool_error(f"File '{file_path}' not in context") + return f"Error: File not in context" + + # Read file content + content = self.io.read_text(abs_path) + if content is None: + return f"Error reading file: {file_path}" + + # If near_context is provided, narrow down the search + if near_context: + # Find the section containing both find_text and near_context + sections = [] + for i in range(len(content)): + if i + len(find_text) <= len(content) and content[i:i+len(find_text)] == find_text: + # Look for near_context within a reasonable window (e.g., 200 chars) + window_start = max(0, i - 200) + window_end = min(len(content), i + len(find_text) + 200) + window = content[window_start:window_end] + if near_context in window: + sections.append(i) + + if not sections: + self.io.tool_error(f"Could not find '{find_text}' near '{near_context}'") + return f"Error: Text not found near specified context" + + # Select the occurrence (1-based index) + if occurrence == -1: # Last occurrence + start_index = sections[-1] + else: + occurrence_idx = min(occurrence - 1, len(sections) - 1) + start_index = sections[occurrence_idx] + else: + # Find all occurrences of find_text + sections = [] + start = 0 + while True: + start = content.find(find_text, start) + if start == -1: + break + sections.append(start) + start += 1 # Move past this occurrence + + if not sections: + self.io.tool_error(f"Text '{find_text}' not found in file") + return f"Error: Text not found in file" + + # Select the occurrence (1-based index) + if occurrence == -1: # Last occurrence + start_index = sections[-1] + else: + occurrence_idx = min(occurrence - 1, len(sections) - 1) + start_index = sections[occurrence_idx] + + # Perform the replacement + original_content = content + new_content = content[:start_index] + replace_text + content[start_index + len(find_text):] + + if original_content == new_content: + self.io.tool_warning(f"No changes made: replacement text is identical to original") + return f"Warning: No changes made (replacement identical to original)" + + # Write the modified content back to the file + if not self.dry_run: + self.io.write_text(abs_path, new_content) + + # Track the change + metadata = { + 'start_index': start_index, + 'find_text': find_text, + 'replace_text': replace_text, + 'near_context': near_context, + 'occurrence': occurrence + } + change_id = self.change_tracker.track_change( + file_path=rel_path, + change_type='replacetext', + original_content=original_content, + new_content=new_content, + metadata=metadata, + change_id=change_id + ) + + self.aider_edited_files.add(rel_path) + + # Get more context around the replace (get up to 3 lines before and after) + lines = content.splitlines() + found_idx = -1 + for i, line in enumerate(lines): + if start_index < len(''.join(lines[:i+1])) + i: # Account for newlines + found_idx = i + break + + if found_idx != -1: + # Get lines with context + start_line = max(0, found_idx - 3) + end_line = min(len(lines) - 1, found_idx + 3) + + # Format the diff in git style + diff_lines = [] + for i in range(start_line, end_line + 1): + if i == found_idx: + # This is the line containing the change + line = lines[i] + # Find position of match within the line + line_start = start_index - len(''.join(lines[:i])) - i + if line_start >= 0 and line_start + len(find_text) <= len(line): + # If we can isolate the exact position in the line + old_line = line + new_line = line[:line_start] + replace_text + line[line_start + len(find_text):] + diff_lines.append(f"- {old_line}") + diff_lines.append(f"+ {new_line}") + else: + # If we can't isolate exact position (e.g., multi-line match) + diff_lines.append(f"- {line}") + # Try our best approximation for the new line + if find_text in line: + diff_lines.append(f"+ {line.replace(find_text, replace_text)}") + else: + diff_lines.append(f"+ [modified line]") + else: + # Context line, prefix with space + diff_lines.append(f" {lines[i]}") + + diff_example = f"@@ line {start_line+1},{end_line+1} @@\n" + "\n".join(diff_lines) + else: + # Fallback if we can't locate the exact line + diff_example = f"- {find_text}\n+ {replace_text}" + + self.io.tool_output(f"✅ Replaced text in {file_path} (change_id: {change_id})") + return f"Successfully replaced text (change_id: {change_id}):\n{diff_example}" + else: + self.io.tool_output(f"Did not replace text in {file_path} (--dry-run)") + return f"Did not replace text (--dry-run)" + + except Exception as e: + self.io.tool_error(f"Error in ReplaceText: {str(e)}") + return f"Error: {str(e)}" + + def _execute_replace_all(self, file_path, find_text, replace_text, change_id=None): + """ + Replace all occurrences of text in a file. + + Parameters: + - file_path: Path to the file to modify + - find_text: Text to find and replace + - replace_text: Text to replace it with + - change_id: Optional ID for tracking the change + + Returns a result message. + """ + try: + # Get absolute file path + abs_path = self.abs_root_path(file_path) + rel_path = self.get_rel_fname(abs_path) + + # Check if file exists + if not os.path.isfile(abs_path): + self.io.tool_error(f"File '{file_path}' not found") + return f"Error: File not found" + + # Check if file is in editable context + if abs_path not in self.abs_fnames: + if abs_path in self.abs_read_only_fnames: + self.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.") + return f"Error: File is read-only. Use MakeEditable first." + else: + self.io.tool_error(f"File '{file_path}' not in context") + return f"Error: File not in context" + + # Read file content + content = self.io.read_text(abs_path) + if content is None: + return f"Error reading file: {file_path}" + + # Count occurrences + count = content.count(find_text) + if count == 0: + self.io.tool_warning(f"Text '{find_text}' not found in file") + return f"Warning: Text not found in file" + + # Perform the replacement + original_content = content + new_content = content.replace(find_text, replace_text) + + if original_content == new_content: + self.io.tool_warning(f"No changes made: replacement text is identical to original") + return f"Warning: No changes made (replacement identical to original)" + + # Write the modified content back to the file + if not self.dry_run: + self.io.write_text(abs_path, new_content) + + # Track the change + metadata = { + 'find_text': find_text, + 'replace_text': replace_text, + 'occurrences': count + } + change_id = self.change_tracker.track_change( + file_path=rel_path, + change_type='replaceall', + original_content=original_content, + new_content=new_content, + metadata=metadata, + change_id=change_id + ) + + self.aider_edited_files.add(rel_path) + + # Build a mapping of line number to replacements on that line + line_changes = {} + + # Split content into lines + lines = content.splitlines() + + # Keep track of character position across all lines + char_pos = 0 + for line_idx, line in enumerate(lines): + line_len = len(line) + + # Look for occurrences within this line + line_pos = 0 + while line_pos <= line_len - len(find_text): + match_pos = line[line_pos:].find(find_text) + if match_pos == -1: + break + + # Found a match in this line + true_pos = line_pos + match_pos + if line_idx not in line_changes: + line_changes[line_idx] = [] + + line_changes[line_idx].append((true_pos, find_text, replace_text)) + + # Move past this match + line_pos = true_pos + len(find_text) + + # Move to next line (add 1 for the newline) + char_pos += line_len + 1 + + # Generate git-style diffs for each affected line with context + diff_chunks = [] + sorted_changed_lines = sorted(line_changes.keys()) + + # Group adjacent changed lines into chunks + chunks = [] + current_chunk = [] + + for line_idx in sorted_changed_lines: + if not current_chunk or line_idx <= current_chunk[-1] + 6: # Keep chunks within 6 lines + current_chunk.append(line_idx) + else: + chunks.append(current_chunk) + current_chunk = [line_idx] + + if current_chunk: + chunks.append(current_chunk) + + # Generate diff for each chunk + for chunk in chunks: + min_line = max(0, min(chunk) - 3) # 3 lines of context before + max_line = min(len(lines) - 1, max(chunk) + 3) # 3 lines of context after + + diff_lines = [] + diff_lines.append(f"@@ line {min_line+1},{max_line+1} @@") + + for i in range(min_line, max_line + 1): + if i in line_changes: + # This is a line with changes + original_line = lines[i] + modified_line = original_line + + # Apply all replacements to this line + # We need to apply them from right to left to maintain correct positions + changes = sorted(line_changes[i], key=lambda x: x[0], reverse=True) + for pos, old_text, new_text in changes: + modified_line = modified_line[:pos] + new_text + modified_line[pos + len(old_text):] + + diff_lines.append(f"- {original_line}") + diff_lines.append(f"+ {modified_line}") + else: + # Context line + diff_lines.append(f" {lines[i]}") + + diff_chunks.append("\n".join(diff_lines)) + + # Join all chunks into a single diff + diff_examples = "\n\n".join(diff_chunks) if diff_chunks else "No changes shown (content parsing error)" + + self.io.tool_output(f"✅ Replaced {count} occurrences in {file_path} (change_id: {change_id})") + return f"Successfully replaced {count} occurrences (change_id: {change_id}) with examples:\n{diff_examples}" + else: + self.io.tool_output(f"Did not replace text in {file_path} (--dry-run)") + return f"Did not replace text (--dry-run)" + + except Exception as e: + self.io.tool_error(f"Error in ReplaceAll: {str(e)}") + return f"Error: {str(e)}" + + def _execute_insert_block(self, file_path, content, after_pattern=None, before_pattern=None, change_id=None): + """ + Insert a block of text after or before a specified pattern. + + Parameters: + - file_path: Path to the file to modify + - content: Text block to insert + - after_pattern: Pattern after which to insert the block (line containing this pattern) + - before_pattern: Pattern before which to insert the block (line containing this pattern) + - change_id: Optional ID for tracking the change + + Returns a result message. + """ + try: + # Get absolute file path + abs_path = self.abs_root_path(file_path) + rel_path = self.get_rel_fname(abs_path) + + # Check if file exists + if not os.path.isfile(abs_path): + self.io.tool_error(f"File '{file_path}' not found") + return f"Error: File not found" + + # Check if file is in editable context + if abs_path not in self.abs_fnames: + if abs_path in self.abs_read_only_fnames: + self.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.") + return f"Error: File is read-only. Use MakeEditable first." + else: + self.io.tool_error(f"File '{file_path}' not in context") + return f"Error: File not in context" + + # Read file content + file_content = self.io.read_text(abs_path) + if file_content is None: + return f"Error reading file: {file_path}" + + # Validate we have either after_pattern or before_pattern, but not both + if after_pattern and before_pattern: + self.io.tool_error("Cannot specify both after_pattern and before_pattern") + return "Error: Cannot specify both after_pattern and before_pattern" + if not after_pattern and not before_pattern: + self.io.tool_error("Must specify either after_pattern or before_pattern") + return "Error: Must specify either after_pattern or before_pattern" + + # Split into lines for easier handling + lines = file_content.splitlines() + original_content = file_content + + # Find the insertion point + insertion_point = -1 + pattern = after_pattern if after_pattern else before_pattern + for i, line in enumerate(lines): + if pattern in line: + insertion_point = i + # For after_pattern, insert after this line + if after_pattern: + insertion_point += 1 + break + + if insertion_point == -1: + self.io.tool_error(f"Pattern '{pattern}' not found in file") + return f"Error: Pattern not found in file" + + # Insert the content + content_lines = content.splitlines() + new_lines = lines[:insertion_point] + content_lines + lines[insertion_point:] + new_content = '\n'.join(new_lines) + + if original_content == new_content: + self.io.tool_warning(f"No changes made: insertion would not change file") + return f"Warning: No changes made (insertion would not change file)" + + # Write the modified content back to the file + if not self.dry_run: + self.io.write_text(abs_path, new_content) + + # Track the change + metadata = { + 'insertion_point': insertion_point, + 'after_pattern': after_pattern, + 'before_pattern': before_pattern, + 'content': content + } + change_id = self.change_tracker.track_change( + file_path=rel_path, + change_type='insertblock', + original_content=original_content, + new_content=new_content, + metadata=metadata, + change_id=change_id + ) + + self.aider_edited_files.add(rel_path) + pattern_type = "after" if after_pattern else "before" + self.io.tool_output(f"✅ Inserted block {pattern_type} pattern in {file_path} (change_id: {change_id})") + return f"Successfully inserted block (change_id: {change_id})" + else: + self.io.tool_output(f"Did not insert block in {file_path} (--dry-run)") + return f"Did not insert block (--dry-run)" + + except Exception as e: + self.io.tool_error(f"Error in InsertBlock: {str(e)}") + return f"Error: {str(e)}" + + def _execute_delete_block(self, file_path, start_pattern, end_pattern=None, line_count=None, change_id=None): + """ + Delete a block of text between start_pattern and end_pattern (inclusive). + + Parameters: + - file_path: Path to the file to modify + - start_pattern: Pattern marking the start of the block to delete + - end_pattern: Pattern marking the end of the block to delete + - line_count: Number of lines to delete (alternative to end_pattern) + - change_id: Optional ID for tracking the change + + Returns a result message. + """ + try: + # Get absolute file path + abs_path = self.abs_root_path(file_path) + rel_path = self.get_rel_fname(abs_path) + + # Check if file exists + if not os.path.isfile(abs_path): + self.io.tool_error(f"File '{file_path}' not found") + return f"Error: File not found" + + # Check if file is in editable context + if abs_path not in self.abs_fnames: + if abs_path in self.abs_read_only_fnames: + self.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.") + return f"Error: File is read-only. Use MakeEditable first." + else: + self.io.tool_error(f"File '{file_path}' not in context") + return f"Error: File not in context" + + # Read file content + file_content = self.io.read_text(abs_path) + if file_content is None: + return f"Error reading file: {file_path}" + + # Validate we have either end_pattern or line_count, but not both + if end_pattern and line_count: + self.io.tool_error("Cannot specify both end_pattern and line_count") + return "Error: Cannot specify both end_pattern and line_count" + + # Split into lines for easier handling + lines = file_content.splitlines() + original_content = file_content + + # Find the start line + start_line = -1 + for i, line in enumerate(lines): + if start_pattern in line: + start_line = i + break + + if start_line == -1: + self.io.tool_error(f"Start pattern '{start_pattern}' not found in file") + return f"Error: Start pattern not found in file" + + # Find the end line + end_line = -1 + if end_pattern: + for i in range(start_line + 1, len(lines)): + if end_pattern in lines[i]: + end_line = i + break + + if end_line == -1: + self.io.tool_error(f"End pattern '{end_pattern}' not found after start pattern") + return f"Error: End pattern not found after start pattern" + elif line_count: + # Calculate end line based on start line and line count + end_line = min(start_line + line_count - 1, len(lines) - 1) + else: + # If neither is specified, delete just the start line + end_line = start_line + + # Delete the block + deleted_lines = lines[start_line:end_line+1] + new_lines = lines[:start_line] + lines[end_line+1:] + new_content = '\n'.join(new_lines) + + if original_content == new_content: + self.io.tool_warning(f"No changes made: deletion would not change file") + return f"Warning: No changes made (deletion would not change file)" + + # Write the modified content back to the file + if not self.dry_run: + self.io.write_text(abs_path, new_content) + + # Track the change + metadata = { + 'start_line': start_line, + 'end_line': end_line, + 'start_pattern': start_pattern, + 'end_pattern': end_pattern, + 'line_count': line_count, + 'deleted_content': '\n'.join(deleted_lines) + } + change_id = self.change_tracker.track_change( + file_path=rel_path, + change_type='deleteblock', + original_content=original_content, + new_content=new_content, + metadata=metadata, + change_id=change_id + ) + + self.aider_edited_files.add(rel_path) + self.io.tool_output(f"✅ Deleted {end_line - start_line + 1} lines from {file_path} (change_id: {change_id})") + return f"Successfully deleted {end_line - start_line + 1} lines (change_id: {change_id})" + else: + self.io.tool_output(f"Did not delete block in {file_path} (--dry-run)") + return f"Did not delete block (--dry-run)" + + except Exception as e: + self.io.tool_error(f"Error in DeleteBlock: {str(e)}") + return f"Error: {str(e)}" + + def _execute_undo_change(self, change_id=None, last_file=None): + """ + Undo a specific change by ID, or the last change to a file. + + Parameters: + - change_id: ID of the change to undo + - last_file: Path to file where the last change should be undone + + Returns a result message. + """ + try: + # Validate parameters + if change_id is None and last_file is None: + self.io.tool_error("Must specify either change_id or last_file") + return "Error: Must specify either change_id or last_file" + + # If last_file is specified, get the most recent change for that file + if last_file: + abs_path = self.abs_root_path(last_file) + rel_path = self.get_rel_fname(abs_path) + + change_id = self.change_tracker.get_last_change(rel_path) + if not change_id: + self.io.tool_error(f"No changes found for file '{last_file}'") + return f"Error: No changes found for file" + + # Attempt to undo the change + success, message, change_info = self.change_tracker.undo_change(change_id) + + if not success: + self.io.tool_error(message) + return f"Error: {message}" + + # Apply the undo by restoring the original content + if change_info: + file_path = change_info['file_path'] + abs_path = self.abs_root_path(file_path) + + # Write the original content back to the file + if not self.dry_run: + self.io.write_text(abs_path, change_info['original']) + self.aider_edited_files.add(file_path) + + change_type = change_info['type'] + self.io.tool_output(f"✅ Undid {change_type} in {file_path} (change_id: {change_id})") + return f"Successfully undid {change_type} (change_id: {change_id})" + else: + self.io.tool_output(f"Did not undo change in {file_path} (--dry-run)") + return f"Did not undo change (--dry-run)" + + return "Error: Failed to undo change (unknown reason)" + + except Exception as e: + self.io.tool_error(f"Error in UndoChange: {str(e)}") + return f"Error: {str(e)}" + + def _execute_replace_line(self, file_path, line_number, new_content, change_id=None): + """ + Replace a specific line identified by line number. + Useful for fixing errors identified by error messages or linters. + + Parameters: + - file_path: Path to the file to modify + - line_number: The line number to replace (1-based) + - new_content: New content for the line + - change_id: Optional ID for tracking the change + + Returns a result message. + """ + try: + # Get absolute file path + abs_path = self.abs_root_path(file_path) + rel_path = self.get_rel_fname(abs_path) + + # Check if file exists + if not os.path.isfile(abs_path): + self.io.tool_error(f"File '{file_path}' not found") + return f"Error: File not found" + + # Check if file is in editable context + if abs_path not in self.abs_fnames: + if abs_path in self.abs_read_only_fnames: + self.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.") + return f"Error: File is read-only. Use MakeEditable first." + else: + self.io.tool_error(f"File '{file_path}' not in context") + return f"Error: File not in context" + + # Read file content + file_content = self.io.read_text(abs_path) + if file_content is None: + return f"Error reading file: {file_path}" + + # Split into lines + lines = file_content.splitlines() + + # Validate line number + if not isinstance(line_number, int): + try: + line_number = int(line_number) + except ValueError: + self.io.tool_error(f"Line number must be an integer, got '{line_number}'") + return f"Error: Line number must be an integer" + + # Convert 1-based line number (what most editors and error messages use) to 0-based index + idx = line_number - 1 + + if idx < 0 or idx >= len(lines): + self.io.tool_error(f"Line number {line_number} is out of range (file has {len(lines)} lines)") + return f"Error: Line number out of range" + + # Store original content for change tracking + original_content = file_content + original_line = lines[idx] + + # Replace the line + lines[idx] = new_content + + # Join lines back into a string + new_content_full = '\n'.join(lines) + + if original_content == new_content_full: + self.io.tool_warning("No changes made: new line content is identical to original") + return f"Warning: No changes made (new content identical to original)" + + # Write the modified content back to the file + if not self.dry_run: + self.io.write_text(abs_path, new_content_full) + + # Track the change + metadata = { + 'line_number': line_number, + 'original_line': original_line, + 'new_line': new_content + } + change_id = self.change_tracker.track_change( + file_path=rel_path, + change_type='replaceline', + original_content=original_content, + new_content=new_content_full, + metadata=metadata, + change_id=change_id + ) + + self.aider_edited_files.add(rel_path) + + # Create a readable diff for the line replacement + diff = f"Line {line_number}:\n- {original_line}\n+ {new_content}" + + self.io.tool_output(f"✅ Replaced line {line_number} in {file_path} (change_id: {change_id})") + return f"Successfully replaced line {line_number} (change_id: {change_id}):\n{diff}" + else: + self.io.tool_output(f"Did not replace line in {file_path} (--dry-run)") + return f"Did not replace line (--dry-run)" + + except Exception as e: + self.io.tool_error(f"Error in ReplaceLine: {str(e)}") + return f"Error: {str(e)}" + + def _execute_replace_lines(self, file_path, start_line, end_line, new_content, change_id=None): + """ + Replace a range of lines identified by line numbers. + Useful for fixing errors identified by error messages or linters. + + Parameters: + - file_path: Path to the file to modify + - start_line: The first line number to replace (1-based) + - end_line: The last line number to replace (1-based) + - new_content: New content for the lines (can be multi-line) + - change_id: Optional ID for tracking the change + + Returns a result message. + """ + try: + # Get absolute file path + abs_path = self.abs_root_path(file_path) + rel_path = self.get_rel_fname(abs_path) + + # Check if file exists + if not os.path.isfile(abs_path): + self.io.tool_error(f"File '{file_path}' not found") + return f"Error: File not found" + + # Check if file is in editable context + if abs_path not in self.abs_fnames: + if abs_path in self.abs_read_only_fnames: + self.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.") + return f"Error: File is read-only. Use MakeEditable first." + else: + self.io.tool_error(f"File '{file_path}' not in context") + return f"Error: File not in context" + + # Read file content + file_content = self.io.read_text(abs_path) + if file_content is None: + return f"Error reading file: {file_path}" + + # Convert line numbers to integers if needed + if not isinstance(start_line, int): + try: + start_line = int(start_line) + except ValueError: + self.io.tool_error(f"Start line must be an integer, got '{start_line}'") + return f"Error: Start line must be an integer" + + if not isinstance(end_line, int): + try: + end_line = int(end_line) + except ValueError: + self.io.tool_error(f"End line must be an integer, got '{end_line}'") + return f"Error: End line must be an integer" + + # Split into lines + lines = file_content.splitlines() + + # Convert 1-based line numbers to 0-based indices + start_idx = start_line - 1 + end_idx = end_line - 1 + + # Validate line numbers + if start_idx < 0 or start_idx >= len(lines): + self.io.tool_error(f"Start line {start_line} is out of range (file has {len(lines)} lines)") + return f"Error: Start line out of range" + + if end_idx < start_idx or end_idx >= len(lines): + self.io.tool_error(f"End line {end_line} is out of range (must be >= start line and < {len(lines)})") + return f"Error: End line out of range" + + # Store original content for change tracking + original_content = file_content + replaced_lines = lines[start_idx:end_idx+1] + + # Split the new content into lines + new_lines = new_content.splitlines() + + # Perform the replacement + new_full_lines = lines[:start_idx] + new_lines + lines[end_idx+1:] + new_content_full = '\n'.join(new_full_lines) + + if original_content == new_content_full: + self.io.tool_warning("No changes made: new content is identical to original") + return f"Warning: No changes made (new content identical to original)" + + # Write the modified content back to the file + if not self.dry_run: + self.io.write_text(abs_path, new_content_full) + + # Track the change + metadata = { + 'start_line': start_line, + 'end_line': end_line, + 'replaced_lines': replaced_lines, + 'new_lines': new_lines + } + change_id = self.change_tracker.track_change( + file_path=rel_path, + change_type='replacelines', + original_content=original_content, + new_content=new_content_full, + metadata=metadata, + change_id=change_id + ) + + self.aider_edited_files.add(rel_path) + replaced_count = end_line - start_line + 1 + new_count = len(new_lines) + + # Create a readable diff for the lines replacement + diff = f"Lines {start_line}-{end_line}:\n" + # Add removed lines with - prefix + for line in replaced_lines: + diff += f"- {line}\n" + # Add separator + diff += "---\n" + # Add new lines with + prefix + for line in new_lines: + diff += f"+ {line}\n" + + self.io.tool_output(f"✅ Replaced lines {start_line}-{end_line} ({replaced_count} lines) with {new_count} new lines in {file_path} (change_id: {change_id})") + return f"Successfully replaced lines {start_line}-{end_line} with {new_count} new lines (change_id: {change_id}):\n{diff}" + else: + self.io.tool_output(f"Did not replace lines in {file_path} (--dry-run)") + return f"Did not replace lines (--dry-run)" + + except Exception as e: + self.io.tool_error(f"Error in ReplaceLines: {str(e)}") + return f"Error: {str(e)}" + + def _execute_indent_lines(self, file_path, start_pattern, end_pattern=None, line_count=None, indent_levels=1, change_id=None): + """ + Indent or unindent a block of lines in a file. + + Parameters: + - file_path: Path to the file to modify + - start_pattern: Pattern marking the start of the block to indent + - end_pattern: Pattern marking the end of the block to indent + - line_count: Number of lines to indent (alternative to end_pattern) + - indent_levels: Number of levels to indent (positive) or unindent (negative) + - change_id: Optional ID for tracking the change + + Returns a result message. + """ + try: + # Get absolute file path + abs_path = self.abs_root_path(file_path) + rel_path = self.get_rel_fname(abs_path) + + # Check if file exists + if not os.path.isfile(abs_path): + self.io.tool_error(f"File '{file_path}' not found") + return f"Error: File not found" + + # Check if file is in editable context + if abs_path not in self.abs_fnames: + if abs_path in self.abs_read_only_fnames: + self.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.") + return f"Error: File is read-only. Use MakeEditable first." + else: + self.io.tool_error(f"File '{file_path}' not in context") + return f"Error: File not in context" + + # Read file content + file_content = self.io.read_text(abs_path) + if file_content is None: + return f"Error reading file: {file_path}" + + # Validate we have either end_pattern or line_count, but not both + if end_pattern and line_count: + self.io.tool_error("Cannot specify both end_pattern and line_count") + return "Error: Cannot specify both end_pattern and line_count" + + # Split into lines for easier handling + lines = file_content.splitlines() + original_content = file_content + + # Find the start line + start_line = -1 + for i, line in enumerate(lines): + if start_pattern in line: + start_line = i + break + + if start_line == -1: + self.io.tool_error(f"Start pattern '{start_pattern}' not found in file") + return f"Error: Start pattern not found in file" + + # Find the end line + end_line = -1 + if end_pattern: + for i in range(start_line + 1, len(lines)): + if end_pattern in lines[i]: + end_line = i + break + + if end_line == -1: + self.io.tool_error(f"End pattern '{end_pattern}' not found after start pattern") + return f"Error: End pattern not found after start pattern" + elif line_count: + # Calculate end line based on start line and line count + end_line = min(start_line + line_count - 1, len(lines) - 1) + else: + # If neither is specified, indent just the start line + end_line = start_line + + # Determine indentation amount (4 spaces per level) + indent_spaces = 4 * indent_levels + + # Apply indentation + for i in range(start_line, end_line + 1): + if indent_levels > 0: + # Add indentation + lines[i] = ' ' * indent_spaces + lines[i] + else: + # Remove indentation, but do not remove more than exists + spaces_to_remove = min(abs(indent_spaces), len(lines[i]) - len(lines[i].lstrip())) + if spaces_to_remove > 0: + lines[i] = lines[i][spaces_to_remove:] + + # Join lines back into a string + new_content = '\n'.join(lines) + + if original_content == new_content: + self.io.tool_warning(f"No changes made: indentation would not change file") + return f"Warning: No changes made (indentation would not change file)" + + # Write the modified content back to the file + if not self.dry_run: + self.io.write_text(abs_path, new_content) + + # Track the change + metadata = { + 'start_line': start_line, + 'end_line': end_line, + 'start_pattern': start_pattern, + 'end_pattern': end_pattern, + 'line_count': line_count, + 'indent_levels': indent_levels + } + change_id = self.change_tracker.track_change( + file_path=rel_path, + change_type='indentlines', + original_content=original_content, + new_content=new_content, + metadata=metadata, + change_id=change_id + ) + + self.aider_edited_files.add(rel_path) + action = "Indented" if indent_levels > 0 else "Unindented" + levels = abs(indent_levels) + level_text = "level" if levels == 1 else "levels" + self.io.tool_output(f"✅ {action} {end_line - start_line + 1} lines by {levels} {level_text} in {file_path} (change_id: {change_id})") + return f"Successfully {action.lower()} {end_line - start_line + 1} lines by {levels} {level_text} (change_id: {change_id})" + else: + self.io.tool_output(f"Did not indent lines in {file_path} (--dry-run)") + return f"Did not indent lines (--dry-run)" + + except Exception as e: + self.io.tool_error(f"Error in IndentLines: {str(e)}") + return f"Error: {str(e)}" + + def _execute_list_changes(self, file_path=None, limit=10): + """ + List recent changes made to files. + + Parameters: + - file_path: Optional path to filter changes by file + - limit: Maximum number of changes to list + + Returns a formatted list of changes. + """ + try: + # If file_path is specified, get the absolute path + rel_file_path = None + if file_path: + abs_path = self.abs_root_path(file_path) + rel_file_path = self.get_rel_fname(abs_path) + + # Get the list of changes + changes = self.change_tracker.list_changes(rel_file_path, limit) + + if not changes: + if file_path: + return f"No changes found for file '{file_path}'" + else: + return "No changes have been made yet" + + # Format the changes into a readable list + result = "Recent changes:\n" + for i, change in enumerate(changes): + change_time = datetime.fromtimestamp(change['timestamp']).strftime('%H:%M:%S') + change_type = change['type'] + file_path = change['file_path'] + change_id = change['id'] + + result += f"{i+1}. [{change_id}] {change_time} - {change_type.upper()} on {file_path}\n" + + self.io.tool_output(result) + return result + + except Exception as e: + self.io.tool_error(f"Error in ListChanges: {str(e)}") + return f"Error: {str(e)}" diff --git a/aider/coders/navigator_prompts.py b/aider/coders/navigator_prompts.py index 2db0292cc..e4592dfaa 100644 --- a/aider/coders/navigator_prompts.py +++ b/aider/coders/navigator_prompts.py @@ -12,7 +12,7 @@ class NavigatorPrompts(CoderPrompts): LLM to manage its own context by adding/removing files and executing commands. """ - main_system = """ + main_system = r''' ## Role and Purpose Act as an expert software engineer with the ability to autonomously navigate and modify a codebase. @@ -60,10 +60,50 @@ Act as an expert software engineer with the ability to autonomously navigate and - **MakeReadonly**: `[tool_call(MakeReadonly, file_path="src/main.py")]` Convert an editable file back to read-only status. +### Granular Editing Tools +- **ReplaceText**: `[tool_call(ReplaceText, file_path="path/to/file.py", find_text="old text", replace_text="new text", near_context="unique nearby text", occurrence=1)]` + Replace specific text with new text. Use near_context to disambiguate between multiple occurrences. + Set occurrence to -1 for the last occurrence, or a number for a specific occurrence. + +- **ReplaceAll**: `[tool_call(ReplaceAll, file_path="path/to/file.py", find_text="oldVar", replace_text="newVar")]` + Replace all occurrences of text in a file. Useful for renaming variables, function names, etc. + +- **InsertBlock**: `[tool_call(InsertBlock, file_path="path/to/file.py", content=""" +def new_function(): + return True +""", after_pattern="# Insert after this line")]` + Insert a block of text after or before a pattern. Use single quotes with escaped newlines for multi-line content. + Specify either after_pattern or before_pattern to place the block. + +- **DeleteBlock**: `[tool_call(DeleteBlock, file_path="path/to/file.py", start_pattern="def old_function", end_pattern="# End function")]` + Delete a block of text from start_pattern to end_pattern (inclusive). + Alternatively, use line_count instead of end_pattern to delete a specific number of lines. + +- **ReplaceLine**: `[tool_call(ReplaceLine, file_path="path/to/file.py", line_number=42, new_content="def fixed_function(param):")]` + Replace a specific line by its line number. Especially useful for fixing errors or lint warnings that include line numbers. + Line numbers are 1-based (as in most editors and error messages). + +- **ReplaceLines**: `[tool_call(ReplaceLines, file_path="path/to/file.py", start_line=42, end_line=45, new_content=""" +def better_function(param): + # Fixed implementation + return process(param) +""")]` + Replace a range of lines by line numbers. Useful for fixing multiple lines referenced in error messages. + The new_content can contain any number of lines, not just the same count as the original range. + +- **IndentLines**: `[tool_call(IndentLines, file_path="path/to/file.py", start_pattern="def my_function", end_pattern="return result", indent_levels=1)]` + Indent or unindent a block of lines. Use positive indent_levels to increase indentation or negative to decrease. + Specify either end_pattern or line_count to determine the range of lines to indent. + +- **UndoChange**: `[tool_call(UndoChange, change_id="a1b2c3d4")]` + Undo a specific change by its ID. Alternatively, use last_file="path/to/file.py" to undo the most recent change to that file. + +- **ListChanges**: `[tool_call(ListChanges, file_path="path/to/file.py", limit=5)]` + List recent changes made to files. Optionally filter by file_path and limit the number of results. + ### Other Tools - **Command**: `[tool_call(Command, command_string="git diff HEAD~1")]` Execute a shell command. Requires user confirmation. - **Do NOT use this for aider commands starting with `/` (like `/add`, `/run`, `/diff`).** ### Multi-Turn Exploration When you include any tool call, the system will automatically continue to the next round. @@ -88,6 +128,13 @@ When you include any tool call, the system will automatically continue to the ne - Target specific patterns rather than overly broad searches - Remember the `Find` tool is optimized for locating symbols across the codebase +### Granular Editing Workflow +1. **Discover and Add Files**: Use Glob, Grep, Find to locate relevant files +2. **Make Files Editable**: Convert read-only files to editable with MakeEditable +3. **Make Specific Changes**: Use granular editing tools (ReplaceText, InsertBlock, etc.) for precise edits +4. **Review Changes**: List applied changes with ListChanges +5. **Fix Mistakes**: If needed, undo changes with UndoChange by specific ID or last change to a file + ### Context Management Strategy - Keep your context focused by removing files that are no longer relevant - For large codebases, maintain only 5-15 files in context at once for best performance @@ -98,17 +145,83 @@ When you include any tool call, the system will automatically continue to the ne ## Code Editing Process -### SEARCH/REPLACE Block Format -When proposing code changes, describe each change with a SEARCH/REPLACE block using this exact format: +### Granular Editing with Tool Calls +For precise, targeted edits to code, use the granular editing tools: -```language_name -/path/to/file.ext +- **ReplaceText**: Replace specific instances of text in a file +- **ReplaceAll**: Replace all occurrences of text in a file (e.g., rename variables) +- **InsertBlock**: Insert multi-line blocks of code at specific locations +- **DeleteBlock**: Remove specific sections of code +- **ReplaceLine/ReplaceLines**: Fix specific line numbers from error messages or linters +- **IndentLines**: Adjust indentation of code blocks +- **UndoChange**: Reverse specific changes by ID if you make a mistake + +#### When to Use Line Number Based Tools + +When dealing with errors or warnings that include line numbers, prefer the line-based editing tools: + +``` +Error in /path/to/file.py line 42: Syntax error: unexpected token +Warning in /path/to/file.py lines 105-107: This block should be indented +``` + +For these cases, use: +- `ReplaceLine` for single line fixes (e.g., syntax errors) +- `ReplaceLines` for multi-line issues +- `IndentLines` for indentation problems + +#### Multiline Tool Call Content Format + +When providing multiline content in tool calls (like ReplaceLines, InsertBlock), one leading and one trailing +newline will be automatically trimmed if present. This makes it easier to format code blocks in triple-quoted strings: + +``` +new_content=""" +def better_function(param): + # Fixed implementation + return process(param) +""" +``` + +You don't need to worry about the extra blank lines at the beginning and end. If you actually need to +preserve blank lines in your output, simply add an extra newline: + +``` +new_content=""" + +def better_function(param): # Note the extra newline above to preserve a blank line + # Fixed implementation + return process(param) +""" +``` + +Example of inserting a new multi-line function: +``` +[tool_call(InsertBlock, + file_path="src/utils.py", + after_pattern="def existing_function():", + content=""" +def new_function(param1, param2): + # This is a new utility function + result = process_data(param1) + if result and param2: + return result + return None +""")] +``` + +### SEARCH/REPLACE Block Format (Alternative Method) +For larger changes that involve multiple edits or significant restructuring, you can still use SEARCH/REPLACE blocks with this exact format: + +````python +path/to/file.ext <<<<<<< SEARCH Original code lines to match exactly ======= Replacement code lines >>>>>>> REPLACE -``` +```` +NOTE that this uses four backticks as the fence and not three! ### Editing Guidelines - Every SEARCH section must EXACTLY MATCH existing content, including whitespace and indentation @@ -123,6 +236,7 @@ Replacement code lines - If tools return errors or unexpected results, try alternative approaches - Refine search patterns if results are too broad or too narrow - Use the enhanced context blocks (directory structure and git status) to orient yourself +- Use ListChanges to see what edits have been made and UndoChange to revert mistakes Always reply to the user in {language}. @@ -179,7 +293,7 @@ Would you like me to explain any specific part of the authentication process in These files have been added to the chat so you can see all of their contents. Trust this message as the true contents of the files! -""" +''' files_content_assistant_reply = ( "I understand. I'll use these files to help with your request."