diff --git a/aider/commands/load.py b/aider/commands/load.py index 00e4bc547b2..266fce9ca38 100644 --- a/aider/commands/load.py +++ b/aider/commands/load.py @@ -2,6 +2,7 @@ from aider.commands.utils.base_command import BaseCommand from aider.commands.utils.helpers import format_command_result +from aider.commands.utils.save_load_manager import SaveLoadManager class LoadCommand(BaseCommand): @@ -15,12 +16,13 @@ async def execute(cls, io, coder, args, **kwargs): io.tool_error("Please provide a filename containing commands to load.") return format_command_result(io, "load", "No filename provided") + manager = SaveLoadManager(coder, io) + try: - with open(args.strip(), "r", encoding=io.encoding, errors="replace") as f: - commands = f.readlines() - except FileNotFoundError: - io.tool_error(f"File not found: {args}") - return format_command_result(io, "load", f"File not found: {args}") + commands = manager.load_commands(args.strip()) + except FileNotFoundError as e: + io.tool_error(str(e)) + return format_command_result(io, "load", str(e)) except Exception as e: io.tool_error(f"Error reading file: {e}") return format_command_result(io, "load", f"Error reading file: {e}") @@ -34,6 +36,7 @@ async def execute(cls, io, coder, args, **kwargs): commands_instance = Commands(io, coder) + should_raise_at_end = None for cmd in commands: cmd = cmd.strip() if not cmd or cmd.startswith("#"): @@ -45,13 +48,18 @@ async def execute(cls, io, coder, args, **kwargs): except Exception as e: # Handle SwitchCoder exception specifically if type(e).__name__ == "SwitchCoder": - io.tool_error( - f"Command '{cmd}' is only supported in interactive mode, skipping." - ) + # SwitchCoder is raised when switching between coder types (e.g., /architect, /ask). + # This is expected behavior, not an error. But this gets in the way when running `/load` so we + # ignore it and continue processing remaining commands. + should_raise_at_end = e + continue else: # Re-raise other exceptions raise + if should_raise_at_end: + raise should_raise_at_end + return format_command_result( io, "load", f"Loaded and executed commands from {args.strip()}" ) @@ -59,7 +67,8 @@ async def execute(cls, io, coder, args, **kwargs): @classmethod def get_completions(cls, io, coder, args) -> List[str]: """Get completion options for load command.""" - return [] + manager = SaveLoadManager(coder, io) + return manager.list_files() @classmethod def get_help(cls) -> str: diff --git a/aider/commands/save.py b/aider/commands/save.py index 9b4834c92ab..be04542d16a 100644 --- a/aider/commands/save.py +++ b/aider/commands/save.py @@ -1,8 +1,8 @@ -from pathlib import Path from typing import List from aider.commands.utils.base_command import BaseCommand from aider.commands.utils.helpers import format_command_result +from aider.commands.utils.save_load_manager import SaveLoadManager class SaveCommand(BaseCommand): @@ -13,38 +13,16 @@ class SaveCommand(BaseCommand): async def execute(cls, io, coder, args, **kwargs): """Execute the save command with given parameters.""" if not args.strip(): - io.tool_error("Please provide a filename to save the commands to.") - return format_command_result(io, "save", "No filename provided") + return format_command_result( + io, "save", "", "No filename provided to save the commands to" + ) - try: - with open(args.strip(), "w", encoding=io.encoding) as f: - f.write("/drop\n") - # Write commands to add editable files - for fname in sorted(coder.abs_fnames): - rel_fname = coder.get_rel_fname(fname) - f.write(f"/add {rel_fname}\n") - - # Write commands to add read-only files - for fname in sorted(coder.abs_read_only_fnames): - # Use absolute path for files outside repo root, relative path for files inside - if Path(fname).is_relative_to(coder.root): - rel_fname = coder.get_rel_fname(fname) - f.write(f"/read-only {rel_fname}\n") - else: - f.write(f"/read-only {fname}\n") - # Write commands to add read-only stubs files - for fname in sorted(coder.abs_read_only_stubs_fnames): - # Use absolute path for files outside repo root, relative path for files inside - if Path(fname).is_relative_to(coder.root): - rel_fname = coder.get_rel_fname(fname) - f.write(f"/read-only-stub {rel_fname}\n") - else: - f.write(f"/read-only-stub {fname}\n") + manager = SaveLoadManager(coder, io) - io.tool_output(f"Saved commands to {args.strip()}") - return format_command_result(io, "save", f"Saved commands to {args.strip()}") + try: + filepath = manager.save_commands(args.strip()) + return format_command_result(io, "save", f"Saved commands to {filepath}") except Exception as e: - io.tool_error(f"Error saving commands to file: {e}") return format_command_result(io, "save", f"Error saving commands to file: {e}", e) @classmethod @@ -61,7 +39,10 @@ def get_help(cls) -> str: help_text += "\nUsage:\n" help_text += " /save # Save commands to reconstruct current chat session\n" help_text += "\nExamples:\n" - help_text += " /save session.txt # Save session commands to session.txt\n" + help_text += " /save session # Save to .aider/saves/session.txt\n" + help_text += " /save session.txt # Save to .aider/saves/session.txt\n" + help_text += " /save ./session.txt # Save to ./session.txt (explicit path)\n" + help_text += " /save /tmp/session.txt # Save to /tmp/session.txt (absolute path)\n" help_text += "\nThe saved file contains commands that can be used with /load to restore\n" help_text += "the current chat session, including all editable and read-only files.\n" help_text += "The file starts with /drop to clear existing files, then adds all files.\n" diff --git a/aider/commands/utils/helpers.py b/aider/commands/utils/helpers.py index bb55e782ba9..665bf2492db 100644 --- a/aider/commands/utils/helpers.py +++ b/aider/commands/utils/helpers.py @@ -90,7 +90,9 @@ def validate_file_access(io, coder, file_path: str, require_in_chat: bool = Fals return True -def format_command_result(io, command_name: str, success_message: str, error: Exception = None): +def format_command_result( + io, command_name: str, success_message: str, error: Exception | str = None +): """ Format command execution result consistently. diff --git a/aider/commands/utils/save_load_manager.py b/aider/commands/utils/save_load_manager.py new file mode 100644 index 00000000000..f7ea7a4720b --- /dev/null +++ b/aider/commands/utils/save_load_manager.py @@ -0,0 +1,98 @@ +import os +from pathlib import Path +from typing import List + + +class SaveLoadManager: + """Manager for saving and loading command files.""" + + def __init__(self, coder, io): + self.coder = coder + self.io = io + + def get_saves_directory(self) -> Path: + """Get the saves directory, creating it if necessary.""" + saves_dir = Path(self.coder.abs_root_path(".aider/saves")) + os.makedirs(saves_dir, exist_ok=True) + return saves_dir + + def resolve_filepath(self, filename: str) -> Path: + """Resolve a filename to an absolute path, using saves directory if needed.""" + filepath = Path(filename) + + # If it's a simple filename (no directory separators), save to .aider/saves/ + if not filepath.is_absolute() and str(filepath) == filepath.name: + saves_dir = self.get_saves_directory() + filepath = saves_dir / filepath + + return filepath + + def save_commands(self, filename: str) -> Path: + """Save commands to reconstruct the current chat session to a file.""" + filepath = self.resolve_filepath(filename) + + try: + # Ensure parent directory exists + os.makedirs(filepath.parent, exist_ok=True) + + with open(filepath, "w", encoding=self.io.encoding) as f: + f.write("/drop\n") + # Write commands to add editable files + for fname in sorted(self.coder.abs_fnames): + rel_fname = self.coder.get_rel_fname(fname) + f.write(f"/add {rel_fname}\n") + + # Write commands to add read-only files + for fname in sorted(self.coder.abs_read_only_fnames): + # Use absolute path for files outside repo root, relative path for files inside + if Path(fname).is_relative_to(self.coder.root): + rel_fname = self.coder.get_rel_fname(fname) + f.write(f"/read-only {rel_fname}\n") + else: + f.write(f"/read-only {fname}\n") + # Write commands to add read-only stubs files + for fname in sorted(self.coder.abs_read_only_stubs_fnames): + # Use absolute path for files outside repo root, relative path for files inside + if Path(fname).is_relative_to(self.coder.root): + rel_fname = self.coder.get_rel_fname(fname) + f.write(f"/read-only-stub {rel_fname}\n") + else: + f.write(f"/read-only-stub {fname}\n") + + return filepath + except Exception as e: + raise IOError(f"Error saving commands to file: {e}") + + def load_commands(self, filename: str) -> List[str]: + """Load commands from a file.""" + filepath = self.resolve_filepath(filename) + + try: + with open(filepath, "r", encoding=self.io.encoding, errors="replace") as f: + commands = f.readlines() + return [ + cmd.strip() for cmd in commands if cmd.strip() and not cmd.strip().startswith("#") + ] + except FileNotFoundError: + raise FileNotFoundError(f"File not found: {filepath}") + except Exception as e: + raise IOError(f"Error reading file: {e}") + + def list_files(self) -> List[str]: + """Return a list of all filenames (without extensions) in the saves directory. + + Returns: + List[str]: List of filenames without extensions, sorted alphabetically + """ + try: + saves_dir = self.get_saves_directory() + + if not saves_dir.exists(): + return [] + + # Get all files (not directories) in the saves directory + save_files = [f.name for f in saves_dir.iterdir() if f.is_file()] + return sorted(save_files) + except Exception: + # Return empty list on any error + return []