diff --git a/cecli/commands/add.py b/cecli/commands/add.py index c4a4e31d15b..292a85c96bf 100644 --- a/cecli/commands/add.py +++ b/cecli/commands/add.py @@ -13,6 +13,15 @@ from cecli.utils import is_image_file, run_fzf +def _is_chat_attachment_staging_path(rel_norm: str) -> bool: + """ + Host UIs (e.g. desktop IDEs) stage chat uploads under ``./attachments/``. + Do not offer to create missing paths there via ``/add`` — re-attach in the UI instead. + """ + parts = rel_norm.replace("\\", "/").strip("/").split("/") + return len(parts) >= 2 and parts[0].startswith(".") and parts[1] == "attachments" + + class AddCommand(BaseCommand): NORM_NAME = "add" DESCRIPTION = "Add files to the chat so cecli can edit them or review them in detail" @@ -71,19 +80,25 @@ async def execute(cls, io, coder, args, **kwargs): if len(confirm_fname) > 64: confirm_fname = f".../{os.path.basename(confirm_fname)}" - # Check if the path matches any exempt-path regex patterns + try: + rel_norm = os.path.relpath(fname, coder.root).replace("\\", "/") + except ValueError: + rel_norm = str(fname).replace("\\", "/") + exempt_paths = getattr(coder.args, "exempt_paths", None) or [] - if exempt_paths: - try: - rel_norm = os.path.relpath(fname, coder.root).replace("\\", "/") - except ValueError: - rel_norm = str(fname).replace("\\", "/") - if any(re.search(p, rel_norm) for p in exempt_paths): - io.tool_error( - f"Path '{confirm_fname}' matches an exempt-path pattern. " - "Skipping file creation." - ) - continue + if exempt_paths and any(re.search(p, rel_norm) for p in exempt_paths): + io.tool_error( + f"Path '{confirm_fname}' matches an exempt-path pattern. " + "Skipping file creation." + ) + continue + + if _is_chat_attachment_staging_path(rel_norm): + io.tool_error( + f"Attachment not found: {confirm_fname}. " + "Re-attach the file in chat instead of using /add on a staging path." + ) + continue if await io.confirm_ask( f"No files matched '{confirm_fname}'. Do you want to create this file?" diff --git a/cecli/repomap.py b/cecli/repomap.py index 1f721ef0ca3..0afc009042d 100644 --- a/cecli/repomap.py +++ b/cecli/repomap.py @@ -350,6 +350,12 @@ def get_repo_map( "has_chat_files": bool(chat_files), } + def _resolve_abs_fname(self, fname: str) -> str: + """Normalize repo file paths for existence checks and tag parsing.""" + if os.path.isabs(fname): + return os.path.normpath(fname) + return os.path.normpath(os.path.join(self.root, fname)) + def get_rel_fname(self, fname): try: return os.path.relpath(fname, self.root) @@ -757,8 +763,9 @@ def get_ranked_tags( else: self.io.update_spinner(f"{UPDATING_REPO_MAP_MESSAGE}: {fname}") + abs_fname = self._resolve_abs_fname(fname) try: - file_ok = os.path.isfile(fname) + file_ok = os.path.isfile(abs_fname) except OSError: file_ok = False @@ -768,11 +775,12 @@ def get_ranked_tags( self.warned_files.add(fname) if skipped_missing <= 2: self.io.tool_warning( - f"Repo-map skipping missing file: {fname}" + f"Repo-map skipping missing file: {abs_fname}" " (removed on disk or not yet written)." ) continue + fname = abs_fname # dump(fname) rel_fname = self.get_rel_fname(fname) current_pers = 0.0 # Start with 0 personalization score diff --git a/cecli/tools/command.py b/cecli/tools/command.py index f09d32f2cdf..70ae397b664 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -287,8 +287,8 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal # Remove from background tracking since it's done BackgroundCommandManager.stop_background_command(command_key) - # Output to TUI console if TUI exists (same logic as _execute_foreground) - if coder.tui and coder.tui(): + # Emit output to frontend (tool card collapsible output) + if output_content.strip(): coder.io.tool_output(output_content, type="tool-result") if exit_code == 0: diff --git a/cecli/tools/ls.py b/cecli/tools/ls.py index 89c5b791b5f..cc09966edee 100644 --- a/cecli/tools/ls.py +++ b/cecli/tools/ls.py @@ -80,6 +80,8 @@ def execute(cls, coder, path=None, **kwargs): f"📋 Listed {len(contents)} file(s) in '{dir_path}'", type="tool-result" ) sorted_contents = sorted(contents) + # Emit full listing for frontend tool card (collapsible output) + coder.io.tool_output("\n".join(sorted_contents)) if len(sorted_contents) > 10: return ( f"Found {len(sorted_contents)} files: {', '.join(sorted_contents[:10])}..." diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index f9fafa4a3cb..1c7fe396e81 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -17,38 +17,31 @@ class Tool(BaseTool): NORM_NAME = "readrange" TRACK_INVOCATIONS = False VALIDATIONS = { - "read": ["coerce_list"], - "read[]": ["coerce_dict"], - "read[].range_start": ["coerce_str"], - "read[].range_end": ["coerce_str"], + "show": ["coerce_list"], + "show[]": ["coerce_dict"], } SCHEMA = { "type": "function", "function": { "name": "ReadRange", "description": ( - "Get content ID prefixed content between start and end markers in files." - " This is useful for files you are attempting to edit and for understanding their structure." - " Accepts an array of `read` objects, each with file_path, range_start, range_end." - " They can contain up to 3 lines of content. Avoid using singular generic keywords and" + "Get content hash prefixes of content between start and end patterns in files." + " Accepts an array of `show` objects, each with file_path, start_text, end_text." + " These values must be lines of content in the file. They can contain up to 3" + " lines but newlines should generally be avoided. Avoid using generic keywords and" " symbols. Special markers @000 and 000@ represent the file boundaries and can be" - " used for range_start and range_end for the first and last lines of the file" - " respectively. Line numbers may also be used for range lookups." - " It is best to use function names, variable declarations, entire line contents" - " and other meaningful identifiers as range_start and range_end values." - " Do not use the same pattern for the range_start and range_end." - " Do not use empty strings for the range_start and range_end." - " Do not use content IDs for the range_start and range_end values as they change between edits." - " Always use the ReadRange tool instead of cli tools for reading file contents." - " Line number and special marker ranges greater than 200 lines will return" - " preview content for further, more scoped investigation." - " Call this tool sequentially on increasingly finer grained searches " - " to help with understanding important structural features in large files." + " used for start_text and end_text for the first and last lines of the file" + " respectively. Avoid using both of the special markers together on non-empty" + " files. Line numbers may be used as values but they are discouraged as" + " they shift between edits. Never use content hashes as the start_text and end_text values." + " Do not use the same pattern for the start_text and end_text. It is best to use function" + " names, variable declarations and other meaningful identifiers as start_text and" + " end_text values." ), "parameters": { "type": "object", "properties": { - "read": { + "show": { "type": "array", "items": { "type": "object", @@ -57,14 +50,14 @@ class Tool(BaseTool): "type": "string", "description": "File path to search in.", }, - "range_start": { + "start_text": { "type": "string", "description": ( "The text marking the beginning of the range." " Use '@000' for the first line on empty files." ), }, - "range_end": { + "end_text": { "type": "string", "description": ( "The text marking the end of the range." @@ -72,26 +65,25 @@ class Tool(BaseTool): ), }, }, - "required": ["file_path", "range_start", "range_end"], + "required": ["file_path", "start_text", "end_text"], }, - "description": "Array of read operations to perform.", + "description": "Array of show operations to perform.", }, }, - "required": ["read"], + "required": ["show"], }, }, } _last_invocation = {} # file_path -> {start_idx, end_idx} _last_read_turn: Dict[str, int] = {} # abs_path -> turn_count when last read - _special_marker_count: Dict[str, int] = {} # abs_path -> count of both-special-marker reads @classmethod - def execute(cls, coder, read, **kwargs): + def execute(cls, coder, show, **kwargs): """ Displays numbered lines from multiple files centered around target locations (patterns or line_numbers), without adding files to context. - Accepts an array of read operations to perform. + Accepts an array of show operations to perform. Uses utility functions for path resolution and error handling. """ from cecli.helpers.conversation import ConversationService @@ -102,73 +94,88 @@ def execute(cls, coder, read, **kwargs): error_outputs = [] try: - # 1. Validate read parameter - if not isinstance(read, list): - read = [read] if isinstance(read, dict) else read + # 1. Validate show parameter + if not isinstance(show, list): + show = [show] if isinstance(show, dict) else show - if len(read) == 0: - raise ToolError("read array cannot be empty") + if len(show) == 0: + raise ToolError("show array cannot be empty") all_outputs = [] already_up_to_details = [] new_context_details = [] - all_outputs_set = set() - new_context_set = set() - already_up_to_set = set() ranges = {} - for read_index, read_op in enumerate(read): - # Extract parameters for this read operation - file_path = read_op.get("file_path") - range_start = read_op.get("range_start") - range_end = read_op.get("range_end") + for show_index, show_op in enumerate(show): + # Extract parameters for this show operation + file_path = show_op.get("file_path") + start_text = show_op.get("start_text") + end_text = show_op.get("end_text") padding = 5 if file_path is None: error_outputs.append( cls.format_error( coder, - f"read operation {read_index + 1} missing required file_path parameter", + f"Show operation {show_index + 1} missing required file_path parameter", None, None, None, - read_index, + show_index, ) ) continue # Validate arguments for this operation - if not is_provided(range_start) or not is_provided(range_end): - error_outputs.append( - cls.format_error( - coder, - ( - f"read operation {read_index + 1}: Provide both 'range_start' and" - " 'range_end'." - ), - file_path, - range_start, - range_end, - read_index, + if not is_provided(start_text) or not is_provided(end_text): + # Auto-fallback: if the file is in editable context and markers are + # missing/junk, show the whole file instead of erroring. + abs_fnames = getattr(coder, "abs_fnames", set()) + abs_ro = getattr(coder, "abs_read_only_fnames", set()) + try: + abs_path_check = coder.abs_root_path(file_path) if file_path else None + except Exception: + abs_path_check = None + if abs_path_check and ( + abs_path_check in abs_fnames or abs_path_check in abs_ro + ): + start_text = "@000" + end_text = "000@" + else: + error_outputs.append( + cls.format_error( + coder, + ( + f"Show operation {show_index + 1}: Provide both 'start_text' and" + " 'end_text'." + ), + file_path, + start_text, + end_text, + show_index, + ) ) - ) - continue + continue + + # Models sometimes pass line numbers as int; coerce before str ops. + start_text = str(start_text) + end_text = str(end_text) - if range_start.count("\n") > 4 or range_end.count("\n") > 4: + if start_text.count("\n") > 4 or end_text.count("\n") > 4: error_outputs.append( cls.format_error( coder, "Patterns must not contain more than 5 lines.", file_path, - range_start, - range_end, - read_index, + start_text, + end_text, + show_index, ) ) continue - range_start = strip_hashline(range_start).strip() - range_end = strip_hashline(range_end).strip() + start_text = strip_hashline(start_text).strip() + end_text = strip_hashline(end_text).strip() # 2. Resolve path abs_path, rel_path = resolve_paths(coder, file_path) @@ -179,25 +186,24 @@ def execute(cls, coder, read, **kwargs): coder, f"File not found: {file_path}", file_path, - range_start, - range_end, - read_index, + start_text, + end_text, + show_index, ) ) continue # 3. Read file content - content: str = coder.io.read_text(abs_path) - + content = coder.io.read_text(abs_path) if content is None: error_outputs.append( cls.format_error( coder, f"Could not read file: {file_path}", file_path, - range_start, - range_end, - read_index, + start_text, + end_text, + show_index, ) ) continue @@ -224,80 +230,25 @@ def execute(cls, coder, read, **kwargs): # 4. Determine line range start_line_idx = -1 end_line_idx = -1 - both_structured = False - # found_by = "" - - if range_start is not None and range_end is not None: - - def _is_valid_int(s): - try: - int(s) - return True - except ValueError: - return False - - start_is_digit = _is_valid_int(range_start) - end_is_digit = _is_valid_int(range_end) - start_is_special = range_start in ("@000", "000@") - end_is_special = range_end in ("@000", "000@") - both_structured = (start_is_digit or start_is_special) and ( - end_is_digit or end_is_special - ) - start_is_text = not start_is_digit and not start_is_special - end_is_text = not end_is_digit and not end_is_special - mixed_special_search = (start_is_special and end_is_text) or ( - end_is_special and start_is_text - ) - start_indices = [] - end_indices = [] - - if both_structured: - if start_is_digit: - start_line_num = int(range_start) - 1 - start_line_num = max(0, min(start_line_num, num_lines - 1)) - start_indices = [start_line_num] - else: - start_indices = [0] - - if end_is_digit: - end_line_num = int(range_end) - 1 - end_line_num = max(0, min(end_line_num, num_lines - 1)) - end_indices = [end_line_num] - else: - end_indices = [num_lines - 1] - - elif mixed_special_search: - if start_is_special: - # Start is special marker, end is text pattern - if range_start == "@000": - start_indices = [0] - else: # 000@ - start_indices = [num_lines - 1] - # Search for end pattern as text - end_pattern_lines = range_end.split("\n") - end_indices = [] - for i in range(len(lines) - len(end_pattern_lines) + 1): - if all( - p_line in lines[i + j] - for j, p_line in enumerate(end_pattern_lines) - ): - end_indices.append(i + len(end_pattern_lines) - 1) - else: - # Start is text pattern, end is special marker - start_pattern_lines = range_start.split("\n") - start_indices = [] - for i in range(len(lines) - len(start_pattern_lines) + 1): - if all( - p_line in lines[i + j] - for j, p_line in enumerate(start_pattern_lines) - ): - start_indices.append(i) - if range_end == "@000": - end_indices = [0] - else: # 000@ - end_indices = [num_lines - 1] + found_by = "" + + if start_text is not None and end_text is not None: + if start_text.isdigit() and end_text.isdigit(): + # Treat both as 1-based line numbers + start_line_num = int(start_text) + end_line_num = int(end_text) + # Clamp to valid range [1, num_lines] + start_line_num = max(1, min(start_line_num, num_lines)) + end_line_num = max(1, min(end_line_num, num_lines)) + if start_line_num > end_line_num: + # Swap so start <= end + start_line_num, end_line_num = end_line_num, start_line_num + start_indices = [start_line_num - 1] + end_indices = [end_line_num - 1] + elif start_text == "@000" or start_text == "000@": + start_indices = [0] else: - start_pattern_lines = range_start.split("\n") + start_pattern_lines = start_text.split("\n") start_indices = [] for i in range(len(lines) - len(start_pattern_lines) + 1): if all( @@ -306,7 +257,10 @@ def _is_valid_int(s): ): start_indices.append(i) - end_pattern_lines = range_end.split("\n") + if end_text == "000@" or end_text == "@000": + end_indices = [num_lines - 1] + else: + end_pattern_lines = end_text.split("\n") end_indices = [] for i in range(len(lines) - len(end_pattern_lines) + 1): if all( @@ -323,13 +277,13 @@ def _is_valid_int(s): cls.format_error( coder, ( - f"Start pattern '{range_start}' too broad." + f"Start pattern '{start_text}' too broad." " Refine your search. Be more specific." ), file_path, - range_start, - range_end, - read_index, + start_text, + end_text, + show_index, ) ) continue @@ -375,13 +329,13 @@ def _is_valid_int(s): cls.format_error( coder, ( - f"Start pattern '{range_start}' not found in {file_path}." + f"Start pattern '{start_text}' not found in {file_path}." " Refine your search." ), file_path, - range_start, - range_end, - read_index, + start_text, + end_text, + show_index, ) ) continue @@ -391,13 +345,13 @@ def _is_valid_int(s): cls.format_error( coder, ( - f"End pattern '{range_end}' not found in {file_path}." + f"End pattern '{end_text}' not found in {file_path}." " Refine your search." ), file_path, - range_start, - range_end, - read_index, + start_text, + end_text, + show_index, ) ) continue @@ -407,72 +361,36 @@ def _is_valid_int(s): cls.format_error( coder, ( - f"End pattern '{range_end}' not found after start pattern in" + f"End pattern '{end_text}' not found after start pattern in" f" {file_path}." ), file_path, - range_start, - range_end, - read_index, + start_text, + end_text, + show_index, ) ) continue - if best_pair is None: - error_outputs.append( - cls.format_error( - coder, - ( - f"End pattern '{range_end}' not found after start pattern in" - f" {file_path}." - ), - file_path, - range_start, - range_end, - read_index, - ) - ) - continue - s_idx, e_idx = best_pair - s_idx, e_idx = cls._extend_range_with_stub( - coder, abs_path, s_idx, e_idx, num_lines + + # Validate range width when special markers are used + # If too large, use _get_range_preview which tries get_file_stub + # first, falling back to 20 equally-spaced lines for non-code files + if (start_text == "@000" or end_text == "000@") and (e_idx - s_idx > 200): + preview = cls._get_range_preview( + abs_path, coder.io, start_idx=s_idx, end_idx=e_idx, line_numbers=True ) + if show_index > 0: + all_outputs.append("") + all_outputs.append(preview) + cls._last_invocation[abs_path] = {"start_idx": s_idx, "end_idx": e_idx} + continue # Store the found indices for future disambiguation cls._last_invocation[abs_path] = {"start_idx": s_idx, "end_idx": e_idx} - # For structured searches (line numbers, special markers) or mixed searches - # (one special marker, one text pattern), cap large ranges with preview - # Text pattern searches are not subject to capping - if both_structured or (mixed_special_search and (e_idx - s_idx > 200)): - - preview, has_stub = cls._get_range_preview( - coder, abs_path, start_idx=s_idx, end_idx=e_idx, line_numbers=True - ) - - if has_stub and abs_path not in coder.abs_fnames: - token_count = coder.main_model.token_count(content) - # Track special marker usage for auto-editable detection - if token_count <= coder.large_file_token_threshold: - cls._special_marker_count[abs_path] = ( - cls._special_marker_count.get(abs_path, 0) + 1 - ) - if cls._special_marker_count[abs_path] > 1: - coder.abs_fnames.add(abs_path) - preview = f"Full contents of {rel_path} added to cotext." - if abs_path in coder.abs_read_only_fnames: - coder.abs_read_only_fnames.remove(abs_path) - - if preview not in all_outputs_set: - all_outputs_set.add(preview) - if len(all_outputs): - all_outputs.append("") - all_outputs.append(preview) - - continue - - # found_by = f"range '{range_start}' to '{range_end}'" + found_by = f"range '{start_text}' to '{end_text}'" try: padding_int = int(padding) @@ -490,32 +408,32 @@ def _is_valid_int(s): coder, "Internal error: Could not determine line range.", file_path, - range_start, - range_end, - read_index, + start_text, + end_text, + show_index, ) ) continue # 6. Format output for this operation # Use rel_path for user-facing messages - # output_lines = [f"Displaying context around {found_by} in {rel_path}:"] + output_lines = [f"Displaying context around {found_by} in {rel_path}:"] # Generate hashline for the entire file hashed_content = hashline(content) hashed_lines = hashed_content.splitlines() # Extract the context window from hashed lines - # context_hashed_lines = hashed_lines[start_line_idx : end_line_idx + 1] + context_hashed_lines = hashed_lines[start_line_idx : end_line_idx + 1] - # for i in range(start_line_idx, end_line_idx + 1): - # hashed_line = context_hashed_lines[i - start_line_idx] - # output_lines.append(hashed_line) + for i in range(start_line_idx, end_line_idx + 1): + hashed_line = context_hashed_lines[i - start_line_idx] + output_lines.append(hashed_line) - # Add separator between multiple read operations - # if read_index > 0: - # all_outputs.append("") - # all_outputs.extend(output_lines) + # Add separator between multiple show operations + if show_index > 0: + all_outputs.append("") + all_outputs.extend(output_lines) # Update the conversation cache with the displayed range # Note: start_line_idx and end_line_idx are 0-based, convert to 1-based for hashline @@ -536,14 +454,14 @@ def _is_valid_int(s): is_already_up_to_date = False add_to_ranges = False - # last_turn = cls._last_read_turn.get(abs_path) + last_turn = cls._last_read_turn.get(abs_path) if original_context_content and original_context_content == new_context_content: already_up_to_date.append(rel_path) is_already_up_to_date = True - # if last_turn is None or coder.turn_count - last_turn < 3 and already_up_to_date: - # add_to_ranges = True + if last_turn is None or coder.turn_count - last_turn < 3 and already_up_to_date: + add_to_ranges = True else: add_to_ranges = True @@ -563,24 +481,16 @@ def _is_valid_int(s): and e_idx >= 0 and e_idx < len(hashed_lines) ): - # hashed_slice = hashed_lines[s_idx : e_idx + 1] + hashed_slice = hashed_lines[s_idx : e_idx + 1] if is_already_up_to_date: - model_response = cls.format_model_response( - coder, rel_path, s_idx, e_idx, hashed_lines, current=True + already_up_to_details.append( + cls.format_model_response(coder, rel_path, s_idx, e_idx, hashed_slice) ) - - if model_response not in already_up_to_set: - already_up_to_set.add(model_response) - already_up_to_details.append(model_response) else: - model_response = cls.format_model_response( - coder, rel_path, s_idx, e_idx, hashed_lines + new_context_details.append( + cls.format_model_response(coder, rel_path, s_idx, e_idx, hashed_slice) ) - if model_response not in new_context_set: - new_context_set.add(model_response) - new_context_details.append(model_response) - # Conditionally remove old file context messages # If the file was last read >= 3 turns ago, keep old messages (allow coexistence) # Otherwise, remove them to avoid duplicates @@ -607,23 +517,18 @@ def _is_valid_int(s): if already_up_to_details or new_context_details: if new_context_details: coder.io.tool_output( - f"✓ Retrieved context for {len(new_context_details)} operation(s)", - type="tool-result", + f"✅ Retrieved context for {len(new_context_details)} operation(s)" ) detail_str = "\n".join(new_context_details) result_parts.append( f"Retrieved context for {len(new_context_details)} operation(s):\n\n" f"{detail_str}\n" - "Full results for these reads will be given in a follow up message.\n" ) if already_up_to_details: coder.io.tool_output( - ( - "Lines already up to date in context for" - f" {len(already_up_to_details)} operation(s)" - ), - type="tool-result", + "Lines already up to date in context for" + f" {len(already_up_to_details)} operation(s)" ) detail_str = "\n".join(already_up_to_details) @@ -631,7 +536,6 @@ def _is_valid_int(s): "Content up to date and available in history from previous read for " f"{len(already_up_to_details)} operation(s):\n\n" f"{detail_str}\n" - "Current contents for these reads available in previous content ID message." ) if already_up_to_date and not new_context_retrieved: result_parts.append( @@ -639,14 +543,8 @@ def _is_valid_int(s): " the relevant files." ) - if all_outputs: - result_parts.append("\n".join(all_outputs)) - result_parts.append("\nUse these outlines to refine your search.\n") - if error_outputs: - coder.io.tool_error( - f"Errors encountered for {len(error_outputs)} operation(s)", type="tool-result" - ) + coder.io.tool_error(f"Errors encountered for {len(error_outputs)} operation(s)") result_parts.append("Errors:\n" + "\n".join(error_outputs)) @@ -663,108 +561,19 @@ def _is_valid_int(s): return handle_tool_error(coder, tool_name, e) @classmethod - def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_lines, current=False): + def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_slice): """Format a file's context range as hash-prefixed lines for the model.""" - # Read file content for stub lookups - try: - from cecli.tools.utils.helpers import resolve_paths - - abs_path, _ = resolve_paths(coder, rel_path) - last_turn = cls._last_read_turn[abs_path] or 0 - except Exception: - pass - - lines = [] - - # Try to return structural stub information instead of raw hashed lines - try: - if hashed_lines and current and coder.turn_count - last_turn >= 2: - num_lines = len(hashed_lines) - - start_stub_s, start_stub_e = cls._extend_range_with_stub( - coder, abs_path, s_idx, s_idx, num_lines - ) - end_stub_s, end_stub_e = cls._extend_range_with_stub( - coder, abs_path, e_idx, e_idx, num_lines - ) - - # start_stub_s, start_stub_e = cls._reposition_indices(s_idx, start_stub_s, start_stub_e) - # end_stub_s, end_stub_e = cls._reposition_indices(e_idx, end_stub_s, end_stub_e) - - start_found = start_stub_s != s_idx or start_stub_e != s_idx - end_found = end_stub_s != e_idx or end_stub_e != e_idx - - if end_stub_s != start_stub_s or end_stub_e != start_stub_e: - start_stub_s = end_stub_s - start_stub_e = end_stub_e - start_found = True - end_found = False - - if start_found or end_found: - if start_found: - lines.append( - f"File {rel_path} Snapshot (Lines {start_stub_s + 1} - {start_stub_e + 1}):" - ) - lines.extend(hashed_lines[start_stub_s:start_stub_e]) - - if ( - end_found - and start_stub_s != end_stub_s - and start_stub_e != end_stub_e - and end_stub_e != e_idx - ): - lines.append("...⋮...") - lines.append( - f"File {rel_path} Snapshot (Lines {end_stub_s + 1} - {end_stub_e + 1}):" - ) - lines.extend(hashed_lines[end_stub_s:end_stub_e]) - - lines.append("") - return "\n".join(lines) - except Exception: - pass - lines = [f"File {rel_path} Snapshot (Lines {s_idx + 1} - {e_idx + 1}):"] - total = e_idx - s_idx - if total <= 15: - lines.extend(hashed_lines[s_idx : e_idx + 1]) + total = len(hashed_slice) + if total <= 10: + lines.extend(hashed_slice) else: - lines.extend(hashed_lines[s_idx : s_idx + 5]) - lines.append("...⋮...") - lines.extend(hashed_lines[e_idx - 4 : e_idx + 1]) + lines.extend(hashed_slice[:5]) + lines.append("...") + lines.extend(hashed_slice[-5:]) lines.append("") return "\n".join(lines) - @classmethod - def _reposition_indices( - cls, target_idx: int, start_idx: int, end_idx: int, total_lines: int = 20 - ) -> tuple: - """ - Calculates the clamped start and end indices for a centered window. - Returns a tuple of (slice_start, slice_end) compatible with python slicing. - """ - # 1. Calculate ideal half-window size - half_window = total_lines // 2 - - # 2. Calculate initial left/right bounds - left = target_idx - half_window - right = target_idx + half_window - - # 3. Slide the window if it overflows boundaries - if left < start_idx: - right += start_idx - left - left = start_idx - - if right > end_idx: - left -= right - end_idx - right = end_idx - - # 4. Final safety clamp in case the range itself is smaller than total_lines - left = max(start_idx, left) - - # Return right + 1 so it's ready-to-use for standard Python slicing [start:end] - return left, right + 1 - @classmethod def clear_old_messages(cls, coder): from cecli.helpers.conversation import ConversationService, MessageTag @@ -828,18 +637,18 @@ def format_output(cls, coder, mcp_server, tool_response): coder.io.tool_error("Invalid Tool JSON") return - read_ops = params.get("read", []) - if read_ops: + show_ops = params.get("show", []) + if show_ops: coder.io.tool_output("") - for i, read_op in enumerate(read_ops): - file_path = read_op.get("file_path", "") - range_start = strip_hashline(read_op.get("range_start", "")).strip() - range_end = strip_hashline(read_op.get("range_end", "")).strip() + for i, show_op in enumerate(show_ops): + file_path = show_op.get("file_path", "") + start_text = strip_hashline(str(show_op.get("start_text", ""))).strip() + end_text = strip_hashline(str(show_op.get("end_text", ""))).strip() - # Format as "read: • file_path • range_start • range_end • padding" + # Format as "show: • file_path • start_text • end_text • padding" formatted_query = ( - f"{color_start}range_{i + 1}:{color_end} {file_path} • {range_start} •" - f" {range_end}" + f"{color_start}range_{i + 1}:{color_end} {file_path} • {start_text} •" + f" {end_text}" ) coder.io.tool_output(formatted_query) coder.io.tool_output("") @@ -847,24 +656,26 @@ def format_output(cls, coder, mcp_server, tool_response): tool_footer(coder=coder, tool_response=tool_response, params=params) @classmethod - def format_error(cls, coder, error_text, file_path, range_start, range_end, operation_index): + def format_error(cls, coder, error_text, file_path, start_text, end_text, operation_index): """Format error output for the ReadRange tool.""" - # Truncate range_start to first line with ellipsis if multiline - start_line = (range_start or "N/A").split("\n")[0] - if range_start and range_start.count("\n") > 0: + # Truncate start_text to first line with ellipsis if multiline + start_text = str(start_text or "") + end_text = str(end_text or "") + start_line = (start_text or "N/A").split("\n")[0] + if start_text and start_text.count("\n") > 0: start_line = start_line + " ..." - # Truncate range_end to first line with ellipsis if multiline - end_line = (range_end or "N/A").split("\n")[0] - if range_end and range_end.count("\n") > 0: + # Truncate end_text to first line with ellipsis if multiline + end_line = (end_text or "N/A").split("\n")[0] + if end_text and end_text.count("\n") > 0: end_line = end_line + " ..." output = [ f"[Operation {operation_index + 1}]", f"file_path: {file_path or 'N/A'}", - f"range_start: {start_line}", - f"range_end: {end_line}", + f"start_text: {start_line}", + f"end_text: {end_line}", "", error_text, ] @@ -876,58 +687,7 @@ def on_duplicate_request(cls, coder, **kwargs): coder.edit_allowed = True @classmethod - def _extend_range_with_stub(cls, coder, abs_path, s_idx, e_idx, num_lines): - """ - Extends the range [s_idx, e_idx] to include the stub result before - and up to the stub result after the specified range. - """ - from cecli.repomap import RepoMap - - try: - if not hasattr(RepoMap, "_stub_instance"): - RepoMap._stub_instance = RepoMap(map_tokens=0, io=coder.io) - rm = RepoMap._stub_instance - rel_fname = rm.get_rel_fname(abs_path) - tags = rm.get_tags(abs_path, rel_fname) - if not tags: - return s_idx, e_idx - - # Get all definition lines, plus import lines for structural context - lois = sorted( - list( - set( - tag.line - for tag in tags - if tag.kind == "def" or tag.specific_kind == "import" - ) - ) - ) - if not lois: - return s_idx, e_idx - - # Find the stub result before or at s_idx - # We want the largest line in lois that is <= s_idx - before_lines = [ln for ln in lois if ln <= s_idx] - new_s_idx = s_idx - if before_lines: - new_s_idx = before_lines[-1] - - # Find the stub result after e_idx - # We want the smallest line in lois that is > e_idx - after_lines = [ln for ln in lois if ln > e_idx] - new_e_idx = e_idx - if after_lines: - new_e_idx = after_lines[0] - 1 - else: - new_e_idx = num_lines - 1 - - return new_s_idx, new_e_idx - except Exception: - # Fallback to original range if anything goes wrong - return s_idx, e_idx - - @classmethod - def _get_range_preview(cls, coder, abs_path, start_idx, end_idx, line_numbers=True): + def _get_range_preview(cls, abs_path, io, start_idx, end_idx, line_numbers=True): """Get a preview of a large file range between start_idx and end_idx. For code files (where tree-sitter can parse structure), uses @@ -947,9 +707,6 @@ def _get_range_preview(cls, coder, abs_path, start_idx, end_idx, line_numbers=Tr """ from cecli.repomap import RepoMap - io = coder.io - abs_path, rel_path = resolve_paths(coder, abs_path) - stub = RepoMap.get_file_stub( abs_path, io, start_line=start_idx, end_line=end_idx, line_numbers=line_numbers ) @@ -958,12 +715,12 @@ def _get_range_preview(cls, coder, abs_path, start_idx, end_idx, line_numbers=Tr if stub and stub != "# No outline available": total_lines = end_idx - start_idx + 1 parts = [ - f"Showing structural information for {rel_path}:", - "Use this information to further narrow your search", + f"File range too large ({total_lines} lines).", + "Showing structural outline of the range:", "", stub, ] - return "\n".join(parts), True + return "\n".join(parts) content = io.read_text(abs_path) if not content: @@ -977,7 +734,7 @@ def _get_range_preview(cls, coder, abs_path, start_idx, end_idx, line_numbers=Tr total_lines = actual_end - actual_start + 1 if total_lines <= 0: - return "", False + return "" if total_lines <= 20: # Return all lines @@ -1008,4 +765,4 @@ def _get_range_preview(cls, coder, abs_path, start_idx, end_idx, line_numbers=Tr line_num = idx + 1 parts.append(f" {line_num:>5} | {line_content}") - return "\n".join(parts), False + return "\n".join(parts) diff --git a/cecli/tools/utils/helpers.py b/cecli/tools/utils/helpers.py index e97bc28e204..499e0901e82 100644 --- a/cecli/tools/utils/helpers.py +++ b/cecli/tools/utils/helpers.py @@ -21,7 +21,7 @@ def is_provided(value, *, treat_zero_as_missing=False): """ if value is None: return False - if isinstance(value, str) and value == "": + if isinstance(value, str) and value.strip().lower() in ("", "n/a", "null", "none"): return False if treat_zero_as_missing and isinstance(value, (int, float)) and value == 0: return False diff --git a/tests/basic/test_commands.py b/tests/basic/test_commands.py index c62f8ec26fc..484e7815d03 100644 --- a/tests/basic/test_commands.py +++ b/tests/basic/test_commands.py @@ -248,6 +248,19 @@ async def test_cmd_add_skips_create_on_exempt_path(self): self.assertEqual(len(coder.abs_fnames), 0) self.assertFalse(staging.exists()) + async def test_cmd_add_skips_create_on_attachment_staging_path(self): + io = InputOutput(pretty=False, fancy_input=False, yes=True) + from cecli.coders import Coder + + coder = await Coder.create(self.GPT35, None, io) + commands = Commands(io, coder) + + staging = Path(".cecli/attachments/missing.png") + commands.execute("add", str(staging)) + + self.assertEqual(len(coder.abs_fnames), 0) + self.assertFalse(staging.exists()) + async def test_cmd_add_drop_directory(self): # Initialize the Commands and InputOutput objects io = InputOutput(pretty=False, fancy_input=False, yes=False)