diff --git a/README.md b/README.md index f2aecb6f69c..2dc86be1174 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,23 @@ aider --model sonnet --api-key anthropic= aider --model o3-mini --api-key openai= ``` +### Configuration + +Create a `.aider.conf.yml` file to configure model aliases and other settings: + +```yaml +# Use a list to define multiple aliases +alias: + - "fast:gpt-4o-mini" + - "smart:o3-mini" + - "hacker:claude-3-sonnet-20240229" + +# Default model +model: "gpt-4o-mini" +``` + +> **Note:** YAML does not support repeated keys. Always use a list format for multiple aliases. + See the [installation instructions](https://aider.chat/docs/install.html) and [usage documentation](https://aider.chat/docs/usage.html) for more details. ## More Information diff --git a/aider/args.py b/aider/args.py index 5b3fdf07faf..bbfa2915f34 100644 --- a/aider/args.py +++ b/aider/args.py @@ -392,6 +392,12 @@ def get_parser(default_config_files, git_root): " see https://pygments.org/styles for available themes)" ), ) + group.add_argument( + "--code-theme-no-background", + action="store_true", + help="Disable background colors in code theme (default: False)", + default=False, + ) group.add_argument( "--show-diffs", action="store_true", diff --git a/aider/io.py b/aider/io.py index ed6f22d51ae..9411b29b96d 100644 --- a/aider/io.py +++ b/aider/io.py @@ -252,6 +252,7 @@ def __init__( completion_menu_current_color=None, completion_menu_current_bg_color=None, code_theme="default", + code_theme_no_background=False, encoding="utf-8", line_endings="platform", dry_run=False, @@ -297,6 +298,7 @@ def __init__( ) self.code_theme = code_theme + self.code_theme_no_background = code_theme_no_background self.input = input self.output = output @@ -1015,6 +1017,7 @@ def get_assistant_mdstream(self): mdargs = dict( style=self.assistant_output_color, code_theme=self.code_theme, + code_theme_no_background=self.code_theme_no_background, inline_code_lexer="text", ) mdStream = MarkdownStream(mdargs=mdargs) diff --git a/aider/main.py b/aider/main.py index afb3f836624..f1b6f699e2a 100644 --- a/aider/main.py +++ b/aider/main.py @@ -140,14 +140,25 @@ def setup_git(git_root, io): if user_name and user_email: return repo.working_tree_dir - - with repo.config_writer() as git_config: - if not user_name: - git_config.set_value("user", "name", "Your Name") - io.tool_warning('Update git name with: git config user.name "Your Name"') - if not user_email: - git_config.set_value("user", "email", "you@example.com") - io.tool_warning('Update git email with: git config user.email "you@example.com"') + + try: + with repo.config_writer() as git_config: + if not user_name: + git_config.set_value("user", "name", "Your Name") + io.tool_warning('Update git name with: git config user.name "Your Name"') + if not user_email: + git_config.set_value("user", "email", "you@example.com") + io.tool_warning('Update git email with: git config user.email "you@example.com"') + except (OSError, PermissionError) as e: + io.tool_warning( + f"Warning: Could not write to git config: {e}\n" + f"This may be due to network drive permissions or file locking issues.\n" + f"You may need to manually set git config values using:\n" + f"git config user.name \"Your Name\"\n" + f"git config user.email \"you@example.com\"" + ) + # Continue without modifying config, in read-only mode + pass return repo.working_tree_dir @@ -542,6 +553,11 @@ def main(argv=None, input=None, output=None, force_git_root=None, return_coder=F args.tool_warning_color = "#FFA500" args.assistant_output_color = "blue" args.code_theme = "default" + # Clear background colors for light mode + args.completion_menu_bg_color = None + args.completion_menu_current_bg_color = None + # Override Pygments theme to disable backgrounds + args.code_theme_no_background = True if return_coder and args.yes_always is None: args.yes_always = True @@ -566,6 +582,7 @@ def get_io(pretty): completion_menu_current_bg_color=args.completion_menu_current_bg_color, assistant_output_color=args.assistant_output_color, code_theme=args.code_theme, + code_theme_no_background=args.code_theme_no_background, dry_run=args.dry_run, encoding=args.encoding, line_endings=args.line_endings, diff --git a/aider/md_renderer.py b/aider/md_renderer.py new file mode 100644 index 00000000000..7debc6cccfb --- /dev/null +++ b/aider/md_renderer.py @@ -0,0 +1,28 @@ +from rich.markdown import Markdown, Padding +from rich.syntax import Syntax + +from .theme_utils import get_code_theme + +class CustomMarkdown(Markdown): + """Custom Markdown renderer that handles code block backgrounds""" + + def __init__(self, text, code_theme="default", code_theme_no_background=False, **kwargs): + self.code_theme_name = code_theme + self.code_theme_no_background = code_theme_no_background + super().__init__(text, **kwargs) + + def render_code_block(self, block, width): + """Render a code block with optional background removal.""" + code = block.text.rstrip() + lexer = block.lexer_name if hasattr(block, "lexer_name") else "default" + + theme = get_code_theme(self.code_theme_name, self.code_theme_no_background) + syntax = Syntax( + code, + lexer, + theme=theme, + word_wrap=False, + padding=0, + background_color=None if self.code_theme_no_background else "default", + ) + return Padding(syntax, pad=(0, 0)) \ No newline at end of file diff --git a/aider/mdstream.py b/aider/mdstream.py index 774b247c2be..1e8b40aac2c 100755 --- a/aider/mdstream.py +++ b/aider/mdstream.py @@ -6,11 +6,13 @@ from rich import box from rich.console import Console from rich.live import Live -from rich.markdown import CodeBlock, Heading, Markdown +from rich.markdown import CodeBlock, Heading from rich.panel import Panel from rich.syntax import Syntax from rich.text import Text +from .md_renderer import CustomMarkdown + from aider.dump import dump # noqa: F401 _text_prefix = """ @@ -131,7 +133,7 @@ def _render_markdown_to_lines(self, text): # Render the markdown to a string buffer string_io = io.StringIO() console = Console(file=string_io, force_terminal=True) - markdown = NoInsetMarkdown(text, **self.mdargs) + markdown = CustomMarkdown(text, **self.mdargs) console.print(markdown) output = string_io.getvalue() diff --git a/aider/theme_utils.py b/aider/theme_utils.py new file mode 100644 index 00000000000..646c43ab502 --- /dev/null +++ b/aider/theme_utils.py @@ -0,0 +1,34 @@ +from pygments.style import Style as PygmentsStyle +from pygments.util import ClassNotFound +from pygments.styles import get_style_by_name + + +class NoBackgroundStyle(PygmentsStyle): + """A style wrapper that removes background colors from another style.""" + + def __init__(self, base_style): + # Get the base style's colors and settings + self.styles = base_style.styles.copy() + # Remove background colors from all token styles + for token, style_string in self.styles.items(): + if style_string: + # Split style into parts + parts = style_string.split() + # Filter out any bg:color settings + parts = [p for p in parts if not p.startswith('bg:')] + self.styles[token] = ' '.join(parts) + + +def get_code_theme(theme_name, no_background=False): + """Get a Pygments style, optionally without backgrounds.""" + try: + base_style = get_style_by_name(theme_name) + if no_background: + return NoBackgroundStyle(base_style) + return base_style + except ClassNotFound: + # Fallback to default style + base_style = get_style_by_name('default') + if no_background: + return NoBackgroundStyle(base_style) + return base_style \ No newline at end of file diff --git a/aider/utils.py b/aider/utils.py index 834ffa1953d..0f90bc51d93 100644 --- a/aider/utils.py +++ b/aider/utils.py @@ -95,8 +95,14 @@ def is_image_file(file_name): def safe_abs_path(res): "Gives an abs path, which safely returns a full (not 8.3) windows path" - res = Path(res).resolve() - return str(res) + try: + res = Path(res).resolve(strict=False) + return str(res) + except RuntimeError as e: + if "Symlink loop" in str(e): + # If we encounter a symlink loop, return the absolute path without resolving symlinks + return str(Path(res).absolute()) + raise def format_content(role, content): diff --git a/tests/basic/test_git_config_network.py b/tests/basic/test_git_config_network.py new file mode 100644 index 00000000000..6ff775dd6af --- /dev/null +++ b/tests/basic/test_git_config_network.py @@ -0,0 +1,46 @@ +import os +from pathlib import Path +from unittest import TestCase +from unittest.mock import patch, MagicMock + +import git + +from aider.io import InputOutput +from aider.main import setup_git +from aider.utils import GitTemporaryDirectory + + +class TestGitConfigNetworkDrive(TestCase): + def setUp(self): + self.tempdir = GitTemporaryDirectory() + self.old_cwd = os.getcwd() + os.chdir(self.tempdir.name) + + def tearDown(self): + os.chdir(self.old_cwd) + self.tempdir.cleanup() + + def test_setup_git_with_permission_error(self): + """Test that setup_git handles permission errors gracefully""" + io = InputOutput(pretty=False, yes=True) + + # Create a mock repo that raises PermissionError on config_writer + mock_repo = MagicMock(spec=git.Repo) + mock_config_writer = MagicMock() + mock_config_writer.__enter__ = MagicMock(side_effect=PermissionError("Permission denied")) + mock_repo.config_writer.return_value = mock_config_writer + + # Create a test working directory to return + test_dir = str(Path(self.tempdir.name).resolve()) + mock_repo.working_tree_dir = test_dir + + # Mock git.Repo to return our mock + with patch('git.Repo', return_value=mock_repo): + result = setup_git(test_dir, io) + + # Verify setup_git completes and returns working directory despite error + self.assertEqual(result, test_dir) + + # Verify warning was shown + warnings = [call[0][0] for call in io.tool_warning.call_args_list] + self.assertTrue(any("Could not write to git config" in warning for warning in warnings)) \ No newline at end of file diff --git a/tests/test_utils_symlinks.py b/tests/test_utils_symlinks.py new file mode 100644 index 00000000000..4a933995bf9 --- /dev/null +++ b/tests/test_utils_symlinks.py @@ -0,0 +1,60 @@ +import os +import unittest +from pathlib import Path +import tempfile + +from aider.utils import safe_abs_path + + +class TestUtilsSymlinks(unittest.TestCase): + def setUp(self): + # Create a temporary directory for our tests + self.test_dir = tempfile.mkdtemp() + self.orig_cwd = os.getcwd() + os.chdir(self.test_dir) + + def tearDown(self): + # Clean up + os.chdir(self.orig_cwd) + try: + import shutil + shutil.rmtree(self.test_dir) + except: + pass + + def test_safe_abs_path_normal(self): + """Test safe_abs_path with a normal path""" + test_file = Path(self.test_dir) / "test.txt" + test_file.touch() + result = safe_abs_path(test_file) + self.assertEqual(str(test_file.resolve()), result) + + def test_safe_abs_path_symlink_loop(self): + """Test safe_abs_path with a circular symlink""" + # Create a circular symlink + link1 = Path(self.test_dir) / "link1" + link2 = Path(self.test_dir) / "link2" + + # Create the first link pointing to the second + os.symlink("link2", str(link1)) + # Create the second link pointing back to the first + os.symlink("link1", str(link2)) + + # Test that safe_abs_path handles the symlink loop gracefully + result = safe_abs_path(link1) + self.assertTrue(result.endswith("link1")) + self.assertTrue(os.path.isabs(result)) + + def test_safe_abs_path_nonexistent(self): + """Test safe_abs_path with a non-existent path""" + nonexistent = Path(self.test_dir) / "nonexistent" + result = safe_abs_path(nonexistent) + self.assertTrue(os.path.isabs(result)) + self.assertTrue(result.endswith("nonexistent")) + + def test_safe_abs_path_relative(self): + """Test safe_abs_path with a relative path""" + rel_path = "relative/path" + result = safe_abs_path(rel_path) + self.assertTrue(os.path.isabs(result)) + self.assertTrue(result.endswith(rel_path.replace("/", os.path.sep))) \ No newline at end of file