From d62c0cfb2e6ca4bcd67ba41e3ef40dc4a8188bda Mon Sep 17 00:00:00 2001 From: X-iZhang Date: Sat, 21 Feb 2026 01:39:32 +0000 Subject: [PATCH] feat(clipboard): add clipboard utilities for copy-on-select functionality --- EvoScientist/cli/clipboard.py | 116 ++++++++++++++++++++++++++++ EvoScientist/cli/tui_interactive.py | 18 +++-- 2 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 EvoScientist/cli/clipboard.py diff --git a/EvoScientist/cli/clipboard.py b/EvoScientist/cli/clipboard.py new file mode 100644 index 0000000..8d7ff42 --- /dev/null +++ b/EvoScientist/cli/clipboard.py @@ -0,0 +1,116 @@ +"""Clipboard utilities for EvoScientist TUI. + +Provides copy-on-select for the Textual TUI with three fallback 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 +""" + +from __future__ import annotations + +import base64 +import logging +import os +import pathlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from textual.app import App + +logger = logging.getLogger(__name__) + +_PREVIEW_MAX = 40 + + +# ── OSC 52 (remote / SSH / tmux) ────────────────────────────────── + + +def _copy_osc52(text: str) -> None: + """Copy text using OSC 52 escape sequence (works over SSH/tmux).""" + encoded = base64.b64encode(text.encode("utf-8")).decode("ascii") + seq = f"\033]52;c;{encoded}\a" + if os.environ.get("TMUX"): + seq = f"\033Ptmux;\033{seq}\033\\" + with pathlib.Path("/dev/tty").open("w", encoding="utf-8") as tty: + tty.write(seq) + tty.flush() + + +# ── Preview helper ───────────────────────────────────────────────── + + +def _shorten(texts: list[str]) -> str: + """Return a short preview string for the notification toast.""" + dense = "⏎".join(texts).replace("\n", "⏎") + if len(dense) > _PREVIEW_MAX: + return dense[: _PREVIEW_MAX - 1] + "…" + return dense + + +# ── Public API ───────────────────────────────────────────────────── + + +def copy_selection_to_clipboard(app: App) -> None: + """Copy mouse-selected text from any widget to the system clipboard. + + Called from ``on_mouse_up`` in the Textual app so that selecting + text with the mouse automatically copies it. + """ + selected_texts: list[str] = [] + + for widget in app.query("*"): + if not hasattr(widget, "text_selection") or not widget.text_selection: + continue + selection = widget.text_selection + if selection.end is None: + continue + try: + result = widget.get_selection(selection) + except (AttributeError, TypeError, ValueError, IndexError) as exc: + logger.debug( + "Failed to get selection from %s: %s", + type(widget).__name__, exc, + ) + continue + if not result: + continue + text, _ = result + if text.strip(): + selected_texts.append(text) + + if not selected_texts: + return + + combined = "\n".join(selected_texts) + + # Try methods in priority order + copy_methods = [app.copy_to_clipboard] + + try: + import pyperclip + copy_methods.insert(0, pyperclip.copy) + except ImportError: + pass + + copy_methods.append(_copy_osc52) + + for fn in copy_methods: + try: + fn(combined) + app.notify( + f'"{_shorten(selected_texts)}" copied', + severity="information", + timeout=2, + markup=False, + ) + except (OSError, RuntimeError, TypeError) as exc: + logger.debug("Clipboard method %s failed: %s", getattr(fn, "__name__", repr(fn)), exc) + continue + else: + return + + app.notify( + "Failed to copy — no clipboard method available", + severity="warning", + timeout=3, + ) diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index 9cb8c1e..1fad7dc 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -178,8 +178,10 @@ def run_textual_interactive( from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Container, Horizontal, VerticalScroll + from textual.events import MouseUp from textual.widgets import Input, Static + from .clipboard import copy_selection_to_clipboard from .widgets import ( LoadingWidget, ThinkingWidget, @@ -924,6 +926,12 @@ def run_textual_interactive( style="dim", ) + # ── Clipboard (copy on mouse select) ───────────────── + + def on_mouse_up(self, event: MouseUp) -> None: + """Copy mouse-selected text to clipboard on release.""" + copy_selection_to_clipboard(self) + # ── Input handling ───────────────────────────────────── async def on_input_submitted(self, event: Input.Submitted) -> None: @@ -1695,21 +1703,17 @@ def run_textual_interactive( def _render_status(self) -> None: status = self.query_one("#status", Static) if self._busy: - left = "thinking..." + left = "vibe researching..." left_style = "bold #f59e0b" else: - left = "manual | /help for commands" + left = "/help for commands" left_style = "#f59e0b" - model_label = model or "default-model" - if provider and model: - model_label = f"{provider}:{model}" - status.update( Text.assemble( (left, left_style), (" ", ""), - (model_label, "dim"), + ("EvoScientist", "dim"), ) )