Add the ability to read text from the system clipboard
This commit is contained in:
committed by
Jan Piotrowski
parent
2f1622579f
commit
1c6f4a2078
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user