From 5044bdfdfffceceecc0d92a776983ad361079839 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 09:33:28 +0200 Subject: 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 --- packages/meshbay-cli/tests/test_cli_actions.py | 13 +++++ packages/meshbay-cli/tests/test_clipboard.py | 58 +++++++++++++++++++ packages/meshbay-cli/tests/test_interactive.py | 79 ++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 packages/meshbay-cli/tests/test_clipboard.py (limited to 'packages/meshbay-cli/tests') diff --git a/packages/meshbay-cli/tests/test_cli_actions.py b/packages/meshbay-cli/tests/test_cli_actions.py index 5443d9b..240ac67 100644 --- a/packages/meshbay-cli/tests/test_cli_actions.py +++ b/packages/meshbay-cli/tests/test_cli_actions.py @@ -203,6 +203,19 @@ def test_a_usage_error_is_exit_1_not_the_sign_in_code(capsys): +def test_help_prints_what_help_prints(capsys): + assert cli.main(["--help"]) == 0 + whole = capsys.readouterr().out + assert main("help") == 0 + assert capsys.readouterr().out == whole + assert cli.main(["chat", "--help"]) == 0 + chat = capsys.readouterr().out + assert main("help", "chat") == 0 + assert capsys.readouterr().out == chat + assert main("help", "no-such-action") == 1 + assert "No action 'no-such-action'" in capsys.readouterr().err + + def test_groups_lists_each_group_with_its_state(hub, capsys): hub.groups.append({"id": "g2", "name": "Club/Photos", "owner_username": "bob-test", "node_online": False, "visibility": "public", "is_admin": False, diff --git a/packages/meshbay-cli/tests/test_clipboard.py b/packages/meshbay-cli/tests/test_clipboard.py new file mode 100644 index 0000000..6296317 --- /dev/null +++ b/packages/meshbay-cli/tests/test_clipboard.py @@ -0,0 +1,58 @@ +""" +The chat's clipboard: the platform's tools, run for real, and nothing when none +works. Fake `wl-copy`/`wl-paste` stand in for wl-clipboard, sharing one file. +""" + +import shutil +import sys +import time + +import pytest +from meshbay_cli.tui import clipboard + +pytestmark = pytest.mark.skipif(sys.platform in ("win32", "darwin"), + reason="the Wayland and X11 tools") +# Found before a test leaves only the fake tools on PATH. +CAT, SLEEP = shutil.which("cat"), shutil.which("sleep") + + +@pytest.fixture +def wayland(tmp_path, monkeypatch): + held = tmp_path / "held" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + for name, body in (("wl-copy", f'{CAT} > "{held}"'), ("wl-paste", f'{CAT} "{held}"')): + tool = bin_dir / name + tool.write_text(f"#!/bin/sh\n{body}\n") + tool.chmod(0o755) + monkeypatch.setenv("PATH", str(bin_dir)) + monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") + monkeypatch.delenv("DISPLAY", raising=False) + return held + + +async def test_what_is_copied_is_what_is_pasted(wayland): + assert await clipboard.write_text("héllo — 你好\nsecond") is True + assert wayland.read_text(encoding="utf-8") == "héllo — 你好\nsecond" + assert await clipboard.read_text() == "héllo — 你好\nsecond" + + +async def test_no_tool_is_none_not_an_error(tmp_path, monkeypatch): + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") + monkeypatch.setenv("DISPLAY", ":0") + assert await clipboard.read_text() is None + assert await clipboard.write_text("x") is False + + +async def test_a_failing_tool_is_skipped(wayland): + (wayland.parent / "bin" / "wl-paste").write_text("#!/bin/sh\nexit 1\n") + assert await clipboard.read_text() is None + + +async def test_a_hanging_tool_is_given_up(wayland, monkeypatch): + (wayland.parent / "bin" / "wl-paste").write_text(f"#!/bin/sh\nexec {SLEEP} 30\n") + monkeypatch.setattr(clipboard, "TIMEOUT", 0.3) + started = time.monotonic() + assert await clipboard.read_text() is None + assert 0.3 <= time.monotonic() - started < 5 diff --git a/packages/meshbay-cli/tests/test_interactive.py b/packages/meshbay-cli/tests/test_interactive.py index fa7e96c..5b9d9b0 100644 --- a/packages/meshbay-cli/tests/test_interactive.py +++ b/packages/meshbay-cli/tests/test_interactive.py @@ -208,6 +208,17 @@ async def test_delete_removes_what_is_yours_and_asks_first(world): assert any("select them on the right" in n.message for n in app._notifications) +async def _right_click(pilot, widget, offset=(0, 0)) -> None: + """Through App.on_event, as the terminal driver sends it; the pilot's own + clicks skip it.""" + from textual import events + from textual.pilot import _get_mouse_message_arguments + args = _get_mouse_message_arguments(widget, offset, button=3) + for cls in (events.MouseDown, events.MouseUp): + pilot.app.post_message(cls(**args)) + await pilot.pause() + + def _log_text(screen) -> str: from textual.widgets import RichLog return "\n".join("".join(seg.text for seg in strip) @@ -285,3 +296,71 @@ async def test_the_interactive_chat_joins_talks_leaves_and_comes_back(world, mon await _until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen)) await pilot.press("q") assert app.return_code in (0, None) + + +async def test_a_right_click_copies_from_the_chat_and_pastes_into_the_line(world, monkeypatch): + """The selection, else the line under the pointer; pasted as one line.""" + from meshbay_cli.tui import clipboard + from meshbay_cli.tui.chat import ChatGroupsScreen, ChatInput, ChatLog, ChatRoomScreen + hub, node, _ = world + written, held = [], {"text": None} + + async def write_text(text): + written.append(text) + return True + + async def read_text(): + return held["text"] + monkeypatch.setattr(clipboard, "write_text", write_text) + monkeypatch.setattr(clipboard, "read_text", read_text) + node.say("earlier, from bob") + assert await asyncio.to_thread(cli.main, ["--hub", "https://hub.test", "-u", USERNAME, + "--password", PASSPHRASE, "login"]) == 0 + + app = MeshBayApp(context.Context(cli.build_parser().parse_args( + ["--hub", "https://hub.test"])), ChatGroupsScreen) + async with app.run_test(size=(120, 30)) as pilot: + await _until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen)) + await pilot.pause(0.3) + await pilot.press("enter") + await _until(pilot, lambda: isinstance(app.screen, ChatRoomScreen)) + room = app.screen + log = room.query_one(ChatLog) + await _until(pilot, lambda: " earlier, from bob" in _log_text(room)) + y = next(i for i, line in enumerate(log.lines) if "earlier, from bob" in line.text) + line = log.lines[y].text + + # Nothing selected: the line under the pointer. + await _right_click(pilot, log, (4, y)) + await _until(pilot, lambda: written) + assert written[-1] == line.rstrip() and app.clipboard == written[-1] + assert " earlier, from bob" in written[-1] + + # Selected with the mouse: that text, and only it. + x = line.index("bob-test") + await pilot.mouse_down(log, offset=(1 + x, y)) + await pilot.hover(log, offset=(x + len("bob-test"), y)) + await pilot.mouse_up(log, offset=(x + len("bob-test"), y)) + await pilot.pause(0.1) + assert log.text_selection is not None + await _right_click(pilot, log, (1, y + 3)) + await _until(pilot, lambda: len(written) == 2) + assert written[-1] == "bob-test" + + # Pasted into the line written in, at the cursor, lines side by side. + field = room.query_one(ChatInput) + field.value = "say: " + field.cursor_position = len(field.value) + held["text"] = "first line\nsecond line\n" + await _right_click(pilot, field, (2, 0)) + await _until(pilot, lambda: field.value != "say: ") + assert field.value == "say: first line second line" + + # No tool to read the system clipboard: what was copied here. + held["text"] = None + field.value = "" + await _right_click(pilot, field, (2, 0)) + await _until(pilot, lambda: field.value) + assert field.value == "bob-test" + assert app.screen is room # a right-click left nothing behind + await pilot.press("escape") -- cgit v1.2.3