diff --git a/cli.py b/cli.py index 17fae086e8..739a1b91ec 100644 --- a/cli.py +++ b/cli.py @@ -1203,6 +1203,11 @@ def _format_image_attachment_badges(attached_images: list[Path], image_counter: ) +def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool: + """Auto-attach clipboard images only for image-only paste gestures.""" + return not pasted_text.strip() + + def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]: """Collect local image attachments for single-query CLI flows.""" message = query or "" @@ -6282,6 +6287,9 @@ class HermesCLI: if result.get("success") and result.get("transcript", "").strip(): transcript = result["transcript"].strip() + self._attached_images.clear() + if hasattr(self, '_app') and self._app: + self._app.invalidate() self._pending_input.put(transcript) submitted = True elif result.get("success"): @@ -8006,8 +8014,9 @@ class HermesCLI: """Handle terminal paste — detect clipboard images. When the terminal supports bracketed paste, Ctrl+V / Cmd+V - triggers this with the pasted text. We also check the - clipboard for an image on every paste event. + triggers this with the pasted text. We only auto-attach a + clipboard image for image-only/empty paste gestures so text + pastes and dictation do not accidentally attach stale images. Large pastes (5+ lines) are collapsed to a file reference placeholder while preserving any existing user text in the @@ -8017,7 +8026,7 @@ class HermesCLI: # Normalise line endings — Windows \r\n and old Mac \r both become \n # so the 5-line collapse threshold and display are consistent. pasted_text = pasted_text.replace('\r\n', '\n').replace('\r', '\n') - if self._try_attach_clipboard_image(): + if _should_auto_attach_clipboard_image_on_paste(pasted_text) and self._try_attach_clipboard_image(): event.app.invalidate() if pasted_text: line_count = pasted_text.count('\n') diff --git a/tests/tools/test_clipboard.py b/tests/tools/test_clipboard.py index 82a4aa6faf..e8171fe1b9 100644 --- a/tests/tools/test_clipboard.py +++ b/tests/tools/test_clipboard.py @@ -35,6 +35,7 @@ from hermes_cli.clipboard import ( _windows_has_image, _convert_to_png, ) +from cli import _should_auto_attach_clipboard_image_on_paste FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 FAKE_BMP = b"BM" + b"\x00" * 100 @@ -919,6 +920,48 @@ class TestTryAttachClipboardImage: assert path.suffix == ".png" +class TestAutoAttachClipboardImageOnPaste: + def test_skips_auto_attach_for_plain_text_paste(self): + assert _should_auto_attach_clipboard_image_on_paste("hello world") is False + + def test_skips_auto_attach_for_whitespace_and_text_paste(self): + assert _should_auto_attach_clipboard_image_on_paste(" hello world ") is False + + def test_allows_auto_attach_for_empty_paste(self): + assert _should_auto_attach_clipboard_image_on_paste("") is True + + def test_allows_auto_attach_for_whitespace_only_paste(self): + assert _should_auto_attach_clipboard_image_on_paste(" \n\t ") is True + + +class TestVoiceSubmission: + @pytest.fixture + def cli(self): + from cli import HermesCLI + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj._attached_images = [Path("/tmp/stale.png")] + cli_obj._pending_input = queue.Queue() + cli_obj._voice_lock = MagicMock() + cli_obj._voice_processing = True + cli_obj._voice_recording = True + cli_obj._voice_continuous = False + cli_obj._no_speech_count = 0 + cli_obj._voice_recorder = MagicMock() + cli_obj._voice_recorder.stop.return_value = "/tmp/fake.wav" + cli_obj._app = None + return cli_obj + + def test_voice_transcript_clears_stale_attached_images(self, cli): + with patch("tools.voice_mode.play_beep"): + with patch("tools.voice_mode.transcribe_recording", return_value={"success": True, "transcript": "hello"}): + with patch("os.path.isfile", return_value=False): + with patch("cli._cprint"): + cli._voice_stop_and_transcribe() + + assert cli._attached_images == [] + assert cli._pending_input.get_nowait() == "hello" + + # ═════════════════════════════════════════════════════════════════════════ # Level 4: Queue routing — tuple unpacking in process_loop # ═════════════════════════════════════════════════════════════════════════