From 02accb9790862f65a8e15fa1c4d187e9194d68e7 Mon Sep 17 00:00:00 2001 From: Finn Date: Thu, 1 May 2025 20:49:19 -0400 Subject: [PATCH] feat: Adds optional read-only response to confirm_ask --- aider/coders/base_coder.py | 20 ++++++++++++--- aider/io.py | 20 ++++++++++++--- tests/basic/test_coder.py | 7 ++++-- tests/basic/test_commands.py | 34 ++++++++++++++++++++++++++ tests/basic/test_io.py | 47 ++++++++++++++++++++++++++++++++++-- 5 files changed, 118 insertions(+), 10 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 675570c60..fefa44361 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -1664,12 +1664,26 @@ class Coder: added_fnames = [] group = ConfirmGroup(new_mentions) for rel_fname in sorted(new_mentions): - if self.io.confirm_ask( - "Add file to the chat?", subject=rel_fname, group=group, allow_never=True - ): + response = self.io.confirm_ask( + "Add file to the chat?", + subject=rel_fname, + group=group, + allow_never=True, + show_readonly=True, + return_string=True, + ) + + if response == "yes": self.add_rel_fname(rel_fname) added_fnames.append(rel_fname) + elif response == "readonly": + abs_fname = self.abs_root_path(rel_fname) + self.abs_read_only_fnames.add(abs_fname) + added_fnames.append(f"{rel_fname} (read-only)") + elif response == "never": + self.ignore_mentions.add(rel_fname) else: + # "no" response self.ignore_mentions.add(rel_fname) if added_fnames: diff --git a/aider/io.py b/aider/io.py index 90f581aab..9ab416af9 100644 --- a/aider/io.py +++ b/aider/io.py @@ -801,6 +801,8 @@ class InputOutput: explicit_yes_required=False, group=None, allow_never=False, + show_readonly=False, + return_string=False, ): self.num_user_asks += 1 @@ -810,7 +812,7 @@ class InputOutput: question_id = (question, subject) if question_id in self.never_prompts: - return False + return False if not return_string else "no" if group and not group.show_group: group = None @@ -819,6 +821,12 @@ class InputOutput: valid_responses = ["yes", "no", "skip", "all"] options = " (Y)es/(N)o" + + # Only add "Read-only" option when show_readonly is True + if show_readonly: + valid_responses.append("read") + options += "/(R)ead-only" + if group: if not explicit_yes_required: options += "/(A)ll" @@ -892,7 +900,13 @@ class InputOutput: self.never_prompts.add(question_id) hist = f"{question.strip()} {res}" self.append_chat_history(hist, linebreak=True, blockquote=True) - return False + return "never" if return_string else False + + # Handle read-only option when show_readonly is True + if show_readonly and res == "r": + hist = f"{question.strip()} {res}" + self.append_chat_history(hist, linebreak=True, blockquote=True) + return "readonly" if return_string else True if explicit_yes_required: is_yes = res == "y" @@ -911,7 +925,7 @@ class InputOutput: hist = f"{question.strip()} {res}" self.append_chat_history(hist, linebreak=True, blockquote=True) - return is_yes + return "yes" if return_string and is_yes else "no" if return_string else is_yes @restore_multiline def prompt_ask(self, question, default="", subject=None): diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index c58ade1b2..7fc8edc48 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -135,6 +135,9 @@ class TestCoder(unittest.TestCase): repo.git.add(str(fname2)) repo.git.commit("-m", "new") + # Make confirm_ask return "yes" by default + mock_io.confirm_ask.return_value = "yes" + # Initialize the Coder object with the mocked IO and mocked repo coder = Coder.create(self.GPT35, None, mock_io) @@ -238,8 +241,8 @@ class TestCoder(unittest.TestCase): # Mock get_file_mentions to return two file names coder.get_file_mentions = MagicMock(return_value=set(["file1.txt", "file2.txt"])) - # Mock confirm_ask to return False for the first call and True for the second - io.confirm_ask = MagicMock(side_effect=[False, True, True]) + # Mock confirm_ask to return "no" for the first call and "yes" for the second + io.confirm_ask = MagicMock(side_effect=["no", "yes", "yes"]) # First call to check_for_file_mentions coder.check_for_file_mentions("Please check file1.txt for the info") diff --git a/tests/basic/test_commands.py b/tests/basic/test_commands.py index 7ded4ca3a..813dd3e9c 100644 --- a/tests/basic/test_commands.py +++ b/tests/basic/test_commands.py @@ -1587,6 +1587,40 @@ class TestCommands(TestCase): # Check if all files were removed from abs_read_only_fnames self.assertEqual(len(coder.abs_read_only_fnames), 0) + def test_check_for_file_mentions_with_readonly_option(self): + with GitTemporaryDirectory(): + io = InputOutput(pretty=False, fancy_input=False, yes=False) + coder = Coder.create(self.GPT35, None, io) + + # Create a test file + test_file = Path("test_file.py") + test_file.write_text("print('Hello, world!')") + + # Mock get_file_mentions to return our test file and confirm_ask to return "readonly" + with ( + mock.patch.object(coder, "get_file_mentions", return_value={"test_file.py"}), + mock.patch.object(io, "confirm_ask", return_value="readonly"), + ): + # Call check_for_file_mentions with content mentioning the file + result = coder.check_for_file_mentions("Let's look at test_file.py") + + # Verify confirm_ask was called with show_readonly=True + io.confirm_ask.assert_called_with( + "Add file to the chat?", + subject="test_file.py", + group=mock.ANY, + allow_never=True, + show_readonly=True, + return_string=True, + ) + + # Verify the file was added as read-only + self.assertEqual(len(coder.abs_fnames), 0) + self.assertEqual(len(coder.abs_read_only_fnames), 1) + + # Verify the return message includes "(read-only)" + self.assertIn("(read-only)", result) + def test_cmd_read_only_with_tilde_path(self): with GitTemporaryDirectory(): io = InputOutput(pretty=False, fancy_input=False, yes=False) diff --git a/tests/basic/test_io.py b/tests/basic/test_io.py index e925ef66d..b92b0aac1 100644 --- a/tests/basic/test_io.py +++ b/tests/basic/test_io.py @@ -246,6 +246,51 @@ class TestInputOutput(unittest.TestCase): self.assertNotIn("(A)ll", mock_input.call_args[0][0]) mock_input.reset_mock() + @patch("builtins.input") + def test_confirm_ask_read_only_option(self, mock_input): + io = InputOutput(pretty=False, fancy_input=False) + + # Test case 1: User selects 'Read-only' option + mock_input.return_value = "r" + result = io.confirm_ask( + "Add file to the chat?", subject="test_file.py", show_readonly=True, return_string=True + ) + self.assertEqual(result, "readonly") + mock_input.assert_called_once() + mock_input.reset_mock() + + # Test case 2: show_readonly=False should not offer read-only option + mock_input.side_effect = ["r", "n"] # First 'r' will be invalid, then 'n' to exit the loop + result = io.confirm_ask( + "Add file to the chat?", subject="test_file.py", show_readonly=False, return_string=True + ) + self.assertEqual(result, "no") + self.assertEqual(mock_input.call_count, 2) + mock_input.reset_mock() + + # Test case 3: Return boolean with show_readonly=True and 'r' response + mock_input.side_effect = None # Clear any side_effect + mock_input.return_value = "r" + result = io.confirm_ask( + "Add file to the chat?", subject="test_file.py", show_readonly=True, return_string=False + ) + self.assertTrue(result) # Should return True for backward compatibility + mock_input.assert_called_once() + mock_input.reset_mock() + + # Test case 4: Verify prompt includes read-only option + mock_input.return_value = "y" + io.confirm_ask("Add file to the chat?", subject="test_file.py", show_readonly=True) + call_args = mock_input.call_args[0][0] + self.assertIn("(R)ead-only", call_args) + mock_input.reset_mock() + + # Test case 5: Verify prompt doesn't include read-only option when show_readonly=False + mock_input.return_value = "y" + io.confirm_ask("Add file to the chat?", subject="test_file.py", show_readonly=False) + call_args = mock_input.call_args[0][0] + self.assertNotIn("(R)ead-only", call_args) + @patch("builtins.input") def test_confirm_ask_yes_no(self, mock_input): io = InputOutput(pretty=False, fancy_input=False) @@ -451,8 +496,6 @@ class TestInputOutputMultilineMode(unittest.TestCase): """Test that tool_output correctly handles hex colors without # prefix""" from unittest.mock import patch - from rich.text import Text - # Create IO with hex color without # for tool_output_color io = InputOutput(tool_output_color="FFA500", pretty=True)