diff --git a/cecli/__init__.py b/cecli/__init__.py index 138c0fd8873..745ede1fdbb 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.96.6.dev" +__version__ = "0.96.7.dev" safe_version = __version__ try: diff --git a/cecli/args.py b/cecli/args.py index e0c03de4c54..c8edebd89a9 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -957,6 +957,7 @@ def get_parser(default_config_files, git_root): "-c", "--config", is_config_file=True, + env_var="CECLI_CONFIG_FILE", metavar="CONFIG_FILE", help=( "Specify the config file (default: search for .cecli.conf.yml in git root, cwd" diff --git a/cecli/commands/add.py b/cecli/commands/add.py index c50d7f5b90e..d0166e0db31 100644 --- a/cecli/commands/add.py +++ b/cecli/commands/add.py @@ -6,6 +6,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import ( format_command_result, + get_file_completions, parse_quoted_filenames, quote_filename, ) @@ -82,10 +83,6 @@ async def execute(cls, io, coder, args, **kwargs): for matched_file in sorted(all_matched_files): abs_file_path = coder.abs_root_path(matched_file) - if not abs_file_path.startswith(coder.root) and not is_image_file(matched_file): - io.tool_error(f"Can not add {abs_file_path}, which is not within {coder.root}") - continue - if ( coder.repo and coder.repo.git_ignored_file(matched_file) @@ -205,10 +202,22 @@ def expand_subdir(file_path: Path) -> List[Path]: @classmethod def get_completions(cls, io, coder, args) -> List[str]: """Get completion options for add command.""" - files = set(coder.get_all_relative_files()) - files = files - set(coder.get_inchat_relative_files()) - files = [quote_filename(fn) for fn in files] - return files + # Get both directory-based completions and filtered "all" completions + directory_completions = get_file_completions( + coder, + args=args, + completion_type="directory", + include_directories=True, + filter_in_chat=False, + ) + + all_completions = get_file_completions( + coder, args=args, completion_type="all", include_directories=False, filter_in_chat=True + ) + + # Return the joint set (union) of both completion types + joint_set = set(directory_completions) | set(all_completions) + return sorted(joint_set) @classmethod def get_help(cls) -> str: diff --git a/cecli/commands/read_only.py b/cecli/commands/read_only.py index 5a01fd7f45b..b5f5b4d98d9 100644 --- a/cecli/commands/read_only.py +++ b/cecli/commands/read_only.py @@ -7,6 +7,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import ( format_command_result, + get_file_completions, parse_quoted_filenames, quote_filename, ) @@ -207,50 +208,22 @@ def _add_read_only_directory( @classmethod def get_completions(cls, io, coder, args) -> List[str]: """Get completion options for read-only command.""" - from pathlib import Path - - root = Path(coder.root) if hasattr(coder, "root") else Path.cwd() - - # Handle the prefix - could be partial path like "src/ma" or just "ma" - if "/" in args: - # Has directory component - dir_part, file_part = args.rsplit("/", 1) - if dir_part == "": - search_dir = Path("/") - path_prefix = "/" - else: - # Use os.path.expanduser for ~ support if needed, but Path handles it mostly - search_dir = (root / dir_part).resolve() - path_prefix = dir_part + "/" - search_prefix = file_part.lower() - else: - search_dir = root - search_prefix = args.lower() - path_prefix = "" - - completions = [] - try: - if search_dir.exists() and search_dir.is_dir(): - for entry in search_dir.iterdir(): - name = entry.name - if search_prefix and not name.lower().startswith(search_prefix): - continue - - # Add trailing slash for directories - if entry.is_dir(): - completions.append(path_prefix + name + "/") - else: - completions.append(path_prefix + name) - except (PermissionError, OSError): - pass + # Get both directory-based completions and filtered "all" completions + directory_completions = get_file_completions( + coder, + args=args, + completion_type="directory", + include_directories=True, + filter_in_chat=False, + ) - # Also include files already in the chat that match - add_completions = coder.commands.get_completions("/add") - for c in add_completions: - if args.lower() in str(c).lower() and str(c) not in completions: - completions.append(str(c)) + all_completions = get_file_completions( + coder, args=args, completion_type="all", include_directories=False, filter_in_chat=True + ) - return sorted(completions) + # Return the joint set (union) of both completion types + joint_set = set(directory_completions) | set(all_completions) + return sorted(joint_set) @classmethod def get_help(cls) -> str: diff --git a/cecli/commands/read_only_stub.py b/cecli/commands/read_only_stub.py index 0249d5ba9d5..c75ff9b6628 100644 --- a/cecli/commands/read_only_stub.py +++ b/cecli/commands/read_only_stub.py @@ -7,6 +7,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import ( format_command_result, + get_file_completions, parse_quoted_filenames, quote_filename, ) @@ -207,49 +208,22 @@ def _add_read_only_directory( @classmethod def get_completions(cls, io, coder, args) -> List[str]: """Get completion options for read-only-stub command.""" - from pathlib import Path - - root = Path(coder.root) if hasattr(coder, "root") else Path.cwd() - - # Handle the prefix - could be partial path like "src/ma" or just "ma" - if "/" in args: - # Has directory component - dir_part, file_part = args.rsplit("/", 1) - if dir_part == "": - search_dir = Path("/") - path_prefix = "/" - else: - search_dir = (root / dir_part).resolve() - path_prefix = dir_part + "/" - search_prefix = file_part.lower() - else: - search_dir = root - search_prefix = args.lower() - path_prefix = "" - - completions = [] - try: - if search_dir.exists() and search_dir.is_dir(): - for entry in search_dir.iterdir(): - name = entry.name - if search_prefix and not name.lower().startswith(search_prefix): - continue - - # Add trailing slash for directories - if entry.is_dir(): - completions.append(path_prefix + name + "/") - else: - completions.append(path_prefix + name) - except (PermissionError, OSError): - pass + # Get both directory-based completions and filtered "all" completions + directory_completions = get_file_completions( + coder, + args=args, + completion_type="directory", + include_directories=True, + filter_in_chat=False, + ) - # Also include files already in the chat that match - add_completions = coder.commands.get_completions("/add") - for c in add_completions: - if args.lower() in str(c).lower() and str(c) not in completions: - completions.append(str(c)) + all_completions = get_file_completions( + coder, args=args, completion_type="all", include_directories=False, filter_in_chat=True + ) - return sorted(completions) + # Return the joint set (union) of both completion types + joint_set = set(directory_completions) | set(all_completions) + return sorted(joint_set) @classmethod def get_help(cls) -> str: diff --git a/cecli/commands/utils/helpers.py b/cecli/commands/utils/helpers.py index 665bf2492db..cf0b07f9b88 100644 --- a/cecli/commands/utils/helpers.py +++ b/cecli/commands/utils/helpers.py @@ -46,24 +46,116 @@ def glob_filtered_to_repo(pattern: str, root: str, repo) -> List[Path]: else: try: raw_matched_files = list(Path(root).glob(pattern)) - except (IndexError, AttributeError): + except (IndexError, AttributeError, ValueError): # Handle patterns like "**/*.py" that might fail on empty dirs raw_matched_files = [] - # Filter out directories and ignored files + # Expand directories and filter matched_files = [] for f in raw_matched_files: - if not f.is_file(): - continue - if repo and repo.ignored_file(f): - continue - matched_files.append(f) + matched_files.extend(expand_subdir(f)) + + # Filter to repository files + matched_files = [fn.relative_to(root) for fn in matched_files if fn.is_relative_to(root)] + + # if repo, filter against it + if repo: + git_files = repo.get_tracked_files() + matched_files = [fn for fn in matched_files if str(fn) in git_files] return matched_files except Exception as e: raise CommandError(f"Error processing pattern '{pattern}': {e}") +def get_file_completions( + coder, + args: str = "", + completion_type: str = "all", + include_directories: bool = False, + filter_in_chat: bool = False, +) -> List[str]: + """ + Get file completions for command line arguments. + + This function provides unified file completion logic that can be used by + multiple commands (add, read-only, read-only-stub, etc.). + + Args: + coder: Coder instance + args: Command arguments to complete + completion_type: Type of completion to perform: + - "all": Return all available files (default) + - "glob": Treat args as glob pattern and expand + - "directory": Perform directory-based prefix matching + include_directories: Whether to include directories in results + filter_in_chat: Whether to filter out files already in chat + + Returns: + List of completion strings (quoted if needed) + """ + from pathlib import Path + + root = Path(coder.root) if hasattr(coder, "root") else Path.cwd() + + if completion_type == "glob": + # Handle glob pattern completion + if not args.strip(): + return [] + + try: + matched_files = glob_filtered_to_repo(args, str(root), coder.repo) + completions = [str(fn) for fn in matched_files] + except CommandError: + completions = [] + + elif completion_type == "directory": + # Handle directory-based prefix matching (like read-only commands) + if "/" in args: + # Has directory component + dir_part, file_part = args.rsplit("/", 1) + if dir_part == "": + search_dir = Path("/") + path_prefix = "/" + else: + search_dir = (root / dir_part).resolve() + path_prefix = dir_part + "/" + search_prefix = file_part.lower() + else: + search_dir = root + search_prefix = args.lower() + path_prefix = "" + + completions = [] + try: + if search_dir.exists() and search_dir.is_dir(): + for entry in search_dir.iterdir(): + name = entry.name + if search_prefix and not name.lower().startswith(search_prefix): + continue + + # Add trailing slash for directories if requested + if entry.is_dir() and include_directories: + completions.append(path_prefix + name + "/") + elif entry.is_file(): + completions.append(path_prefix + name) + except (PermissionError, OSError): + pass + + else: # "all" completion type + # Get all available files + if filter_in_chat: + files = set(coder.get_all_relative_files()) + files = files - set(coder.get_inchat_relative_files()) + completions = list(files) + else: + completions = coder.get_all_relative_files() + + # Quote filenames with spaces + completions = [quote_filename(fn) for fn in completions] + return sorted(completions) + + def validate_file_access(io, coder, file_path: str, require_in_chat: bool = False) -> bool: """ Validate file access permissions and state. @@ -130,13 +222,16 @@ def get_available_files(coder, in_chat: bool = False) -> List[str]: return coder.get_all_relative_files() -def expand_subdir(file_path): +def expand_subdir(file_path: Path) -> List[Path]: """Expand a directory path to all files within it.""" if file_path.is_file(): - yield file_path - return + return [file_path] if file_path.is_dir(): + files = [] for file in file_path.rglob("*"): if file.is_file(): - yield file + files.append(file) + return files + + return [] diff --git a/cecli/main.py b/cecli/main.py index b55c816c270..c433b4a3e5b 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -181,7 +181,9 @@ async def setup_git(git_root, io): ) return elif cwd and await io.confirm_ask( - "No git repo found, create one to track cecli's changes (recommended)?", acknowledge=True + "No git repo found, create one to track cecli's changes (recommended)?", + acknowledge=True, + explicit_yes_required=True, ): git_root = str(cwd.resolve()) repo = await make_new_repo(git_root, io) diff --git a/cecli/tools/command.py b/cecli/tools/command.py index e8573087b1d..4122223d60c 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -34,7 +34,7 @@ class Tool(BaseTool): } @classmethod - async def execute(cls, coder, command_string, background=False, stop_background=None): + async def execute(cls, coder, command_string, background=False, stop_background=None, **kwargs): """ Execute a shell command, optionally in background. """ diff --git a/cecli/tools/command_interactive.py b/cecli/tools/command_interactive.py index 373c6d70236..49de57f62de 100644 --- a/cecli/tools/command_interactive.py +++ b/cecli/tools/command_interactive.py @@ -26,7 +26,7 @@ class Tool(BaseTool): } @classmethod - async def execute(cls, coder, command_string): + async def execute(cls, coder, command_string, **kwargs): """ Execute an interactive shell command using run_cmd (which uses pexpect/PTY). """ diff --git a/cecli/tools/context_manager.py b/cecli/tools/context_manager.py index 652185c8780..5eb85beff82 100644 --- a/cecli/tools/context_manager.py +++ b/cecli/tools/context_manager.py @@ -51,7 +51,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, remove=None, editable=None, view=None, create=None): + def execute(cls, coder, remove=None, editable=None, view=None, create=None, **kwargs): """Perform batch operations on the coder's context. Parameters diff --git a/cecli/tools/delete_block.py b/cecli/tools/delete_block.py index 1839d22c07b..54cf23ee696 100644 --- a/cecli/tools/delete_block.py +++ b/cecli/tools/delete_block.py @@ -47,6 +47,7 @@ def execute( occurrence=1, change_id=None, dry_run=False, + **kwargs, ): """ Delete a block of text between start_pattern and end_pattern (inclusive). diff --git a/cecli/tools/delete_line.py b/cecli/tools/delete_line.py index 2a76083e300..ef378ce6f4b 100644 --- a/cecli/tools/delete_line.py +++ b/cecli/tools/delete_line.py @@ -29,7 +29,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, file_path, line_number, change_id=None, dry_run=False): + def execute(cls, coder, file_path, line_number, change_id=None, dry_run=False, **kwargs): """ Delete a specific line number (1-based). diff --git a/cecli/tools/delete_lines.py b/cecli/tools/delete_lines.py index d154052983b..ca7c52cbffa 100644 --- a/cecli/tools/delete_lines.py +++ b/cecli/tools/delete_lines.py @@ -30,7 +30,9 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, file_path, start_line, end_line, change_id=None, dry_run=False): + def execute( + cls, coder, file_path, start_line, end_line, change_id=None, dry_run=False, **kwargs + ): """ Delete a range of lines (1-based, inclusive). diff --git a/cecli/tools/extract_lines.py b/cecli/tools/extract_lines.py index 93e917f0eae..0fa7cedde33 100644 --- a/cecli/tools/extract_lines.py +++ b/cecli/tools/extract_lines.py @@ -46,6 +46,7 @@ def execute( near_context=None, occurrence=1, dry_run=False, + **kwargs, ): """ Extract a range of lines from a source file and move them to a target file. diff --git a/cecli/tools/finished.py b/cecli/tools/finished.py index e05b6c90006..c9d55ccb09e 100644 --- a/cecli/tools/finished.py +++ b/cecli/tools/finished.py @@ -19,7 +19,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder): + def execute(cls, coder, **kwargs): """ Mark that the current generation task needs no further effort. diff --git a/cecli/tools/git_branch.py b/cecli/tools/git_branch.py index 8cd27bea2cc..21e442dfe1c 100644 --- a/cecli/tools/git_branch.py +++ b/cecli/tools/git_branch.py @@ -78,6 +78,7 @@ def execute( sort=None, format=None, show_current=False, + **kwargs, ): """ List branches in the repository with various filtering and formatting options. diff --git a/cecli/tools/git_diff.py b/cecli/tools/git_diff.py index b6a919fe09a..99cb5d8ad6b 100644 --- a/cecli/tools/git_diff.py +++ b/cecli/tools/git_diff.py @@ -27,7 +27,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, branch=None): + def execute(cls, coder, branch=None, **kwargs): """ Show the diff between the current working directory and a git branch or commit. """ diff --git a/cecli/tools/git_log.py b/cecli/tools/git_log.py index 173753dc39a..606308c1388 100644 --- a/cecli/tools/git_log.py +++ b/cecli/tools/git_log.py @@ -23,7 +23,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, limit=10): + def execute(cls, coder, limit=10, **kwargs): """ Show the git log. """ diff --git a/cecli/tools/git_remote.py b/cecli/tools/git_remote.py index 77fca36ba3b..4138532b2ad 100644 --- a/cecli/tools/git_remote.py +++ b/cecli/tools/git_remote.py @@ -18,7 +18,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder): + def execute(cls, coder, **kwargs): """ List remote repositories. """ diff --git a/cecli/tools/git_show.py b/cecli/tools/git_show.py index 4f31e5f39df..c4dee758e8d 100644 --- a/cecli/tools/git_show.py +++ b/cecli/tools/git_show.py @@ -23,7 +23,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, object="HEAD"): + def execute(cls, coder, object="HEAD", **kwargs): """ Show various types of objects (blobs, trees, tags, and commits). """ diff --git a/cecli/tools/git_status.py b/cecli/tools/git_status.py index b16ec707f2e..76531bf7dd6 100644 --- a/cecli/tools/git_status.py +++ b/cecli/tools/git_status.py @@ -18,7 +18,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder): + def execute(cls, coder, **kwargs): """ Show the working tree status. """ diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py index 6fe14b1bcfd..a737120e62b 100644 --- a/cecli/tools/grep.py +++ b/cecli/tools/grep.py @@ -76,6 +76,7 @@ def execute( case_insensitive=False, context_before=5, context_after=5, + **kwargs, ): """ Search for lines matching a pattern in files within the project repository. diff --git a/cecli/tools/indent_lines.py b/cecli/tools/indent_lines.py index 820cf6cc8bc..d7ccb9f4be5 100644 --- a/cecli/tools/indent_lines.py +++ b/cecli/tools/indent_lines.py @@ -49,6 +49,7 @@ def execute( occurrence=1, change_id=None, dry_run=False, + **kwargs, ): """ Indent or unindent a block of lines in a file using utility functions. diff --git a/cecli/tools/insert_block.py b/cecli/tools/insert_block.py index a6ec81dacb0..b2c14ec1fe3 100644 --- a/cecli/tools/insert_block.py +++ b/cecli/tools/insert_block.py @@ -58,6 +58,7 @@ def execute( position=None, auto_indent=True, use_regex=False, + **kwargs, ): """ Insert a block of text after or before a specified pattern using utility functions. diff --git a/cecli/tools/list_changes.py b/cecli/tools/list_changes.py index c2f049302af..3e8e4331684 100644 --- a/cecli/tools/list_changes.py +++ b/cecli/tools/list_changes.py @@ -22,7 +22,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, file_path=None, limit=10): + def execute(cls, coder, file_path=None, limit=10, **kwargs): """ List recent changes made to files. diff --git a/cecli/tools/load_skill.py b/cecli/tools/load_skill.py index 85c050035e9..e8a6d4f5e4e 100644 --- a/cecli/tools/load_skill.py +++ b/cecli/tools/load_skill.py @@ -25,7 +25,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, skill_name): + def execute(cls, coder, skill_name, **kwargs): """ Load a skill by name (agent mode only). """ diff --git a/cecli/tools/ls.py b/cecli/tools/ls.py index 43a649592a3..200816db435 100644 --- a/cecli/tools/ls.py +++ b/cecli/tools/ls.py @@ -24,7 +24,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, dir_path=None, directory=None): + def execute(cls, coder, dir_path=None, directory=None, **kwargs): # Handle both positional and keyword arguments for backward compatibility if dir_path is None and directory is not None: dir_path = directory diff --git a/cecli/tools/remove_skill.py b/cecli/tools/remove_skill.py index eb2d036a6d1..feb2ae6e9de 100644 --- a/cecli/tools/remove_skill.py +++ b/cecli/tools/remove_skill.py @@ -25,7 +25,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, skill_name): + def execute(cls, coder, skill_name, **kwargs): """ Remove a skill by name (agent mode only). """ diff --git a/cecli/tools/replace_text.py b/cecli/tools/replace_text.py index b04252508df..96486dd0e9b 100644 --- a/cecli/tools/replace_text.py +++ b/cecli/tools/replace_text.py @@ -18,30 +18,36 @@ class Tool(BaseTool): "type": "function", "function": { "name": "ReplaceText", - "description": "Replace text in a file. Can handle an array of up to 10 edits.", + "description": ( + "Replace text in one or more files. Can handle an array of up to 10 edits across" + " multiple files. Each edit must include its own file_path." + ), "parameters": { "type": "object", "properties": { - "file_path": {"type": "string"}, "edits": { "type": "array", "items": { "type": "object", "properties": { + "file_path": { + "type": "string", + "description": "Required file path for this specific edit.", + }, "find_text": {"type": "string"}, "replace_text": {"type": "string"}, "line_number": {"type": "integer"}, "occurrence": {"type": "integer", "default": 1}, "replace_all": {"type": "boolean", "default": False}, }, - "required": ["find_text", "replace_text"], + "required": ["file_path", "find_text", "replace_text"], }, "description": "Array of edits to apply.", }, "change_id": {"type": "string"}, "dry_run": {"type": "boolean", "default": False}, }, - "required": ["file_path", "edits"], + "required": ["edits"], }, }, } @@ -50,94 +56,175 @@ class Tool(BaseTool): def execute( cls, coder, - file_path, - edits, + edits=None, change_id=None, dry_run=False, + **kwargs, ): """ - Replace text in a file. Can handle single edit or array of edits. + Replace text in one or more files. Can handle single edit or array of edits across multiple files. + Each edit object must include its own file_path. """ tool_name = "ReplaceText" try: - # 1. Validate file and get content - abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) - - # 2. Validate edits parameter + # 1. Validate edits parameter if not isinstance(edits, list): raise ToolError("edits parameter must be an array") if len(edits) == 0: raise ToolError("edits array cannot be empty") - # 3. Process all edits - current_content = original_content - all_metadata = [] - successful_edits = 0 - failed_edits = [] - + # 2. Group edits by file_path + edits_by_file = {} for i, edit in enumerate(edits): - try: - edit_find_text = edit.get("find_text") - edit_replace_text = edit.get("replace_text") - edit_line_number = edit.get("line_number") - edit_occurrence = edit.get("occurrence", 1) - edit_replace_all = edit.get("replace_all", False) + edit_file_path = edit.get("file_path") + if edit_file_path is None: + raise ToolError(f"Edit {i + 1} missing required file_path parameter") + + if edit_file_path not in edits_by_file: + edits_by_file[edit_file_path] = [] + edits_by_file[edit_file_path].append((i, edit)) - if edit_find_text is None or edit_replace_text is None: - raise ToolError(f"Edit {i + 1} missing find_text or replace_text") + # 3. Process each file + all_results = [] + all_failed_edits = [] + total_successful_edits = 0 + files_processed = 0 - # Process this edit - new_content, metadata = cls._process_single_edit( + for file_path_key, file_edits in edits_by_file.items(): + try: + # Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit( + coder, file_path_key + ) + + # Process all edits for this file + current_content = original_content + file_metadata = [] + file_successful_edits = 0 + file_failed_edits = [] + + for edit_index, edit in file_edits: + try: + edit_find_text = edit.get("find_text") + edit_replace_text = edit.get("replace_text") + edit_line_number = edit.get("line_number") + edit_occurrence = edit.get("occurrence", 1) + edit_replace_all = edit.get("replace_all", False) + + if edit_find_text is None or edit_replace_text is None: + raise ToolError( + f"Edit {edit_index + 1} missing find_text or replace_text" + ) + + # Process this edit + new_content, metadata = cls._process_single_edit( + coder, + file_path_key, + edit_find_text, + edit_replace_text, + edit_line_number, + edit_occurrence, + current_content, + rel_path, + abs_path, + edit_replace_all, + ) + + if metadata is not None: # Edit made a change + current_content = new_content + file_metadata.append(metadata) + file_successful_edits += 1 + else: + # Edit didn't change anything (identical replacement) + file_failed_edits.append( + f"Edit {edit_index + 1}: No change (replacement identical to" + " original)" + ) + + except ToolError as e: + # Record failed edit but continue with others + file_failed_edits.append(f"Edit {edit_index + 1}: {str(e)}") + continue + + # Check if any edits succeeded for this file + if file_successful_edits == 0: + all_failed_edits.extend(file_failed_edits) + continue + + new_content = current_content + + # Check if any changes were made for this file + if original_content == new_content: + all_failed_edits.extend(file_failed_edits) + continue + + # Handle dry run + if dry_run: + all_results.append( + { + "file_path": file_path_key, + "successful_edits": file_successful_edits, + "failed_edits": file_failed_edits, + "dry_run": True, + } + ) + total_successful_edits += file_successful_edits + all_failed_edits.extend(file_failed_edits) + files_processed += 1 + continue + + # Apply Change (Not dry run) + metadata = { + "edits": file_metadata, + "total_edits": file_successful_edits, + "failed_edits": file_failed_edits if file_failed_edits else None, + } + + final_change_id = apply_change( coder, - file_path, - edit_find_text, - edit_replace_text, - edit_line_number, - edit_occurrence, - current_content, - rel_path, abs_path, - edit_replace_all, + rel_path, + original_content, + new_content, + "replacetext", + metadata, + change_id, ) - if metadata is not None: # Edit made a change - current_content = new_content - all_metadata.append(metadata) - successful_edits += 1 - else: - # Edit didn't change anything (identical replacement) - failed_edits.append( - f"Edit {i + 1}: No change (replacement identical to original)" - ) + coder.files_edited_by_tools.add(rel_path) + + all_results.append( + { + "file_path": file_path_key, + "successful_edits": file_successful_edits, + "failed_edits": file_failed_edits, + "change_id": final_change_id, + } + ) + total_successful_edits += file_successful_edits + all_failed_edits.extend(file_failed_edits) + files_processed += 1 except ToolError as e: - # Record failed edit but continue with others - failed_edits.append(f"Edit {i + 1}: {str(e)}") + # Record all edits for this file as failed + for edit_index, _ in file_edits: + all_failed_edits.append(f"Edit {edit_index + 1}: {str(e)}") continue - # 4. Check if any edits succeeded - if successful_edits == 0: - error_msg = "No edits were successfully applied:\n" + "\n".join(failed_edits) + # 4. Check if any edits succeeded overall + if total_successful_edits == 0: + error_msg = "No edits were successfully applied:\n" + "\n".join(all_failed_edits) raise ToolError(error_msg) - new_content = current_content - - # 5. Check if any changes were made overall - if original_content == new_content: - coder.io.tool_warning( - "No changes made: all replacements were identical to original" - ) - return "Warning: No changes made (all replacements identical to original)" - - # 6. Handle dry run + # 5. Handle dry run overall if dry_run: dry_run_message = ( - f"Dry run: Would apply {len(edits)} edits in {file_path} " - f"({successful_edits} would succeed, {len(failed_edits)} would fail)." + f"Dry run: Would apply {len(edits)} edits across {len(edits_by_file)} files " + f"({total_successful_edits} would succeed, {len(all_failed_edits)} would fail)." ) - if failed_edits: - dry_run_message += "\nFailed edits:\n" + "\n".join(failed_edits) + if all_failed_edits: + dry_run_message += "\nFailed edits:\n" + "\n".join(all_failed_edits) return format_tool_result( coder, @@ -147,36 +234,30 @@ def execute( dry_run_message=dry_run_message, ) - # 7. Apply Change (Not dry run) - metadata = { - "edits": all_metadata, - "total_edits": successful_edits, - "failed_edits": failed_edits if failed_edits else None, - } - - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "replacetext", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - - # 8. Format and return result - success_message = f"Applied {successful_edits} edits in {file_path}" - if failed_edits: - success_message += f" ({len(failed_edits)} failed)" + # 6. Format and return result + if files_processed == 1: + # Single file case for backward compatibility + result = all_results[0] + success_message = ( + f"Applied {result['successful_edits']} edits in {result['file_path']}" + ) + if result["failed_edits"]: + success_message += f" ({len(result['failed_edits'])} failed)" + change_id_to_return = result.get("change_id") + else: + # Multiple files case + success_message = ( + f"Applied {total_successful_edits} edits across {files_processed} files" + ) + if all_failed_edits: + success_message += f" ({len(all_failed_edits)} failed)" + change_id_to_return = None # Multiple change IDs, can't return single one return format_tool_result( coder, tool_name, success_message, - change_id=final_change_id, + change_id=change_id_to_return, ) except ToolError as e: @@ -193,31 +274,41 @@ def format_output(cls, coder, mcp_server, tool_response): tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) - coder.io.tool_output("") - coder.io.tool_output(f"{color_start}file_path:{color_end}") - coder.io.tool_output(params["file_path"]) - coder.io.tool_output("") - - num_edits = len(params["edits"]) + # Group edits by file_path for display + edits_by_file = {} for i, edit in enumerate(params["edits"]): - # Show diff for this edit - diff = difflib.unified_diff( - edit.get("find_text", "").splitlines(), - edit.get("replace_text", "").splitlines(), - lineterm="", - n=float("inf"), - ) - diff_lines = list(diff)[2:] # Skip header lines - if diff_lines: - if num_edits > 1: - coder.io.tool_output(f"{color_start}diff_{i + 1}:{color_end}") - else: - coder.io.tool_output(f"{color_start}diff:{color_end}") - - coder.io.tool_output("\n".join([line for line in diff_lines])) + edit_file_path = edit.get("file_path") + if edit_file_path not in edits_by_file: + edits_by_file[edit_file_path] = [] + edits_by_file[edit_file_path].append((i, edit)) + + # Display edits grouped by file + for file_path_key, file_edits in edits_by_file.items(): + if file_path_key: + coder.io.tool_output("") + coder.io.tool_output(f"{color_start}file_path:{color_end}") + coder.io.tool_output(file_path_key) coder.io.tool_output("") + for edit_index, edit in file_edits: + # Show diff for this edit + diff = difflib.unified_diff( + edit.get("find_text", "").splitlines(), + edit.get("replace_text", "").splitlines(), + lineterm="", + n=float("inf"), + ) + diff_lines = list(diff)[2:] # Skip header lines + if diff_lines: + if len(params["edits"]) > 1: + coder.io.tool_output(f"{color_start}diff_{edit_index + 1}:{color_end}") + else: + coder.io.tool_output(f"{color_start}diff:{color_end}") + + coder.io.tool_output("\n".join([line for line in diff_lines])) + coder.io.tool_output("") + tool_footer(coder=coder, tool_response=tool_response) @classmethod diff --git a/cecli/tools/show_numbered_context.py b/cecli/tools/show_numbered_context.py index 376cf564840..3663552119a 100644 --- a/cecli/tools/show_numbered_context.py +++ b/cecli/tools/show_numbered_context.py @@ -16,118 +16,180 @@ class Tool(BaseTool): "function": { "name": "ShowNumberedContext", "description": ( - "Show numbered lines of context around a pattern or line number. Mutually Exclusive" - " Parameters: pattern, line_number" + "Show numbered lines of context around patterns or line numbers in multiple files." + " Accepts an array of show objects, each with file_path, pattern/line_number, and" + " context_lines." ), "parameters": { "type": "object", "properties": { - "file_path": {"type": "string"}, - "pattern": {"type": "string"}, - "line_number": {"type": "integer"}, - "context_lines": {"type": "integer", "default": 3}, + "show": { + "type": "array", + "items": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "File path to search in.", + }, + "pattern": { + "type": "string", + "description": ( + "Pattern to search for (mutually exclusive with" + " line_number)." + ), + }, + "line_number": { + "type": "integer", + "description": ( + "Line number to show context around (mutually exclusive" + " with pattern)." + ), + }, + "context_lines": { + "type": "integer", + "default": 3, + "description": ( + "Number of context lines to show around the target." + ), + }, + }, + "required": ["file_path"], + }, + "description": "Array of show operations to perform.", + }, }, - "required": ["file_path"], + "required": ["show"], }, }, } @classmethod - def execute(cls, coder, file_path, pattern=None, line_number=None, context_lines=3): + def execute(cls, coder, show, **kwargs): """ - Displays numbered lines from file_path centered around a target location - (pattern or line_number), without adding the file to context. + Displays numbered lines from multiple files centered around target locations + (patterns or line_numbers), without adding files to context. + Accepts an array of show operations to perform. Uses utility functions for path resolution and error handling. """ tool_name = "ShowNumberedContext" try: - # 1. Validate arguments - pattern_provided = is_provided(pattern) - line_number_provided = is_provided(line_number, treat_zero_as_missing=True) - - if sum([pattern_provided, line_number_provided]) != 1: - raise ToolError("Provide exactly one of 'pattern' or 'line_number'.") - - if not pattern_provided: - pattern = None - if not line_number_provided: - line_number = None - - # 2. Resolve path - abs_path, rel_path = resolve_paths(coder, file_path) - if not os.path.exists(abs_path): - # Check existence after resolving, as resolve_paths doesn't guarantee existence - raise ToolError(f"File not found: {file_path}") - - # 3. Read file content - content = coder.io.read_text(abs_path) - if content is None: - raise ToolError(f"Could not read file: {file_path}") - lines = content.splitlines() - num_lines = len(lines) - - # 4. Determine center line index - center_line_idx = -1 - found_by = "" - - if line_number is not None: - try: - line_number_int = int(line_number) - if 1 <= line_number_int <= num_lines: - center_line_idx = line_number_int - 1 # Convert to 0-based index - found_by = f"line {line_number_int}" + # 1. Validate show parameter + if not isinstance(show, list): + raise ToolError("show parameter must be an array") + + if len(show) == 0: + raise ToolError("show array cannot be empty") + + all_outputs = [] + + for show_index, show_op in enumerate(show): + # Extract parameters for this show operation + file_path = show_op.get("file_path") + pattern = show_op.get("pattern") + line_number = show_op.get("line_number") + context_lines = show_op.get("context_lines", 3) + + if file_path is None: + raise ToolError( + f"Show operation {show_index + 1} missing required file_path parameter" + ) + + # Validate arguments for this operation + pattern_provided = is_provided(pattern) + line_number_provided = is_provided(line_number, treat_zero_as_missing=True) + + if sum([pattern_provided, line_number_provided]) != 1: + raise ToolError( + f"Show operation {show_index + 1}: Provide exactly one of 'pattern' or" + " 'line_number'." + ) + + if not pattern_provided: + pattern = None + if not line_number_provided: + line_number = None + + # 2. Resolve path + abs_path, rel_path = resolve_paths(coder, file_path) + if not os.path.exists(abs_path): + # Check existence after resolving, as resolve_paths doesn't guarantee existence + raise ToolError(f"File not found: {file_path}") + + # 3. Read file content + content = coder.io.read_text(abs_path) + if content is None: + raise ToolError(f"Could not read file: {file_path}") + lines = content.splitlines() + num_lines = len(lines) + + # 4. Determine center line index + center_line_idx = -1 + found_by = "" + + if line_number is not None: + try: + line_number_int = int(line_number) + if 1 <= line_number_int <= num_lines: + center_line_idx = line_number_int - 1 # Convert to 0-based index + found_by = f"line {line_number_int}" + else: + raise ToolError( + f"Line number {line_number_int} is out of range (1-{num_lines}) for" + f" {file_path}." + ) + except ValueError: + raise ToolError(f"Invalid line number '{line_number}'. Must be an integer.") + + elif pattern is not None: + # TODO: Update this section for multiline pattern support later + first_match_line_idx = -1 + for i, line in enumerate(lines): + if pattern in line: + first_match_line_idx = i + break + + if first_match_line_idx != -1: + center_line_idx = first_match_line_idx + found_by = f"pattern '{pattern}' on line {center_line_idx + 1}" else: - raise ToolError( - f"Line number {line_number_int} is out of range (1-{num_lines}) for" - f" {file_path}." - ) + raise ToolError(f"Pattern '{pattern}' not found in {file_path}.") + + if center_line_idx == -1: + # Should not happen if logic above is correct, but as a safeguard + raise ToolError("Internal error: Could not determine center line.") + + # 5. Calculate context window + try: + context_lines_int = int(context_lines) + if context_lines_int < 0: + raise ValueError("Context lines must be non-negative") except ValueError: - raise ToolError(f"Invalid line number '{line_number}'. Must be an integer.") - - elif pattern is not None: - # TODO: Update this section for multiline pattern support later - first_match_line_idx = -1 - for i, line in enumerate(lines): - if pattern in line: - first_match_line_idx = i - break - - if first_match_line_idx != -1: - center_line_idx = first_match_line_idx - found_by = f"pattern '{pattern}' on line {center_line_idx + 1}" - else: - raise ToolError(f"Pattern '{pattern}' not found in {file_path}.") - - if center_line_idx == -1: - # Should not happen if logic above is correct, but as a safeguard - raise ToolError("Internal error: Could not determine center line.") - - # 5. Calculate context window - try: - context_lines_int = int(context_lines) - if context_lines_int < 0: - raise ValueError("Context lines must be non-negative") - except ValueError: - coder.io.tool_warning( - f"Invalid context_lines value '{context_lines}', using default 3." - ) - context_lines_int = 3 - - start_line_idx = max(0, center_line_idx - context_lines_int) - end_line_idx = min(num_lines - 1, center_line_idx + context_lines_int) - - # 6. Format output - # Use rel_path for user-facing messages - output_lines = [f"Displaying context around {found_by} in {rel_path}:"] - max_line_num_width = len(str(end_line_idx + 1)) # Width for padding - - for i in range(start_line_idx, end_line_idx + 1): - line_num_str = str(i + 1).rjust(max_line_num_width) - output_lines.append(f"{line_num_str} | {lines[i]}") + coder.io.tool_warning( + f"Invalid context_lines value '{context_lines}', using default 3." + ) + context_lines_int = 3 + + start_line_idx = max(0, center_line_idx - context_lines_int) + end_line_idx = min(num_lines - 1, center_line_idx + context_lines_int) + + # 6. Format output for this operation + # Use rel_path for user-facing messages + output_lines = [f"Displaying context around {found_by} in {rel_path}:"] + max_line_num_width = len(str(end_line_idx + 1)) # Width for padding + + for i in range(start_line_idx, end_line_idx + 1): + line_num_str = str(i + 1).rjust(max_line_num_width) + output_lines.append(f"{line_num_str} | {lines[i]}") + + # Add separator between multiple show operations + if show_index > 0: + all_outputs.append("") + all_outputs.extend(output_lines) # Log success and return the formatted context directly - coder.io.tool_output(f"Successfully retrieved context for {rel_path}") - return "\n".join(output_lines) + coder.io.tool_output(f"Successfully retrieved context for {len(show)} file(s)") + return "\n".join(all_outputs) except ToolError as e: # Handle expected errors raised by utility functions or validation diff --git a/cecli/tools/thinking.py b/cecli/tools/thinking.py index b434ff59de3..151032f026e 100644 --- a/cecli/tools/thinking.py +++ b/cecli/tools/thinking.py @@ -29,7 +29,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, content): + def execute(cls, coder, content, **kwargs): """ A place to allow the model to record freeform text as it iterates over tools to ideally help it guide itself to a proper solution diff --git a/cecli/tools/undo_change.py b/cecli/tools/undo_change.py index badc2623881..70cce423eb0 100644 --- a/cecli/tools/undo_change.py +++ b/cecli/tools/undo_change.py @@ -21,7 +21,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, change_id=None, file_path=None): + def execute(cls, coder, change_id=None, file_path=None, **kwargs): """ Undo a specific change by ID, or the last change to a file. diff --git a/cecli/tools/update_todo_list.py b/cecli/tools/update_todo_list.py index 1415e644c4a..9c91a4dc529 100644 --- a/cecli/tools/update_todo_list.py +++ b/cecli/tools/update_todo_list.py @@ -42,7 +42,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, content, append=False, change_id=None, dry_run=False): + def execute(cls, coder, content, append=False, change_id=None, dry_run=False, **kwargs): """ Update the todo list file (.cecli/todo.txt) with new content. Can either replace the entire content or append to it. diff --git a/cecli/tools/view_files_matching.py b/cecli/tools/view_files_matching.py index f8246fec3bc..b148ab584ec 100644 --- a/cecli/tools/view_files_matching.py +++ b/cecli/tools/view_files_matching.py @@ -37,7 +37,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, pattern, file_pattern=None, regex=False): + def execute(cls, coder, pattern, file_pattern=None, regex=False, **kwargs): """ Search for pattern (literal string or regex) in files and return matching files as text. diff --git a/cecli/tools/view_files_with_symbol.py b/cecli/tools/view_files_with_symbol.py index 5faf08ffff2..f32175bbc3f 100644 --- a/cecli/tools/view_files_with_symbol.py +++ b/cecli/tools/view_files_with_symbol.py @@ -22,7 +22,7 @@ class Tool(BaseTool): } @classmethod - def execute(cls, coder, symbol): + def execute(cls, coder, symbol, **kwargs): """ Find files containing a symbol using RepoMap and return them as text. Checks files already in context first. diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 01dcea809bb..264949b8c89 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -3,6 +3,7 @@ import concurrent.futures import json import queue +from pathlib import Path from textual import events from textual.app import App, ComposeResult @@ -248,15 +249,18 @@ def compose(self) -> ComposeResult: coder_mode = getattr(coder, "edit_format", "code") or "code" # Get project name (just the folder name, not full path) - project_name = "" + project_name = str(Path.cwd()) + + if len(project_name) >= 64: + project_name = project_name.split("/")[-1] + if coder.repo: - project_name = ( - coder.repo.root.name - if hasattr(coder.repo.root, "name") - else str(coder.repo.root).split("/")[-1] - ) - else: - project_name = "No Repo" + root_path = str(coder.repo.root) + + if len(root_path) <= 64: + project_name = root_path + else: + project_name = root_path.split("/")[-1] # Get history file path from coder's io history_file = getattr(coder.io, "input_history_file", None) @@ -274,7 +278,7 @@ def compose(self) -> ComposeResult: yield KeyHints(id="key-hints") yield MainFooter( model_name=model_name, - project_name=str(coder.repo.root) if len(str(coder.repo.root)) <= 64 else project_name, + project_name=project_name, git_branch="", # Loaded async in on_mount coder_mode=coder_mode, id="footer", @@ -773,7 +777,7 @@ def on_status_bar_confirm_response(self, message: StatusBar.ConfirmResponse): self.input_queue.put({"confirmed": message.result}) # Commands that use path-based completion - PATH_COMPLETION_COMMANDS = {"/read-only", "/read-only-stub", "/load", "/save"} + PATH_COMPLETION_COMMANDS = {"/add", "/read-only", "/read-only-stub", "/load", "/save"} def _extract_symbols(self) -> set[str]: """Extract code symbols from files in chat using Pygments.""" @@ -845,8 +849,6 @@ def _get_symbol_completions(self, prefix: str) -> list[str]: def _get_path_completions(self, prefix: str) -> list[str]: """Get filesystem path completions relative to coder root.""" - from pathlib import Path - coder = self.worker.coder root = Path(coder.root) if hasattr(coder, "root") else Path.cwd() @@ -928,9 +930,9 @@ def _get_suggestions(self, text: str) -> list[str]: if cmd_name in self.PATH_COMPLETION_COMMANDS: suggestions = self._get_path_completions(arg_prefix) # For /read-only and /read-only-stub, also include add completions - if cmd_name in {"/read-only", "/read-only-stub"}: + if cmd_name in {"/add", "/read-only", "/read-only-stub"}: try: - add_completions = commands.get_completions("/add") or [] + add_completions = commands.get_completions(cmd_name) or [] for c in add_completions: if arg_prefix_lower in str(c).lower() and str(c) not in suggestions: suggestions.append(str(c)) diff --git a/cecli/tui/widgets/footer.py b/cecli/tui/widgets/footer.py index 41fb6fdacad..1a4934c4b42 100644 --- a/cecli/tui/widgets/footer.py +++ b/cecli/tui/widgets/footer.py @@ -107,7 +107,9 @@ def render(self) -> Text: if self.project_name: right.append(f"{self.project_name}") - right.append(" • ") + + if self.git_branch: + right.append(" • ") if self.git_branch: right.append(self.git_branch) diff --git a/tests/tools/test_show_numbered_context.py b/tests/tools/test_show_numbered_context.py index 19825b59f4a..a0a8e054a47 100644 --- a/tests/tools/test_show_numbered_context.py +++ b/tests/tools/test_show_numbered_context.py @@ -49,10 +49,14 @@ def test_pattern_with_zero_line_number_is_allowed(coder_with_file): result = show_numbered_context.Tool.execute( coder, - file_path="example.txt", - pattern="beta", - line_number=0, - context_lines=0, + show=[ + { + "file_path": "example.txt", + "pattern": "beta", + "line_number": 0, + "context_lines": 0, + } + ], ) assert "beta" in result @@ -65,10 +69,14 @@ def test_empty_pattern_uses_line_number(coder_with_file): result = show_numbered_context.Tool.execute( coder, - file_path="example.txt", - pattern="", - line_number=2, - context_lines=0, + show=[ + { + "file_path": "example.txt", + "pattern": "", + "line_number": 2, + "context_lines": 0, + } + ], ) assert "2 | beta" in result @@ -80,13 +88,17 @@ def test_conflicting_pattern_and_line_number_raise(coder_with_file): result = show_numbered_context.Tool.execute( coder, - file_path="example.txt", - pattern="beta", - line_number=2, - context_lines=0, + show=[ + { + "file_path": "example.txt", + "pattern": "beta", + "line_number": 2, + "context_lines": 0, + } + ], ) - assert result.startswith("Error: Provide exactly one of") + assert result.startswith("Error: Show operation 1: Provide exactly one of") coder.io.tool_error.assert_called()