Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions aider/commands/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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}")
Expand All @@ -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("#"):
Expand All @@ -45,21 +48,27 @@ 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()}"
)

@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:
Expand Down
43 changes: 12 additions & 31 deletions aider/commands/save.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand All @@ -61,7 +39,10 @@ def get_help(cls) -> str:
help_text += "\nUsage:\n"
help_text += " /save <filename> # 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"
Expand Down
4 changes: 3 additions & 1 deletion aider/commands/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
98 changes: 98 additions & 0 deletions aider/commands/utils/save_load_manager.py
Original file line number Diff line number Diff line change
@@ -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 []