aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
commit5044bdfdfffceceecc0d92a776983ad361079839 (patch)
tree4a80ab66ef53f9ba76cdf8db4276dfc07d0fc1f7 /packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py
parent1e8eae1f3de765fcd8163716de6e5fe5aab5a034 (diff)
downloadmeshbay-5044bdfdfffceceecc0d92a776983ad361079839.tar.gz
feat(cli): help, and right-click copy and paste in the interactive chat
help prints what --help prints; help ACTION, what ACTION --help prints. In the interactive chat a right-click on the conversation copies the text selected with the mouse, else the line under the pointer: to the terminal (OSC 52) and to the platform's clipboard tool. A right-click on the line written in pastes the system clipboard's text at the cursor, its lines side by side, read with wl-paste, xclip or xsel, pbpaste or PowerShell, else what was copied here last. The conversation is selectable with the mouse, which RichLog alone is not. The deb and rpm packages recommend wl-clipboard or xclip for the paste. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py76
1 files changed, 76 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py b/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py
new file mode 100644
index 0000000..1a1f0d7
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py
@@ -0,0 +1,76 @@
+"""
+The system clipboard, for the chat's right-click copy and paste.
+
+A copy goes to the terminal (OSC 52, through Textual), which reaches the
+clipboard of the machine in front of the person even over SSH, and to the
+platform's own tool too, for terminals that ignore OSC 52. A paste has no
+terminal escape to rely on: the text is read with the platform's tool, else it
+is what this program copied last. A tool that is missing, fails or hangs is
+skipped, never an error.
+"""
+
+import asyncio
+import os
+import shutil
+import sys
+
+TIMEOUT = 3.0
+_PS = ["powershell", "-NoProfile", "-NonInteractive", "-Command"]
+
+
+def _tools(read: bool) -> list[list[str]]:
+ if sys.platform == "win32":
+ return [_PS + ["[Console]::OutputEncoding = [Text.Encoding]::UTF8; Get-Clipboard -Raw"]
+ if read else ["clip"]]
+ if sys.platform == "darwin":
+ return [["pbpaste"] if read else ["pbcopy"]]
+ tools = []
+ if os.environ.get("WAYLAND_DISPLAY"):
+ tools.append(["wl-paste", "--no-newline"] if read else ["wl-copy"])
+ if os.environ.get("DISPLAY"):
+ tools.append(["xclip", "-selection", "clipboard", "-o"] if read
+ else ["xclip", "-selection", "clipboard", "-i"])
+ tools.append(["xsel", "--clipboard", "--output"] if read
+ else ["xsel", "--clipboard", "--input"])
+ return tools
+
+
+def _encode(text: str) -> bytes:
+ # clip.exe reads the console's code page unless the text says it is UTF-16.
+ return ("" + text).encode("utf-16-le") if sys.platform == "win32" else text.encode()
+
+
+async def _run(cmd: list[str], data: bytes | None) -> bytes | None:
+ if shutil.which(cmd[0]) is None:
+ return None
+ env = {**os.environ, "LC_CTYPE": "UTF-8"} if sys.platform == "darwin" else None
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ *cmd, stdin=asyncio.subprocess.PIPE if data is not None else asyncio.subprocess.DEVNULL,
+ stdout=asyncio.subprocess.PIPE if data is None else asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.DEVNULL, env=env)
+ except OSError:
+ return None
+ try:
+ out, _ = await asyncio.wait_for(proc.communicate(data), TIMEOUT)
+ except TimeoutError:
+ proc.kill()
+ await proc.wait()
+ return None
+ return (out or b"") if proc.returncode == 0 else None
+
+
+async def read_text() -> str | None:
+ """The system clipboard's text, or None when no tool could read it."""
+ for cmd in _tools(read=True):
+ out = await _run(cmd, None)
+ if out is not None:
+ return out.decode("utf-8", "replace").lstrip("")
+ return None
+
+
+async def write_text(text: str) -> bool:
+ for cmd in _tools(read=False):
+ if await _run(cmd, _encode(text)) is not None:
+ return True
+ return False