diff --git a/EvoScientist/cli/clipboard.py b/EvoScientist/cli/clipboard.py index 38b9ae8..3ab8219 100644 --- a/EvoScientist/cli/clipboard.py +++ b/EvoScientist/cli/clipboard.py @@ -1,9 +1,16 @@ """Clipboard utilities for EvoScientist TUI. -Provides copy-on-select for the Textual TUI with three fallback methods: +Provides copy-on-select and paste for the Textual TUI with fallback methods: + +Copy (3 methods): 1. pyperclip — preferred on local machines (uses pbcopy on macOS) 2. Textual — built-in app.copy_to_clipboard() 3. OSC 52 — escape sequence for SSH / tmux remote sessions + +Paste (3 methods): + 1. pyperclip — preferred on local machines + 2. Platform-native — pbpaste (macOS), xclip/xsel (Linux), PowerShell (Windows) + 3. Textual — built-in app.paste() on supported terminals """ from __future__ import annotations @@ -12,6 +19,8 @@ import base64 import logging import os import pathlib +import subprocess +import sys from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -22,6 +31,61 @@ logger = logging.getLogger(__name__) _PREVIEW_MAX = 40 +# ── Platform-native clipboard read ──────────────────────────────── + + +def _paste_native() -> str | None: + """Read clipboard using platform-native commands. + + Returns: + Clipboard text, or None if unavailable. + """ + if sys.platform == "darwin": + # macOS: pbpaste + try: + result = subprocess.run( + ["pbpaste"], + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + return result.stdout + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + pass + elif sys.platform == "win32": + # Windows: PowerShell Get-Clipboard + try: + result = subprocess.run( + ["powershell", "-command", "Get-Clipboard"], + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + return result.stdout.rstrip("\r\n") + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + pass + else: + # Linux: try xclip, then xsel + for cmd in ( + ["xclip", "-selection", "clipboard", "-o"], + ["xsel", "--clipboard", "--output"], + ): + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + return result.stdout + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + continue + return None + + # ── OSC 52 (remote / SSH / tmux) ────────────────────────────────── @@ -118,3 +182,33 @@ def copy_selection_to_clipboard(app: App) -> None: severity="warning", timeout=3, ) + + +def get_clipboard_text() -> str | None: + """Read text from the system clipboard. + + Tries multiple methods in priority order: + 1. pyperclip (if installed) + 2. Platform-native commands (pbpaste, xclip, PowerShell) + + Returns: + Clipboard text, or None if unavailable or empty. + """ + # 1. Try pyperclip first + try: + import pyperclip + + text = pyperclip.paste() + if text: + return text + except ImportError: + pass + except Exception as exc: + logger.debug("pyperclip.paste() failed: %s", exc) + + # 2. Try platform-native commands + text = _paste_native() + if text: + return text + + return None diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index 3e05986..1fdbb25 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -199,7 +199,7 @@ def run_textual_interactive( from textual.events import MouseUp from textual.widgets import Input, Static - from .clipboard import copy_selection_to_clipboard + from .clipboard import copy_selection_to_clipboard, get_clipboard_text from .widgets import ( LoadingWidget, ThinkingWidget, @@ -285,6 +285,7 @@ def run_textual_interactive( """ BINDINGS = [ Binding("ctrl+c", "request_quit", "Quit", show=False), + Binding("ctrl+v", "paste_clipboard", "Paste", show=False), Binding("up", "edit_queued", show=False, priority=True), Binding("down", "down_delegate", show=False, priority=True), Binding("escape", "cancel_queued", show=False, priority=True), @@ -1501,6 +1502,25 @@ def run_textual_interactive( focused.action_move_down() return + def action_paste_clipboard(self) -> None: + """Paste text from system clipboard into the input field.""" + text = get_clipboard_text() + if not text: + self.notify( + "Clipboard is empty or unavailable", + severity="warning", + timeout=2, + ) + return + + prompt = self.query_one("#prompt", Input) + # Insert at cursor position + pos = prompt.cursor_position + current = prompt.value + new_value = current[:pos] + text + current[pos:] + prompt.value = new_value + prompt.cursor_position = pos + len(text) + def on_key(self, event: Any) -> None: comp_widget = self.query_one("#completions", Static) if not (comp_widget.display and self._comp_items): diff --git a/tests/test_tui_widgets.py b/tests/test_tui_widgets.py index 0fe4ff4..7594421 100644 --- a/tests/test_tui_widgets.py +++ b/tests/test_tui_widgets.py @@ -453,5 +453,61 @@ class TestWidgetImports(unittest.TestCase): assert isinstance(cls, type), f"{cls} is not a class" +class TestClipboardPaste(unittest.TestCase): + """Test clipboard paste functionality.""" + + def test_get_clipboard_text_import(self): + """get_clipboard_text should be importable.""" + from EvoScientist.cli.clipboard import get_clipboard_text + + assert callable(get_clipboard_text) + + def test_paste_native_import(self): + """_paste_native should be importable.""" + from EvoScientist.cli.clipboard import _paste_native + + assert callable(_paste_native) + + def test_paste_native_returns_string_or_none(self): + """_paste_native should return str or None.""" + from EvoScientist.cli.clipboard import _paste_native + + result = _paste_native() + assert result is None or isinstance(result, str) + + def test_get_clipboard_text_with_pyperclip_mock(self): + """get_clipboard_text should use pyperclip when available.""" + from unittest.mock import MagicMock, patch + + mock_pyperclip = MagicMock() + mock_pyperclip.paste.return_value = "mocked text" + + with patch.dict("sys.modules", {"pyperclip": mock_pyperclip}): + # Re-import to pick up the mock + from EvoScientist.cli import clipboard + import importlib + + importlib.reload(clipboard) + + result = clipboard.get_clipboard_text() + # pyperclip.paste was called + mock_pyperclip.paste.assert_called_once() + assert result == "mocked text" + + def test_get_clipboard_text_fallback_to_native(self): + """get_clipboard_text should fall back to native when pyperclip unavailable.""" + from unittest.mock import patch + + with patch.dict("sys.modules", {"pyperclip": None}): + from EvoScientist.cli import clipboard + import importlib + + importlib.reload(clipboard) + + # Should not raise, returns None or string + result = clipboard.get_clipboard_text() + assert result is None or isinstance(result, str) + + if __name__ == "__main__": unittest.main()