feat(clipboard): add clipboard utilities for copy-on-select functionality
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user