From c4e626b1fa9661232399845bfe24ef8944cfb401 Mon Sep 17 00:00:00 2001 From: Roland Parnaso Date: Wed, 1 Apr 2026 20:49:52 -0700 Subject: [PATCH] refactor: extract _detect_file_drop() + add 28 tests Extract the inline file-drop detection logic into a standalone _detect_file_drop() function at module level for testability. The main loop now calls this function instead of inlining the logic. Tests cover: - Slash commands still route correctly (/help, /quit, /xyz) - Image paths auto-detected (.png, .jpg, .gif, etc.) - Non-image files detected (.py, .txt, Makefile, etc.) - Backslash-escaped spaces from macOS drag-and-drop - Trailing user text preserved as remainder - Edge cases: directories, symlinks, no-extension files - Non-string input, empty strings, nonexistent paths --- cli.py | 114 ++++++++++++++--------- tests/test_cli_file_drop.py | 176 ++++++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 41 deletions(-) create mode 100644 tests/test_cli_file_drop.py diff --git a/cli.py b/cli.py index 2f0561a92..165f8319e 100644 --- a/cli.py +++ b/cli.py @@ -830,6 +830,63 @@ def _cprint(text: str): _pt_print(_PT_ANSI(text)) +# --------------------------------------------------------------------------- +# File-drop detection — extracted as a pure function for testability. +# --------------------------------------------------------------------------- + +_IMAGE_EXTENSIONS = frozenset({ + '.png', '.jpg', '.jpeg', '.gif', '.webp', + '.bmp', '.tiff', '.tif', '.svg', '.ico', +}) + + +def _detect_file_drop(user_input: str) -> "dict | None": + """Detect if *user_input* is a dragged/pasted file path, not a slash command. + + When a user drags a file into the terminal, macOS pastes the absolute path + (e.g. ``/Users/roland/Desktop/file.png``) which starts with ``/`` and would + otherwise be mistaken for a slash command. + + Returns a dict on match:: + + { + "path": Path, # resolved file path + "is_image": bool, # True when suffix is a known image type + "remainder": str, # any text after the path + } + + Returns ``None`` when the input is not a real file path. + """ + if not isinstance(user_input, str) or not user_input.startswith("/"): + return None + + # Walk the string absorbing backslash-escaped spaces ("\ "). + raw = user_input + pos = 0 + while pos < len(raw): + ch = raw[pos] + if ch == '\\' and pos + 1 < len(raw) and raw[pos + 1] == ' ': + pos += 2 # skip escaped space + elif ch == ' ': + break + else: + pos += 1 + + first_token_raw = raw[:pos] + first_token = first_token_raw.replace('\\ ', ' ') + drop_path = Path(first_token) + + if not drop_path.exists() or not drop_path.is_file(): + return None + + remainder = raw[pos:].strip() + return { + "path": drop_path, + "is_image": drop_path.suffix.lower() in _IMAGE_EXTENSIONS, + "remainder": remainder, + } + + class ChatConsole: """Rich Console adapter for prompt_toolkit's patch_stdout context. @@ -7556,48 +7613,23 @@ class HermesCLI: user_input, submit_images = user_input # Check for commands — but detect dragged/pasted file paths first. - # When a user drags a file into the terminal, macOS pastes the - # absolute path (e.g. /Users/roland/Desktop/file.png) which - # starts with "/" and would otherwise be mistaken for a slash - # command. We detect this by checking if the first token is a - # real filesystem path. - _is_file_drop = False - if isinstance(user_input, str) and user_input.startswith("/"): - # Extract the first whitespace-delimited token as a path candidate. - # Dragged paths may have backslash-escaped spaces, so also try - # unescaping. Walk forward absorbing "\ " sequences. - _raw = user_input - _pos = 0 - while _pos < len(_raw): - ch = _raw[_pos] - if ch == '\\' and _pos + 1 < len(_raw) and _raw[_pos + 1] == ' ': - _pos += 2 # skip escaped space - elif ch == ' ': - break - else: - _pos += 1 - _first_token_raw = _raw[:_pos] - _first_token = _first_token_raw.replace('\\ ', ' ') - _drop_path = Path(_first_token) - if _drop_path.exists() and _drop_path.is_file(): - _is_file_drop = True - _IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', - '.bmp', '.tiff', '.tif', '.svg', '.ico'} - _remainder = _raw[_pos:].strip() - if _drop_path.suffix.lower() in _IMAGE_EXTS: - submit_images.append(_drop_path) - user_input = _remainder or f"[User attached image: {_drop_path.name}]" - _cprint(f" šŸ“Ž Auto-attached image: {_drop_path.name}") - else: - # Non-image file — mention it in the chat message so - # the agent can read_file / analyze it. - _cprint(f" šŸ“„ Detected file: {_drop_path.name}") - user_input = ( - f"[User attached file: {_drop_path}]" - + (f"\n{_remainder}" if _remainder else "") - ) + # See _detect_file_drop() for details. + _file_drop = _detect_file_drop(user_input) if isinstance(user_input, str) else None + if _file_drop: + _drop_path = _file_drop["path"] + _remainder = _file_drop["remainder"] + if _file_drop["is_image"]: + submit_images.append(_drop_path) + user_input = _remainder or f"[User attached image: {_drop_path.name}]" + _cprint(f" šŸ“Ž Auto-attached image: {_drop_path.name}") + else: + _cprint(f" šŸ“„ Detected file: {_drop_path.name}") + user_input = ( + f"[User attached file: {_drop_path}]" + + (f"\n{_remainder}" if _remainder else "") + ) - if not _is_file_drop and isinstance(user_input, str) and user_input.startswith("/"): + if not _file_drop and isinstance(user_input, str) and user_input.startswith("/"): _cprint(f"\nāš™ļø {user_input}") if not self.process_command(user_input): self._should_exit = True diff --git a/tests/test_cli_file_drop.py b/tests/test_cli_file_drop.py new file mode 100644 index 000000000..386aba5d1 --- /dev/null +++ b/tests/test_cli_file_drop.py @@ -0,0 +1,176 @@ +"""Tests for _detect_file_drop — file path detection that prevents +dragged/pasted absolute paths from being mistaken for slash commands.""" + +import os +import tempfile +from pathlib import Path + +import pytest + +from cli import _detect_file_drop + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def tmp_image(tmp_path): + """Create a temporary .png file and return its path.""" + img = tmp_path / "screenshot.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n") # minimal PNG header + return img + + +@pytest.fixture() +def tmp_text(tmp_path): + """Create a temporary .py file and return its path.""" + f = tmp_path / "main.py" + f.write_text("print('hello')\n") + return f + + +@pytest.fixture() +def tmp_image_with_spaces(tmp_path): + """Create a file whose name contains spaces (like macOS screenshots).""" + img = tmp_path / "Screenshot 2026-04-01 at 7.25.32 PM.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n") + return img + + +# --------------------------------------------------------------------------- +# Tests: returns None for non-file inputs +# --------------------------------------------------------------------------- + +class TestNonFileInputs: + def test_regular_slash_command(self): + assert _detect_file_drop("/help") is None + + def test_unknown_slash_command(self): + assert _detect_file_drop("/xyz") is None + + def test_slash_command_with_args(self): + assert _detect_file_drop("/config set key value") is None + + def test_empty_string(self): + assert _detect_file_drop("") is None + + def test_non_slash_input(self): + assert _detect_file_drop("hello world") is None + + def test_non_string_input(self): + assert _detect_file_drop(42) is None + + def test_nonexistent_path(self): + assert _detect_file_drop("/nonexistent/path/to/file.png") is None + + def test_directory_not_file(self, tmp_path): + """A directory path should not be treated as a file drop.""" + assert _detect_file_drop(str(tmp_path)) is None + + +# --------------------------------------------------------------------------- +# Tests: image file detection +# --------------------------------------------------------------------------- + +class TestImageFileDrop: + def test_simple_image_path(self, tmp_image): + result = _detect_file_drop(str(tmp_image)) + assert result is not None + assert result["path"] == tmp_image + assert result["is_image"] is True + assert result["remainder"] == "" + + def test_image_with_trailing_text(self, tmp_image): + user_input = f"{tmp_image} analyze this please" + result = _detect_file_drop(user_input) + assert result is not None + assert result["path"] == tmp_image + assert result["is_image"] is True + assert result["remainder"] == "analyze this please" + + @pytest.mark.parametrize("ext", [".png", ".jpg", ".jpeg", ".gif", ".webp", + ".bmp", ".tiff", ".tif", ".svg", ".ico"]) + def test_all_image_extensions(self, tmp_path, ext): + img = tmp_path / f"test{ext}" + img.write_bytes(b"fake") + result = _detect_file_drop(str(img)) + assert result is not None + assert result["is_image"] is True + + def test_uppercase_extension(self, tmp_path): + img = tmp_path / "photo.JPG" + img.write_bytes(b"fake") + result = _detect_file_drop(str(img)) + assert result is not None + assert result["is_image"] is True + + +# --------------------------------------------------------------------------- +# Tests: non-image file detection +# --------------------------------------------------------------------------- + +class TestNonImageFileDrop: + def test_python_file(self, tmp_text): + result = _detect_file_drop(str(tmp_text)) + assert result is not None + assert result["path"] == tmp_text + assert result["is_image"] is False + assert result["remainder"] == "" + + def test_non_image_with_trailing_text(self, tmp_text): + user_input = f"{tmp_text} review this code" + result = _detect_file_drop(user_input) + assert result is not None + assert result["is_image"] is False + assert result["remainder"] == "review this code" + + +# --------------------------------------------------------------------------- +# Tests: backslash-escaped spaces (macOS drag-and-drop) +# --------------------------------------------------------------------------- + +class TestEscapedSpaces: + def test_escaped_spaces_in_path(self, tmp_image_with_spaces): + r"""macOS drags produce paths like /path/to/my\ file.png""" + escaped = str(tmp_image_with_spaces).replace(' ', '\\ ') + result = _detect_file_drop(escaped) + assert result is not None + assert result["path"] == tmp_image_with_spaces + assert result["is_image"] is True + + def test_escaped_spaces_with_trailing_text(self, tmp_image_with_spaces): + escaped = str(tmp_image_with_spaces).replace(' ', '\\ ') + user_input = f"{escaped} what is this?" + result = _detect_file_drop(user_input) + assert result is not None + assert result["path"] == tmp_image_with_spaces + assert result["remainder"] == "what is this?" + + +# --------------------------------------------------------------------------- +# Tests: edge cases +# --------------------------------------------------------------------------- + +class TestEdgeCases: + def test_path_with_no_extension(self, tmp_path): + f = tmp_path / "Makefile" + f.write_text("all:\n\techo hi\n") + result = _detect_file_drop(str(f)) + assert result is not None + assert result["is_image"] is False + + def test_path_that_looks_like_command_but_is_file(self, tmp_path): + """A file literally named 'help' inside a directory starting with /.""" + f = tmp_path / "help" + f.write_text("not a command\n") + result = _detect_file_drop(str(f)) + assert result is not None + assert result["is_image"] is False + + def test_symlink_to_file(self, tmp_image, tmp_path): + link = tmp_path / "link.png" + link.symlink_to(tmp_image) + result = _detect_file_drop(str(link)) + assert result is not None + assert result["is_image"] is True