Add the ability to read text from the system clipboard

This commit is contained in:
z00827015
2026-03-18 15:30:15 +01:00
committed by Jan Piotrowski
parent 2f1622579f
commit 1c6f4a2078
3 changed files with 172 additions and 2 deletions
+95 -1
View File
@@ -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
+21 -1
View File
@@ -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):
+56
View File
@@ -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()