Split tools out of NavigatorCoder.

This commit is contained in:
Amar Sood (tekacs) 2025-04-12 06:22:51 -04:00
parent 9275bbc92a
commit 9cb0f5e203
21 changed files with 1853 additions and 1804 deletions

File diff suppressed because it is too large Load diff

55
aider/tools/command.py Normal file
View file

@ -0,0 +1,55 @@
# Import necessary functions
from aider.run_cmd import run_cmd_subprocess
def _execute_command(coder, command_string):
"""
Execute a non-interactive shell command after user confirmation.
"""
try:
# Ask for confirmation before executing, allowing 'Always'
# Use the command string itself as the group key to remember preference per command
if not coder.io.confirm_ask(
"Allow execution of this command?",
subject=command_string,
explicit_yes_required=True, # Require explicit 'yes' or 'always'
allow_never=True # Enable the 'Don't ask again' option
):
# Check if the reason for returning False was *not* because it's remembered
# (confirm_ask returns False if 'n' or 'no' is chosen, even if remembered)
# We only want to skip if the user actively said no *this time* or if it's
# remembered as 'never' (which shouldn't happen with allow_never=True,
# but checking io.never_ask_group is robust).
# If the command is in never_ask_group with a True value (meaning Always),
# confirm_ask would have returned True directly.
# So, if confirm_ask returns False here, it means the user chose No this time.
coder.io.tool_output(f"Skipped execution of shell command: {command_string}")
return "Shell command execution skipped by user."
coder.io.tool_output(f"⚙️ Executing non-interactive shell command: {command_string}")
# Use run_cmd_subprocess for non-interactive execution
exit_status, combined_output = run_cmd_subprocess(
command_string,
verbose=coder.verbose,
cwd=coder.root # Execute in the project root
)
# Format the output for the result message, include more content
output_content = combined_output or ""
# Use the existing token threshold constant as the character limit for truncation
output_limit = coder.large_file_token_threshold
if len(output_content) > output_limit:
# Truncate and add a clear message using the constant value
output_content = output_content[:output_limit] + f"\n... (output truncated at {output_limit} characters, based on large_file_token_threshold)"
if exit_status == 0:
return f"Shell command executed successfully (exit code 0). Output:\n{output_content}"
else:
return f"Shell command failed with exit code {exit_status}. Output:\n{output_content}"
except Exception as e:
coder.io.tool_error(f"Error executing non-interactive shell command '{command_string}': {str(e)}")
# Optionally include traceback for debugging if verbose
# if coder.verbose:
# coder.io.tool_error(traceback.format_exc())
return f"Error executing command: {str(e)}"

View file

@ -0,0 +1,40 @@
# Import necessary functions
from aider.run_cmd import run_cmd
def _execute_command_interactive(coder, command_string):
"""
Execute an interactive shell command using run_cmd (which uses pexpect/PTY).
"""
try:
coder.io.tool_output(f"⚙️ Starting interactive shell command: {command_string}")
coder.io.tool_output(">>> You may need to interact with the command below <<<")
# Use run_cmd which handles PTY logic
exit_status, combined_output = run_cmd(
command_string,
verbose=coder.verbose, # Pass verbose flag
error_print=coder.io.tool_error, # Use io for error printing
cwd=coder.root # Execute in the project root
)
coder.io.tool_output(">>> Interactive command finished <<<")
# Format the output for the result message, include more content
output_content = combined_output or ""
# Use the existing token threshold constant as the character limit for truncation
output_limit = coder.large_file_token_threshold
if len(output_content) > output_limit:
# Truncate and add a clear message using the constant value
output_content = output_content[:output_limit] + f"\n... (output truncated at {output_limit} characters, based on large_file_token_threshold)"
if exit_status == 0:
return f"Interactive command finished successfully (exit code 0). Output:\n{output_content}"
else:
return f"Interactive command finished with exit code {exit_status}. Output:\n{output_content}"
except Exception as e:
coder.io.tool_error(f"Error executing interactive shell command '{command_string}': {str(e)}")
# Optionally include traceback for debugging if verbose
# if coder.verbose:
# coder.io.tool_error(traceback.format_exc())
return f"Error executing interactive command: {str(e)}"

171
aider/tools/delete_block.py Normal file
View file

@ -0,0 +1,171 @@
import os
import traceback
def _execute_delete_block(coder, file_path, start_pattern, end_pattern=None, line_count=None, near_context=None, occurrence=1, change_id=None, dry_run=False):
"""
Delete a block of text between start_pattern and end_pattern (inclusive).
Parameters:
- coder: The Coder instance
- file_path: Path to the file to modify
- start_pattern: Pattern marking the start of the block to delete (line containing this pattern)
- end_pattern: Optional pattern marking the end of the block (line containing this pattern)
- line_count: Optional number of lines to delete (alternative to end_pattern)
- near_context: Optional text nearby to help locate the correct instance of the start_pattern
- occurrence: Which occurrence of the start_pattern to use (1-based index, or -1 for last)
- change_id: Optional ID for tracking the change
- dry_run: If True, simulate the change without modifying the file
Returns a result message.
"""
try:
# Get absolute file path
abs_path = coder.abs_root_path(file_path)
rel_path = coder.get_rel_fname(abs_path)
# Check if file exists
if not os.path.isfile(abs_path):
coder.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 coder.abs_fnames:
if abs_path in coder.abs_read_only_fnames:
coder.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.")
return f"Error: File is read-only. Use MakeEditable first."
else:
coder.io.tool_error(f"File '{file_path}' not in context")
return f"Error: File not in context"
# Reread file content immediately before modification
file_content = coder.io.read_text(abs_path)
if file_content is None:
coder.io.tool_error(f"Could not read file '{file_path}' before DeleteBlock operation.")
return f"Error: Could not read file '{file_path}'"
# Validate we have either end_pattern or line_count, but not both
if end_pattern and line_count:
coder.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 occurrences of the start_pattern
start_pattern_line_indices = []
for i, line in enumerate(lines):
if start_pattern in line:
if near_context:
context_window_start = max(0, i - 5)
context_window_end = min(len(lines), i + 6)
context_block = "\n".join(lines[context_window_start:context_window_end])
if near_context in context_block:
start_pattern_line_indices.append(i)
else:
start_pattern_line_indices.append(i)
if not start_pattern_line_indices:
err_msg = f"Start pattern '{start_pattern}' not found"
if near_context: err_msg += f" near context '{near_context}'"
err_msg += f" in file '{file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}"
# Select the occurrence for the start pattern
num_occurrences = len(start_pattern_line_indices)
try:
occurrence = int(occurrence)
if occurrence == -1:
target_idx = num_occurrences - 1
elif occurrence > 0 and occurrence <= num_occurrences:
target_idx = occurrence - 1
else:
err_msg = f"Occurrence number {occurrence} is out of range for start pattern '{start_pattern}'. Found {num_occurrences} occurrences"
if near_context: err_msg += f" near '{near_context}'"
err_msg += f" in '{file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}"
except ValueError:
coder.io.tool_error(f"Invalid occurrence value: '{occurrence}'. Must be an integer.")
return f"Error: Invalid occurrence value '{occurrence}'"
start_line = start_pattern_line_indices[target_idx]
occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else ""
# Find the end line based on end_pattern or line_count
end_line = -1
if end_pattern:
for i in range(start_line, len(lines)):
if end_pattern in lines[i]:
end_line = i
break
if end_line == -1:
err_msg = f"End pattern '{end_pattern}' not found after {occurrence_str}start pattern '{start_pattern}' (line {start_line + 1}) in '{file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}"
elif line_count:
try:
line_count = int(line_count)
if line_count <= 0: raise ValueError("Line count must be positive")
end_line = min(start_line + line_count - 1, len(lines) - 1)
except ValueError:
coder.io.tool_error(f"Invalid line_count value: '{line_count}'. Must be a positive integer.")
return f"Error: Invalid line_count value '{line_count}'"
else:
end_line = start_line
# Prepare the deletion
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:
coder.io.tool_warning(f"No changes made: deletion would not change file")
return f"Warning: No changes made (deletion would not change file)"
# Generate diff for feedback (assuming _generate_diff_snippet_delete is available on coder)
diff_snippet = coder._generate_diff_snippet_delete(original_content, start_line, end_line)
# Handle dry run
if dry_run:
coder.io.tool_output(f"Dry run: Would delete lines {start_line+1}-{end_line+1} (based on {occurrence_str}start pattern '{start_pattern}') in {file_path}")
return f"Dry run: Would delete block. Diff snippet:\n{diff_snippet}"
# --- Apply Change (Not dry run) ---
coder.io.write_text(abs_path, new_content)
# Track the change
try:
metadata = {
'start_line': start_line + 1,
'end_line': end_line + 1,
'start_pattern': start_pattern,
'end_pattern': end_pattern,
'line_count': line_count,
'near_context': near_context,
'occurrence': occurrence,
'deleted_content': '\n'.join(deleted_lines)
}
change_id = coder.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
)
except Exception as track_e:
coder.io.tool_error(f"Error tracking change for DeleteBlock: {track_e}")
change_id = "TRACKING_FAILED"
coder.aider_edited_files.add(rel_path)
# Improve feedback
num_deleted = end_line - start_line + 1
coder.io.tool_output(f"✅ Deleted {num_deleted} lines (from {occurrence_str}start pattern) in {file_path} (change_id: {change_id})")
return f"Successfully deleted {num_deleted} lines (change_id: {change_id}). Diff snippet:\n{diff_snippet}"
except Exception as e:
coder.io.tool_error(f"Error in DeleteBlock: {str(e)}\n{traceback.format_exc()}")
return f"Error: {str(e)}"

View file

@ -0,0 +1,220 @@
import os
import traceback
def _execute_extract_lines(coder, source_file_path, target_file_path, start_pattern, end_pattern=None, line_count=None, near_context=None, occurrence=1, dry_run=False):
"""
Extract a range of lines from a source file and move them to a target file.
Parameters:
- coder: The Coder instance
- source_file_path: Path to the file to extract lines from
- target_file_path: Path to the file to append extracted lines to (will be created if needed)
- start_pattern: Pattern marking the start of the block to extract
- end_pattern: Optional pattern marking the end of the block
- line_count: Optional number of lines to extract (alternative to end_pattern)
- near_context: Optional text nearby to help locate the correct instance of the start_pattern
- occurrence: Which occurrence of the start_pattern to use (1-based index, or -1 for last)
- dry_run: If True, simulate the change without modifying files
Returns a result message.
"""
try:
# --- Validate Source File ---
abs_source_path = coder.abs_root_path(source_file_path)
rel_source_path = coder.get_rel_fname(abs_source_path)
if not os.path.isfile(abs_source_path):
coder.io.tool_error(f"Source file '{source_file_path}' not found")
return f"Error: Source file not found"
if abs_source_path not in coder.abs_fnames:
if abs_source_path in coder.abs_read_only_fnames:
coder.io.tool_error(f"Source file '{source_file_path}' is read-only. Use MakeEditable first.")
return f"Error: Source file is read-only. Use MakeEditable first."
else:
coder.io.tool_error(f"Source file '{source_file_path}' not in context")
return f"Error: Source file not in context"
# --- Validate Target File ---
abs_target_path = coder.abs_root_path(target_file_path)
rel_target_path = coder.get_rel_fname(abs_target_path)
target_exists = os.path.isfile(abs_target_path)
target_is_editable = abs_target_path in coder.abs_fnames
target_is_readonly = abs_target_path in coder.abs_read_only_fnames
if target_exists and not target_is_editable:
if target_is_readonly:
coder.io.tool_error(f"Target file '{target_file_path}' exists but is read-only. Use MakeEditable first.")
return f"Error: Target file exists but is read-only. Use MakeEditable first."
else:
# This case shouldn't happen if file exists, but handle defensively
coder.io.tool_error(f"Target file '{target_file_path}' exists but is not in context. Add it first.")
return f"Error: Target file exists but is not in context."
# --- Read Source Content ---
source_content = coder.io.read_text(abs_source_path)
if source_content is None:
coder.io.tool_error(f"Could not read source file '{source_file_path}' before ExtractLines operation.")
return f"Error: Could not read source file '{source_file_path}'"
# --- Find Extraction Range ---
if end_pattern and line_count:
coder.io.tool_error("Cannot specify both end_pattern and line_count")
return "Error: Cannot specify both end_pattern and line_count"
source_lines = source_content.splitlines()
original_source_content = source_content
start_pattern_line_indices = []
for i, line in enumerate(source_lines):
if start_pattern in line:
if near_context:
context_window_start = max(0, i - 5)
context_window_end = min(len(source_lines), i + 6)
context_block = "\n".join(source_lines[context_window_start:context_window_end])
if near_context in context_block:
start_pattern_line_indices.append(i)
else:
start_pattern_line_indices.append(i)
if not start_pattern_line_indices:
err_msg = f"Start pattern '{start_pattern}' not found"
if near_context: err_msg += f" near context '{near_context}'"
err_msg += f" in source file '{source_file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}"
num_occurrences = len(start_pattern_line_indices)
try:
occurrence = int(occurrence)
if occurrence == -1:
target_idx = num_occurrences - 1
elif occurrence > 0 and occurrence <= num_occurrences:
target_idx = occurrence - 1
else:
err_msg = f"Occurrence number {occurrence} is out of range for start pattern '{start_pattern}'. Found {num_occurrences} occurrences"
if near_context: err_msg += f" near '{near_context}'"
err_msg += f" in '{source_file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}"
except ValueError:
coder.io.tool_error(f"Invalid occurrence value: '{occurrence}'. Must be an integer.")
return f"Error: Invalid occurrence value '{occurrence}'"
start_line = start_pattern_line_indices[target_idx]
occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else ""
end_line = -1
if end_pattern:
for i in range(start_line, len(source_lines)):
if end_pattern in source_lines[i]:
end_line = i
break
if end_line == -1:
err_msg = f"End pattern '{end_pattern}' not found after {occurrence_str}start pattern '{start_pattern}' (line {start_line + 1}) in '{source_file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}"
elif line_count:
try:
line_count = int(line_count)
if line_count <= 0: raise ValueError("Line count must be positive")
end_line = min(start_line + line_count - 1, len(source_lines) - 1)
except ValueError:
coder.io.tool_error(f"Invalid line_count value: '{line_count}'. Must be a positive integer.")
return f"Error: Invalid line_count value '{line_count}'"
else:
end_line = start_line # Extract just the start line if no end specified
# --- Prepare Content Changes ---
extracted_lines = source_lines[start_line:end_line+1]
new_source_lines = source_lines[:start_line] + source_lines[end_line+1:]
new_source_content = '\n'.join(new_source_lines)
target_content = ""
if target_exists:
target_content = coder.io.read_text(abs_target_path)
if target_content is None:
coder.io.tool_error(f"Could not read existing target file '{target_file_path}'.")
return f"Error: Could not read target file '{target_file_path}'"
original_target_content = target_content # For tracking
# Append extracted lines to target content, ensuring a newline if target wasn't empty
extracted_block = '\n'.join(extracted_lines)
if target_content and not target_content.endswith('\n'):
target_content += '\n' # Add newline before appending if needed
new_target_content = target_content + extracted_block
# --- Generate Diffs ---
source_diff_snippet = coder._generate_diff_snippet_delete(original_source_content, start_line, end_line)
target_insertion_line = len(target_content.splitlines()) if target_content else 0
target_diff_snippet = coder._generate_diff_snippet_insert(original_target_content, target_insertion_line, extracted_lines)
# --- Handle Dry Run ---
if dry_run:
num_extracted = end_line - start_line + 1
target_action = "append to" if target_exists else "create"
coder.io.tool_output(f"Dry run: Would extract {num_extracted} lines (from {occurrence_str}start pattern '{start_pattern}') in {source_file_path} and {target_action} {target_file_path}")
# Provide more informative dry run response with diffs
return (
f"Dry run: Would extract {num_extracted} lines from {rel_source_path} and {target_action} {rel_target_path}.\n"
f"Source Diff (Deletion):\n{source_diff_snippet}\n"
f"Target Diff (Insertion):\n{target_diff_snippet}"
)
# --- Apply Changes (Not Dry Run) ---
coder.io.write_text(abs_source_path, new_source_content)
coder.io.write_text(abs_target_path, new_target_content)
# --- Track Changes ---
source_change_id = "TRACKING_FAILED"
target_change_id = "TRACKING_FAILED"
try:
source_metadata = {
'start_line': start_line + 1, 'end_line': end_line + 1,
'start_pattern': start_pattern, 'end_pattern': end_pattern, 'line_count': line_count,
'near_context': near_context, 'occurrence': occurrence,
'extracted_content': extracted_block, 'target_file': rel_target_path
}
source_change_id = coder.change_tracker.track_change(
file_path=rel_source_path, change_type='extractlines_source',
original_content=original_source_content, new_content=new_source_content,
metadata=source_metadata
)
except Exception as track_e:
coder.io.tool_error(f"Error tracking source change for ExtractLines: {track_e}")
try:
target_metadata = {
'insertion_line': target_insertion_line + 1,
'inserted_content': extracted_block, 'source_file': rel_source_path
}
target_change_id = coder.change_tracker.track_change(
file_path=rel_target_path, change_type='extractlines_target',
original_content=original_target_content, new_content=new_target_content,
metadata=target_metadata
)
except Exception as track_e:
coder.io.tool_error(f"Error tracking target change for ExtractLines: {track_e}")
# --- Update Context ---
coder.aider_edited_files.add(rel_source_path)
coder.aider_edited_files.add(rel_target_path)
if not target_exists:
# Add the newly created file to editable context
coder.abs_fnames.add(abs_target_path)
coder.io.tool_output(f"✨ Created and added '{target_file_path}' to editable context.")
# --- Return Result ---
num_extracted = end_line - start_line + 1
target_action = "appended to" if target_exists else "created"
coder.io.tool_output(f"✅ Extracted {num_extracted} lines from {rel_source_path} (change_id: {source_change_id}) and {target_action} {rel_target_path} (change_id: {target_change_id})")
# Provide more informative success response with change IDs and diffs
return (
f"Successfully extracted {num_extracted} lines from {rel_source_path} and {target_action} {rel_target_path}.\n"
f"Source Change ID: {source_change_id}\nSource Diff (Deletion):\n{source_diff_snippet}\n"
f"Target Change ID: {target_change_id}\nTarget Diff (Insertion):\n{target_diff_snippet}"
)
except Exception as e:
coder.io.tool_error(f"Error in ExtractLines: {str(e)}\n{traceback.format_exc()}")
return f"Error: {str(e)}"

197
aider/tools/indent_lines.py Normal file
View file

@ -0,0 +1,197 @@
import os
import traceback
def _execute_indent_lines(coder, file_path, start_pattern, end_pattern=None, line_count=None, indent_levels=1, near_context=None, occurrence=1, change_id=None, dry_run=False):
"""
Indent or unindent a block of lines in a file.
Parameters:
- coder: The Coder instance
- file_path: Path to the file to modify
- start_pattern: Pattern marking the start of the block to indent (line containing this pattern)
- end_pattern: Optional pattern marking the end of the block (line containing this pattern)
- line_count: Optional number of lines to indent (alternative to end_pattern)
- indent_levels: Number of levels to indent (positive) or unindent (negative)
- near_context: Optional text nearby to help locate the correct instance of the start_pattern
- occurrence: Which occurrence of the start_pattern to use (1-based index, or -1 for last)
- change_id: Optional ID for tracking the change
- dry_run: If True, simulate the change without modifying the file
Returns a result message.
"""
try:
# Get absolute file path
abs_path = coder.abs_root_path(file_path)
rel_path = coder.get_rel_fname(abs_path)
# Check if file exists
if not os.path.isfile(abs_path):
coder.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 coder.abs_fnames:
if abs_path in coder.abs_read_only_fnames:
coder.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.")
return f"Error: File is read-only. Use MakeEditable first."
else:
coder.io.tool_error(f"File '{file_path}' not in context")
return f"Error: File not in context"
# Reread file content immediately before modification
file_content = coder.io.read_text(abs_path)
if file_content is None:
coder.io.tool_error(f"Could not read file '{file_path}' before IndentLines operation.")
return f"Error: Could not read file '{file_path}'"
# Validate we have either end_pattern or line_count, but not both
if end_pattern and line_count:
coder.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 occurrences of the start_pattern
start_pattern_line_indices = []
for i, line in enumerate(lines):
if start_pattern in line:
if near_context:
context_window_start = max(0, i - 5)
context_window_end = min(len(lines), i + 6)
context_block = "\n".join(lines[context_window_start:context_window_end])
if near_context in context_block:
start_pattern_line_indices.append(i)
else:
start_pattern_line_indices.append(i)
if not start_pattern_line_indices:
err_msg = f"Start pattern '{start_pattern}' not found"
if near_context: err_msg += f" near context '{near_context}'"
err_msg += f" in file '{file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}"
# Select the occurrence for the start pattern
num_occurrences = len(start_pattern_line_indices)
try:
occurrence = int(occurrence)
if occurrence == -1:
target_idx = num_occurrences - 1
elif occurrence > 0 and occurrence <= num_occurrences:
target_idx = occurrence - 1
else:
err_msg = f"Occurrence number {occurrence} is out of range for start pattern '{start_pattern}'. Found {num_occurrences} occurrences"
if near_context: err_msg += f" near '{near_context}'"
err_msg += f" in '{file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}"
except ValueError:
coder.io.tool_error(f"Invalid occurrence value: '{occurrence}'. Must be an integer.")
return f"Error: Invalid occurrence value '{occurrence}'"
start_line = start_pattern_line_indices[target_idx]
occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else ""
# Find the end line based on end_pattern or line_count
end_line = -1
if end_pattern:
for i in range(start_line, len(lines)):
if end_pattern in lines[i]:
end_line = i
break
if end_line == -1:
err_msg = f"End pattern '{end_pattern}' not found after {occurrence_str}start pattern '{start_pattern}' (line {start_line + 1}) in '{file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}"
elif line_count:
try:
line_count = int(line_count)
if line_count <= 0: raise ValueError("Line count must be positive")
end_line = min(start_line + line_count - 1, len(lines) - 1)
except ValueError:
coder.io.tool_error(f"Invalid line_count value: '{line_count}'. Must be a positive integer.")
return f"Error: Invalid line_count value '{line_count}'"
else:
end_line = start_line
# Determine indentation amount
try:
indent_levels = int(indent_levels)
except ValueError:
coder.io.tool_error(f"Invalid indent_levels value: '{indent_levels}'. Must be an integer.")
return f"Error: Invalid indent_levels value '{indent_levels}'"
indent_str = ' ' * 4 # Assume 4 spaces per level
# Create a temporary copy to calculate the change
modified_lines = list(lines)
# Apply indentation to the temporary copy
for i in range(start_line, end_line + 1):
if indent_levels > 0:
modified_lines[i] = (indent_str * indent_levels) + modified_lines[i]
elif indent_levels < 0:
spaces_to_remove = abs(indent_levels) * len(indent_str)
current_leading_spaces = len(modified_lines[i]) - len(modified_lines[i].lstrip(' '))
actual_remove = min(spaces_to_remove, current_leading_spaces)
if actual_remove > 0:
modified_lines[i] = modified_lines[i][actual_remove:]
# Join lines back into a string
new_content = '\n'.join(modified_lines)
if original_content == new_content:
coder.io.tool_warning(f"No changes made: indentation would not change file")
return f"Warning: No changes made (indentation would not change file)"
# Generate diff for feedback (assuming _generate_diff_snippet_indent is available on coder)
diff_snippet = coder._generate_diff_snippet_indent(original_content, new_content, start_line, end_line)
# Handle dry run
if dry_run:
action = "indent" if indent_levels > 0 else "unindent"
coder.io.tool_output(f"Dry run: Would {action} lines {start_line+1}-{end_line+1} (based on {occurrence_str}start pattern '{start_pattern}') in {file_path}")
return f"Dry run: Would {action} block. Diff snippet:\n{diff_snippet}"
# --- Apply Change (Not dry run) ---
coder.io.write_text(abs_path, new_content)
# Track the change
try:
metadata = {
'start_line': start_line + 1,
'end_line': end_line + 1,
'start_pattern': start_pattern,
'end_pattern': end_pattern,
'line_count': line_count,
'indent_levels': indent_levels,
'near_context': near_context,
'occurrence': occurrence,
}
change_id = coder.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
)
except Exception as track_e:
coder.io.tool_error(f"Error tracking change for IndentLines: {track_e}")
change_id = "TRACKING_FAILED"
coder.aider_edited_files.add(rel_path)
# Improve feedback
action = "Indented" if indent_levels > 0 else "Unindented"
levels = abs(indent_levels)
level_text = "level" if levels == 1 else "levels"
num_lines = end_line - start_line + 1
coder.io.tool_output(f"{action} {num_lines} lines (from {occurrence_str}start pattern) by {levels} {level_text} in {file_path} (change_id: {change_id})")
return f"Successfully {action.lower()} {num_lines} lines by {levels} {level_text} (change_id: {change_id}). Diff snippet:\n{diff_snippet}"
except Exception as e:
coder.io.tool_error(f"Error in IndentLines: {str(e)}\n{traceback.format_exc()}")
return f"Error: {str(e)}"

160
aider/tools/insert_block.py Normal file
View file

@ -0,0 +1,160 @@
import os
import traceback
def _execute_insert_block(coder, file_path, content, after_pattern=None, before_pattern=None, near_context=None, occurrence=1, change_id=None, dry_run=False):
"""
Insert a block of text after or before a specified pattern.
Parameters:
- coder: The Coder instance
- 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) - specify one of after/before
- before_pattern: Pattern before which to insert the block (line containing this pattern) - specify one of after/before
- near_context: Optional text nearby to help locate the correct instance of the pattern
- occurrence: Which occurrence of the pattern to use (1-based index, or -1 for last)
- change_id: Optional ID for tracking the change
- dry_run: If True, simulate the change without modifying the file
Returns a result message.
"""
try:
# Get absolute file path
abs_path = coder.abs_root_path(file_path)
rel_path = coder.get_rel_fname(abs_path)
# Check if file exists
if not os.path.isfile(abs_path):
coder.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 coder.abs_fnames:
if abs_path in coder.abs_read_only_fnames:
coder.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.")
return f"Error: File is read-only. Use MakeEditable first."
else:
coder.io.tool_error(f"File '{file_path}' not in context")
return f"Error: File not in context"
# Reread file content immediately before modification (Fixes Point 3: Stale Reads)
file_content = coder.io.read_text(abs_path)
if file_content is None:
# Provide more specific error (Improves Point 4)
coder.io.tool_error(f"Could not read file '{file_path}' before InsertBlock operation.")
return f"Error: Could not read file '{file_path}'"
# Validate we have either after_pattern or before_pattern, but not both
if after_pattern and before_pattern:
coder.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:
coder.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 occurrences of the pattern (either after_pattern or before_pattern)
pattern = after_pattern if after_pattern else before_pattern
pattern_type = "after" if after_pattern else "before"
# Find line indices containing the pattern
pattern_line_indices = []
for i, line in enumerate(lines):
if pattern in line:
# If near_context is provided, check if it's nearby
if near_context:
context_window_start = max(0, i - 5) # Check 5 lines before/after
context_window_end = min(len(lines), i + 6)
context_block = "\n".join(lines[context_window_start:context_window_end])
if near_context in context_block:
pattern_line_indices.append(i)
else:
pattern_line_indices.append(i)
if not pattern_line_indices:
err_msg = f"Pattern '{pattern}' not found"
if near_context: err_msg += f" near context '{near_context}'"
err_msg += f" in file '{file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}" # Improve Point 4
# Select the occurrence (Implements Point 5)
num_occurrences = len(pattern_line_indices)
try:
occurrence = int(occurrence) # Ensure occurrence is an integer
if occurrence == -1: # Last occurrence
target_idx = num_occurrences - 1
elif occurrence > 0 and occurrence <= num_occurrences:
target_idx = occurrence - 1 # Convert 1-based to 0-based
else:
err_msg = f"Occurrence number {occurrence} is out of range for pattern '{pattern}'. Found {num_occurrences} occurrences"
if near_context: err_msg += f" near '{near_context}'"
err_msg += f" in '{file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}" # Improve Point 4
except ValueError:
coder.io.tool_error(f"Invalid occurrence value: '{occurrence}'. Must be an integer.")
return f"Error: Invalid occurrence value '{occurrence}'"
# Determine the final insertion line index
insertion_line_idx = pattern_line_indices[target_idx]
if pattern_type == "after":
insertion_line_idx += 1 # Insert on the line *after* the matched line
# Prepare the content to insert
content_lines = content.splitlines()
# Create the new lines array
new_lines = lines[:insertion_line_idx] + content_lines + lines[insertion_line_idx:]
new_content = '\n'.join(new_lines) # Use '\n' to match io.write_text behavior
if original_content == new_content:
coder.io.tool_warning(f"No changes made: insertion would not change file")
return f"Warning: No changes made (insertion would not change file)"
# Generate diff for feedback
diff_snippet = coder._generate_diff_snippet_insert(original_content, insertion_line_idx, content_lines)
# Handle dry run (Implements Point 6)
if dry_run:
occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else ""
coder.io.tool_output(f"Dry run: Would insert block {pattern_type} {occurrence_str}pattern '{pattern}' in {file_path}")
return f"Dry run: Would insert block. Diff snippet:\n{diff_snippet}"
# --- Apply Change (Not dry run) ---
coder.io.write_text(abs_path, new_content)
# Track the change
try:
metadata = {
'insertion_line_idx': insertion_line_idx,
'after_pattern': after_pattern,
'before_pattern': before_pattern,
'near_context': near_context,
'occurrence': occurrence,
'content': content
}
change_id = coder.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
)
except Exception as track_e:
coder.io.tool_error(f"Error tracking change for InsertBlock: {track_e}")
change_id = "TRACKING_FAILED"
coder.aider_edited_files.add(rel_path)
# Improve feedback (Point 5 & 6)
occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else ""
coder.io.tool_output(f"✅ Inserted block {pattern_type} {occurrence_str}pattern in {file_path} (change_id: {change_id})")
return f"Successfully inserted block (change_id: {change_id}). Diff snippet:\n{diff_snippet}"
except Exception as e:
coder.io.tool_error(f"Error in InsertBlock: {str(e)}\n{traceback.format_exc()}") # Add traceback
return f"Error: {str(e)}"

View file

@ -0,0 +1,46 @@
import traceback
from datetime import datetime
def _execute_list_changes(coder, file_path=None, limit=10):
"""
List recent changes made to files.
Parameters:
- coder: The Coder instance
- 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 = coder.abs_root_path(file_path)
rel_file_path = coder.get_rel_fname(abs_path)
# Get the list of changes
changes = coder.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"
coder.io.tool_output(result) # Also print to console for user
return result
except Exception as e:
coder.io.tool_error(f"Error in ListChanges: {str(e)}\n{traceback.format_exc()}") # Add traceback
return f"Error: {str(e)}"

48
aider/tools/ls.py Normal file
View file

@ -0,0 +1,48 @@
import os
def execute_ls(coder, dir_path):
"""
List files in directory and optionally add some to context.
This provides information about the structure of the codebase,
similar to how a developer would explore directories.
"""
try:
# Make the path relative to root if it's absolute
if dir_path.startswith('/'):
rel_dir = os.path.relpath(dir_path, coder.root)
else:
rel_dir = dir_path
# Get absolute path
abs_dir = coder.abs_root_path(rel_dir)
# Check if path exists
if not os.path.exists(abs_dir):
coder.io.tool_output(f"⚠️ Directory '{dir_path}' not found")
return f"Directory not found"
# Get directory contents
contents = []
try:
with os.scandir(abs_dir) as entries:
for entry in entries:
if entry.is_file() and not entry.name.startswith('.'):
rel_path = os.path.join(rel_dir, entry.name)
contents.append(rel_path)
except NotADirectoryError:
# If it's a file, just return the file
contents = [rel_dir]
if contents:
coder.io.tool_output(f"📋 Listed {len(contents)} file(s) in '{dir_path}'")
if len(contents) > 10:
return f"Found {len(contents)} files: {', '.join(contents[:10])}..."
else:
return f"Found {len(contents)} files: {', '.join(contents)}"
else:
coder.io.tool_output(f"📋 No files found in '{dir_path}'")
return f"No files found in directory"
except Exception as e:
coder.io.tool_error(f"Error in ls: {str(e)}")
return f"Error: {str(e)}"

View file

@ -0,0 +1,46 @@
import os
# Keep the underscore prefix as this function is primarily for internal coder use
def _execute_make_editable(coder, file_path):
"""
Convert a read-only file to an editable file.
This allows the LLM to upgrade a file from read-only to editable
when it determines it needs to make changes to that file.
"""
try:
# Get absolute path
abs_path = coder.abs_root_path(file_path)
rel_path = coder.get_rel_fname(abs_path)
# Check if file is already editable
if abs_path in coder.abs_fnames:
coder.io.tool_output(f"📝 File '{file_path}' is already editable")
return f"File is already editable"
# Check if file exists on disk
if not os.path.isfile(abs_path):
coder.io.tool_output(f"⚠️ File '{file_path}' not found")
return f"Error: File not found"
# File exists, is not editable, might be read-only or not in context yet
was_read_only = False
if abs_path in coder.abs_read_only_fnames:
coder.abs_read_only_fnames.remove(abs_path)
was_read_only = True
# Add to editable files
coder.abs_fnames.add(abs_path)
if was_read_only:
coder.io.tool_output(f"📝 Moved '{file_path}' from read-only to editable")
return f"File is now editable (moved from read-only)"
else:
# File was not previously in context at all
coder.io.tool_output(f"📝 Added '{file_path}' directly to editable context")
# Track if added during exploration? Maybe not needed for direct MakeEditable.
# coder.files_added_in_exploration.add(rel_path) # Consider if needed
return f"File is now editable (added directly)"
except Exception as e:
coder.io.tool_error(f"Error in MakeEditable for '{file_path}': {str(e)}")
return f"Error: {str(e)}"

View file

@ -0,0 +1,30 @@
def _execute_make_readonly(coder, file_path):
"""
Convert an editable file to a read-only file.
This allows the LLM to downgrade a file from editable to read-only
when it determines it no longer needs to make changes to that file.
"""
try:
# Get absolute path
abs_path = coder.abs_root_path(file_path)
rel_path = coder.get_rel_fname(abs_path)
# Check if file is in editable context
if abs_path not in coder.abs_fnames:
if abs_path in coder.abs_read_only_fnames:
coder.io.tool_output(f"📚 File '{file_path}' is already read-only")
return f"File is already read-only"
else:
coder.io.tool_output(f"⚠️ File '{file_path}' not in context")
return f"File not in context"
# Move from editable to read-only
coder.abs_fnames.remove(abs_path)
coder.abs_read_only_fnames.add(abs_path)
coder.io.tool_output(f"📚 Made '{file_path}' read-only")
return f"File is now read-only"
except Exception as e:
coder.io.tool_error(f"Error making file read-only: {str(e)}")
return f"Error: {str(e)}"

45
aider/tools/remove.py Normal file
View file

@ -0,0 +1,45 @@
import time
def _execute_remove(coder, file_path):
"""
Explicitly remove a file from context.
This allows the LLM to clean up its context when files are no
longer needed, keeping the context focused and efficient.
"""
try:
# Get absolute path
abs_path = coder.abs_root_path(file_path)
rel_path = coder.get_rel_fname(abs_path)
# Check if file is in context (either editable or read-only)
removed = False
if abs_path in coder.abs_fnames:
# Don't remove if it's the last editable file and there are no read-only files
if len(coder.abs_fnames) <= 1 and not coder.abs_read_only_fnames:
coder.io.tool_output(f"⚠️ Cannot remove '{file_path}' - it's the only file in context")
return f"Cannot remove - last file in context"
coder.abs_fnames.remove(abs_path)
removed = True
elif abs_path in coder.abs_read_only_fnames:
# Don't remove if it's the last read-only file and there are no editable files
if len(coder.abs_read_only_fnames) <= 1 and not coder.abs_fnames:
coder.io.tool_output(f"⚠️ Cannot remove '{file_path}' - it's the only file in context")
return f"Cannot remove - last file in context"
coder.abs_read_only_fnames.remove(abs_path)
removed = True
if not removed:
coder.io.tool_output(f"⚠️ File '{file_path}' not in context")
return f"File not in context"
# Track in recently removed
coder.recently_removed[rel_path] = {
'removed_at': time.time()
}
coder.io.tool_output(f"🗑️ Explicitly removed '{file_path}' from context")
return f"Removed file from context"
except Exception as e:
coder.io.tool_error(f"Error removing file: {str(e)}")
return f"Error: {str(e)}"

View file

@ -0,0 +1,95 @@
import os
import traceback
def _execute_replace_all(coder, file_path, find_text, replace_text, change_id=None, dry_run=False):
"""
Replace all occurrences of text in a file.
Parameters:
- coder: The Coder instance
- 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
- dry_run: If True, simulate the change without modifying the file
Returns a result message.
"""
try:
# Get absolute file path
abs_path = coder.abs_root_path(file_path)
rel_path = coder.get_rel_fname(abs_path)
# Check if file exists
if not os.path.isfile(abs_path):
coder.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 coder.abs_fnames:
if abs_path in coder.abs_read_only_fnames:
coder.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.")
return f"Error: File is read-only. Use MakeEditable first."
else:
coder.io.tool_error(f"File '{file_path}' not in context")
return f"Error: File not in context"
# Reread file content immediately before modification
content = coder.io.read_text(abs_path)
if content is None:
coder.io.tool_error(f"Could not read file '{file_path}' before ReplaceAll operation.")
return f"Error: Could not read file '{file_path}'"
# Count occurrences
count = content.count(find_text)
if count == 0:
coder.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:
coder.io.tool_warning(f"No changes made: replacement text is identical to original")
return f"Warning: No changes made (replacement identical to original)"
# Generate diff for feedback (more comprehensive for ReplaceAll)
diff_examples = coder._generate_diff_chunks(original_content, find_text, replace_text)
# Handle dry run
if dry_run:
coder.io.tool_output(f"Dry run: Would replace {count} occurrences of '{find_text}' in {file_path}")
return f"Dry run: Would replace {count} occurrences. Diff examples:\n{diff_examples}"
# --- Apply Change (Not dry run) ---
coder.io.write_text(abs_path, new_content)
# Track the change
try:
metadata = {
'find_text': find_text,
'replace_text': replace_text,
'occurrences': count
}
change_id = coder.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
)
except Exception as track_e:
coder.io.tool_error(f"Error tracking change for ReplaceAll: {track_e}")
change_id = "TRACKING_FAILED"
coder.aider_edited_files.add(rel_path)
# Improve feedback
coder.io.tool_output(f"✅ Replaced {count} occurrences in {file_path} (change_id: {change_id})")
return f"Successfully replaced {count} occurrences (change_id: {change_id}). Diff examples:\n{diff_examples}"
except Exception as e:
coder.io.tool_error(f"Error in ReplaceAll: {str(e)}\n{traceback.format_exc()}")
return f"Error: {str(e)}"

115
aider/tools/replace_line.py Normal file
View file

@ -0,0 +1,115 @@
import os
import traceback
def _execute_replace_line(coder, file_path, line_number, new_content, change_id=None, dry_run=False):
"""
Replace a specific line identified by line number.
Useful for fixing errors identified by error messages or linters.
Parameters:
- coder: The Coder instance
- 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
- dry_run: If True, simulate the change without modifying the file
Returns a result message.
"""
try:
# Get absolute file path
abs_path = coder.abs_root_path(file_path)
rel_path = coder.get_rel_fname(abs_path)
# Check if file exists
if not os.path.isfile(abs_path):
coder.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 coder.abs_fnames:
if abs_path in coder.abs_read_only_fnames:
coder.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.")
return f"Error: File is read-only. Use MakeEditable first."
else:
coder.io.tool_error(f"File '{file_path}' not in context")
return f"Error: File not in context"
# Reread file content immediately before modification
file_content = coder.io.read_text(abs_path)
if file_content is None:
coder.io.tool_error(f"Could not read file '{file_path}' before ReplaceLine operation.")
return f"Error: Could not read 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:
coder.io.tool_error(f"Line number must be an integer, got '{line_number}'")
coder.io.tool_error(f"Invalid line_number value: '{line_number}'. Must be an integer.")
return f"Error: Invalid line_number value '{line_number}'"
# Convert 1-based line number to 0-based index
idx = line_number - 1
if idx < 0 or idx >= len(lines):
coder.io.tool_error(f"Line number {line_number} is out of range for file '{file_path}' (has {len(lines)} lines).")
return f"Error: Line number {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:
coder.io.tool_warning("No changes made: new line content is identical to original")
return f"Warning: No changes made (new content identical to original)"
# Create a readable diff for the line replacement
diff = f"Line {line_number}:\n- {original_line}\n+ {new_content}"
# Handle dry run
if dry_run:
coder.io.tool_output(f"Dry run: Would replace line {line_number} in {file_path}")
return f"Dry run: Would replace line {line_number}. Diff:\n{diff}"
# --- Apply Change (Not dry run) ---
coder.io.write_text(abs_path, new_content_full)
# Track the change
try:
metadata = {
'line_number': line_number,
'original_line': original_line,
'new_line': new_content
}
change_id = coder.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
)
except Exception as track_e:
coder.io.tool_error(f"Error tracking change for ReplaceLine: {track_e}")
change_id = "TRACKING_FAILED"
coder.aider_edited_files.add(rel_path)
# Improve feedback
coder.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}). Diff:\n{diff}"
except Exception as e:
coder.io.tool_error(f"Error in ReplaceLine: {str(e)}\n{traceback.format_exc()}")
return f"Error: {str(e)}"

View file

@ -0,0 +1,139 @@
import os
import traceback
def _execute_replace_lines(coder, file_path, start_line, end_line, new_content, change_id=None, dry_run=False):
"""
Replace a range of lines identified by line numbers.
Useful for fixing errors identified by error messages or linters.
Parameters:
- coder: The Coder instance
- 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
- dry_run: If True, simulate the change without modifying the file
Returns a result message.
"""
try:
# Get absolute file path
abs_path = coder.abs_root_path(file_path)
rel_path = coder.get_rel_fname(abs_path)
# Check if file exists
if not os.path.isfile(abs_path):
coder.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 coder.abs_fnames:
if abs_path in coder.abs_read_only_fnames:
coder.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.")
return f"Error: File is read-only. Use MakeEditable first."
else:
coder.io.tool_error(f"File '{file_path}' not in context")
return f"Error: File not in context"
# Reread file content immediately before modification
file_content = coder.io.read_text(abs_path)
if file_content is None:
coder.io.tool_error(f"Could not read file '{file_path}' before ReplaceLines operation.")
return f"Error: Could not read file '{file_path}'"
# Convert line numbers to integers if needed
if not isinstance(start_line, int):
try:
start_line = int(start_line)
except ValueError:
coder.io.tool_error(f"Invalid start_line value: '{start_line}'. Must be an integer.")
return f"Error: Invalid start_line value '{start_line}'"
if not isinstance(end_line, int):
try:
end_line = int(end_line)
except ValueError:
coder.io.tool_error(f"Invalid end_line value: '{end_line}'. Must be an integer.")
return f"Error: Invalid end_line value '{end_line}'"
# 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):
coder.io.tool_error(f"Start line {start_line} is out of range for file '{file_path}' (has {len(lines)} lines).")
return f"Error: Start line {start_line} out of range"
if end_idx < start_idx or end_idx >= len(lines):
coder.io.tool_error(f"End line {end_line} is out of range for file '{file_path}' (must be >= start line {start_line} and <= {len(lines)}).")
return f"Error: End line {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:
coder.io.tool_warning("No changes made: new content is identical to original")
return f"Warning: No changes made (new content identical to original)"
# 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"
# Handle dry run
if dry_run:
coder.io.tool_output(f"Dry run: Would replace lines {start_line}-{end_line} in {file_path}")
return f"Dry run: Would replace lines {start_line}-{end_line}. Diff:\n{diff}"
# --- Apply Change (Not dry run) ---
coder.io.write_text(abs_path, new_content_full)
# Track the change
try:
metadata = {
'start_line': start_line,
'end_line': end_line,
'replaced_lines': replaced_lines,
'new_lines': new_lines
}
change_id = coder.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
)
except Exception as track_e:
coder.io.tool_error(f"Error tracking change for ReplaceLines: {track_e}")
change_id = "TRACKING_FAILED"
coder.aider_edited_files.add(rel_path)
replaced_count = end_line - start_line + 1
new_count = len(new_lines)
# Improve feedback
coder.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}). Diff:\n{diff}"
except Exception as e:
coder.io.tool_error(f"Error in ReplaceLines: {str(e)}\n{traceback.format_exc()}")
return f"Error: {str(e)}"

125
aider/tools/replace_text.py Normal file
View file

@ -0,0 +1,125 @@
import os
import traceback
def _execute_replace_text(coder, file_path, find_text, replace_text, near_context=None, occurrence=1, change_id=None, dry_run=False):
"""
Replace specific text with new text, optionally using nearby context for disambiguation.
Parameters:
- coder: The Coder instance
- 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
- dry_run: If True, simulate the change without modifying the file
Returns a result message.
"""
try:
# Get absolute file path
abs_path = coder.abs_root_path(file_path)
rel_path = coder.get_rel_fname(abs_path)
# Check if file exists
if not os.path.isfile(abs_path):
coder.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 coder.abs_fnames:
if abs_path in coder.abs_read_only_fnames:
coder.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.")
return f"Error: File is read-only. Use MakeEditable first."
else:
coder.io.tool_error(f"File '{file_path}' not in context")
return f"Error: File not in context"
# Reread file content immediately before modification
content = coder.io.read_text(abs_path)
if content is None:
coder.io.tool_error(f"Could not read file '{file_path}' before ReplaceText operation.")
return f"Error: Could not read file '{file_path}'"
# Find occurrences using helper function (assuming _find_occurrences is available on coder)
occurrences = coder._find_occurrences(content, find_text, near_context)
if not occurrences:
err_msg = f"Text '{find_text}' not found"
if near_context:
err_msg += f" near context '{near_context}'"
err_msg += f" in file '{file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}"
# Select the occurrence
num_occurrences = len(occurrences)
try:
occurrence = int(occurrence)
if occurrence == -1:
target_idx = num_occurrences - 1
elif occurrence > 0 and occurrence <= num_occurrences:
target_idx = occurrence - 1
else:
err_msg = f"Occurrence number {occurrence} is out of range. Found {num_occurrences} occurrences of '{find_text}'"
if near_context: err_msg += f" near '{near_context}'"
err_msg += f" in '{file_path}'."
coder.io.tool_error(err_msg)
return f"Error: {err_msg}"
except ValueError:
coder.io.tool_error(f"Invalid occurrence value: '{occurrence}'. Must be an integer.")
return f"Error: Invalid occurrence value '{occurrence}'"
start_index = occurrences[target_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:
coder.io.tool_warning(f"No changes made: replacement text is identical to original")
return f"Warning: No changes made (replacement identical to original)"
# Generate diff for feedback (assuming _generate_diff_snippet is available on coder)
diff_example = coder._generate_diff_snippet(original_content, start_index, len(find_text), replace_text)
# Handle dry run
if dry_run:
coder.io.tool_output(f"Dry run: Would replace occurrence {occurrence} of '{find_text}' in {file_path}")
return f"Dry run: Would replace text (occurrence {occurrence}). Diff snippet:\n{diff_example}"
# --- Apply Change (Not dry run) ---
coder.io.write_text(abs_path, new_content)
# Track the change
try:
metadata = {
'start_index': start_index,
'find_text': find_text,
'replace_text': replace_text,
'near_context': near_context,
'occurrence': occurrence
}
change_id = coder.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
)
except Exception as track_e:
coder.io.tool_error(f"Error tracking change for ReplaceText: {track_e}")
change_id = "TRACKING_FAILED"
coder.aider_edited_files.add(rel_path)
# Improve feedback
occurrence_str = f"occurrence {occurrence}" if num_occurrences > 1 else "text"
coder.io.tool_output(f"✅ Replaced {occurrence_str} in {file_path} (change_id: {change_id})")
return f"Successfully replaced {occurrence_str} (change_id: {change_id}). Diff snippet:\n{diff_example}"
except Exception as e:
coder.io.tool_error(f"Error in ReplaceText: {str(e)}\n{traceback.format_exc()}")
return f"Error: {str(e)}"

View file

@ -0,0 +1,56 @@
import traceback
def _execute_undo_change(coder, change_id=None, file_path=None):
"""
Undo a specific change by ID, or the last change to a file.
Parameters:
- coder: The Coder instance
- change_id: ID of the change to undo
- file_path: Path to file where the last change should be undone
Returns a result message.
"""
# Note: Undo does not have a dry_run parameter as it's inherently about reverting a previous action.
try:
# Validate parameters
if change_id is None and file_path is None:
coder.io.tool_error("Must specify either change_id or file_path for UndoChange")
return "Error: Must specify either change_id or file_path"
# If file_path is specified, get the most recent change for that file
if file_path:
abs_path = coder.abs_root_path(file_path)
rel_path = coder.get_rel_fname(abs_path)
change_id = coder.change_tracker.get_last_change(rel_path)
if not change_id:
coder.io.tool_error(f"No tracked changes found for file '{file_path}' to undo.")
return f"Error: No changes found for file '{file_path}'"
# Attempt to get undo information from the tracker
success, message, change_info = coder.change_tracker.undo_change(change_id)
if not success:
coder.io.tool_error(f"Failed to undo change '{change_id}': {message}")
return f"Error: {message}"
# Apply the undo by restoring the original content
if change_info:
file_path = change_info['file_path']
abs_path = coder.abs_root_path(file_path)
# Write the original content back to the file
coder.io.write_text(abs_path, change_info['original'])
coder.aider_edited_files.add(file_path) # Track that the file was modified by the undo
change_type = change_info['type']
coder.io.tool_output(f"✅ Undid {change_type} change '{change_id}' in {file_path}")
return f"Successfully undid {change_type} change '{change_id}'."
else:
# This case should ideally not be reached if tracker returns success
coder.io.tool_error(f"Failed to undo change '{change_id}': Change info missing after successful tracker update.")
return f"Error: Failed to undo change '{change_id}' (missing change info)"
except Exception as e:
coder.io.tool_error(f"Error in UndoChange: {str(e)}\n{traceback.format_exc()}")
return f"Error: {str(e)}"

13
aider/tools/view.py Normal file
View file

@ -0,0 +1,13 @@
def execute_view(coder, file_path):
"""
Explicitly add a file to context as read-only.
This gives the LLM explicit control over what files to view,
rather than relying on indirect mentions.
"""
try:
# Use the coder's helper, marking it as an explicit view request
return coder._add_file_to_context(file_path, explicit=True)
except Exception as e:
coder.io.tool_error(f"Error viewing file: {str(e)}")
return f"Error: {str(e)}"

View file

@ -0,0 +1,55 @@
import os
import fnmatch
def execute_view_files_at_glob(coder, pattern):
"""
Execute a glob pattern and add matching files to context as read-only.
This tool helps the LLM find files by pattern matching, similar to
how a developer would use glob patterns to find files.
"""
try:
# Find files matching the pattern
matching_files = []
# Make the pattern relative to root if it's absolute
if pattern.startswith('/'):
pattern = os.path.relpath(pattern, coder.root)
# Get all files in the repo
all_files = coder.get_all_relative_files()
# Find matches with pattern matching
for file in all_files:
if fnmatch.fnmatch(file, pattern):
matching_files.append(file)
# Limit the number of files added if there are too many matches
if len(matching_files) > coder.max_files_per_glob:
coder.io.tool_output(
f"⚠️ Found {len(matching_files)} files matching '{pattern}', "
f"limiting to {coder.max_files_per_glob} most relevant files."
)
# Sort by modification time (most recent first)
matching_files.sort(key=lambda f: os.path.getmtime(coder.abs_root_path(f)), reverse=True)
matching_files = matching_files[:coder.max_files_per_glob]
# Add files to context
for file in matching_files:
# Use the coder's internal method to add files
coder._add_file_to_context(file)
# Return a user-friendly result
if matching_files:
if len(matching_files) > 10:
brief = ', '.join(matching_files[:5]) + f', and {len(matching_files)-5} more'
coder.io.tool_output(f"📂 Added {len(matching_files)} files matching '{pattern}': {brief}")
else:
coder.io.tool_output(f"📂 Added files matching '{pattern}': {', '.join(matching_files)}")
return f"Added {len(matching_files)} files: {', '.join(matching_files[:5])}{' and more' if len(matching_files) > 5 else ''}"
else:
coder.io.tool_output(f"⚠️ No files found matching '{pattern}'")
return f"No files found matching '{pattern}'"
except Exception as e:
coder.io.tool_error(f"Error in ViewFilesAtGlob: {str(e)}")
return f"Error: {str(e)}"

View file

@ -0,0 +1,71 @@
import os
import fnmatch
def execute_view_files_matching(coder, search_pattern, file_pattern=None):
"""
Search for pattern in files and add matching files to context as read-only.
This tool lets the LLM search for content within files, mimicking
how a developer would use grep to find relevant code.
"""
try:
# Get list of files to search
if file_pattern:
# Use glob pattern to filter files
all_files = coder.get_all_relative_files()
files_to_search = []
for file in all_files:
if fnmatch.fnmatch(file, file_pattern):
files_to_search.append(file)
if not files_to_search:
return f"No files matching '{file_pattern}' to search for pattern '{search_pattern}'"
else:
# Search all files if no pattern provided
files_to_search = coder.get_all_relative_files()
# Search for pattern in files
matches = {}
for file in files_to_search:
abs_path = coder.abs_root_path(file)
try:
with open(abs_path, 'r', encoding='utf-8') as f:
content = f.read()
if search_pattern in content:
matches[file] = content.count(search_pattern)
except Exception:
# Skip files that can't be read (binary, etc.)
pass
# Limit the number of files added if there are too many matches
if len(matches) > coder.max_files_per_glob:
coder.io.tool_output(
f"⚠️ Found '{search_pattern}' in {len(matches)} files, "
f"limiting to {coder.max_files_per_glob} files with most matches."
)
# Sort by number of matches (most matches first)
sorted_matches = sorted(matches.items(), key=lambda x: x[1], reverse=True)
matches = dict(sorted_matches[:coder.max_files_per_glob])
# Add matching files to context
for file in matches:
coder._add_file_to_context(file)
# Return a user-friendly result
if matches:
# Sort by number of matches (most matches first)
sorted_matches = sorted(matches.items(), key=lambda x: x[1], reverse=True)
match_list = [f"{file} ({count} matches)" for file, count in sorted_matches[:5]]
if len(sorted_matches) > 5:
coder.io.tool_output(f"🔍 Found '{search_pattern}' in {len(matches)} files: {', '.join(match_list)} and {len(matches)-5} more")
return f"Found in {len(matches)} files: {', '.join(match_list)} and {len(matches)-5} more"
else:
coder.io.tool_output(f"🔍 Found '{search_pattern}' in: {', '.join(match_list)}")
return f"Found in {len(matches)} files: {', '.join(match_list)}"
else:
coder.io.tool_output(f"⚠️ Pattern '{search_pattern}' not found in any files")
return f"Pattern not found in any files"
except Exception as e:
coder.io.tool_error(f"Error in ViewFilesMatching: {str(e)}")
return f"Error: {str(e)}"

View file

@ -0,0 +1,75 @@
import os
def _execute_view_files_with_symbol(coder, symbol):
"""
Find files containing a specific symbol and add them to context as read-only.
"""
try:
if not coder.repo_map:
coder.io.tool_output("⚠️ Repo map not available, cannot use ViewFilesWithSymbol tool.")
return "Repo map not available"
if not symbol:
return "Error: Missing 'symbol' parameter for ViewFilesWithSymbol"
coder.io.tool_output(f"🔎 Searching for symbol '{symbol}'...")
found_files = set()
current_context_files = coder.abs_fnames | coder.abs_read_only_fnames
files_to_search = set(coder.get_all_abs_files()) - current_context_files
rel_fname_to_abs = {}
all_tags = []
for fname in files_to_search:
rel_fname = coder.get_rel_fname(fname)
rel_fname_to_abs[rel_fname] = fname
try:
tags = coder.repo_map.get_tags(fname, rel_fname)
all_tags.extend(tags)
except Exception as e:
coder.io.tool_warning(f"Could not get tags for {rel_fname}: {e}")
# Find matching symbols
for tag in all_tags:
if tag.name == symbol:
# Use absolute path directly if available, otherwise resolve from relative path
abs_fname = rel_fname_to_abs.get(tag.rel_fname) or coder.abs_root_path(tag.fname)
if abs_fname in files_to_search: # Ensure we only add files we intended to search
found_files.add(abs_fname)
# Limit the number of files added
if len(found_files) > coder.max_files_per_glob:
coder.io.tool_output(
f"⚠️ Found symbol '{symbol}' in {len(found_files)} files, "
f"limiting to {coder.max_files_per_glob} most relevant files."
)
# Sort by modification time (most recent first) - approximate relevance
sorted_found_files = sorted(list(found_files), key=lambda f: os.path.getmtime(f), reverse=True)
found_files = set(sorted_found_files[:coder.max_files_per_glob])
# Add files to context (as read-only)
added_count = 0
added_files_rel = []
for abs_file_path in found_files:
rel_path = coder.get_rel_fname(abs_file_path)
# Double check it's not already added somehow
if abs_file_path not in coder.abs_fnames and abs_file_path not in coder.abs_read_only_fnames:
add_result = coder._add_file_to_context(rel_path, explicit=True) # Use explicit=True for clear output
if "Added" in add_result:
added_count += 1
added_files_rel.append(rel_path)
if added_count > 0:
if added_count > 5:
brief = ', '.join(added_files_rel[:5]) + f', and {added_count-5} more'
coder.io.tool_output(f"🔎 Found '{symbol}' and added {added_count} files: {brief}")
else:
coder.io.tool_output(f"🔎 Found '{symbol}' and added files: {', '.join(added_files_rel)}")
return f"Found symbol '{symbol}' and added {added_count} files as read-only."
else:
coder.io.tool_output(f"⚠️ Symbol '{symbol}' not found in searchable files.")
return f"Symbol '{symbol}' not found in searchable files."
except Exception as e:
coder.io.tool_error(f"Error in ViewFilesWithSymbol: {str(e)}")
return f"Error: {str(e)}"