From 422647e7898b8df67cd11f8fb060aec23dec84aa Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 14 Oct 2026 22:20:15 +0200 Subject: feat(cli): meshbay-cli, a command-line client for members Signs in to the hub, lists the account's groups and their folders, and downloads and uploads files directly with each group's node, end-to-end encrypted. With no argument it opens a two-pane interactive mode (Textual). Session and device keys are kept encrypted under the system's key storage, or a 0600 key file where there is none; node keys are pinned at first contact; a new machine links as a device. Packaged on its own on Linux (deb and rpm, on meshbay-common's venv, with a man page generated from the parser) and frozen next to meshbay-node in the full Windows installer, not the light one, with a self-test the build runs. client/keys.py ports keyderive.js and is LGPL like it. Co-Authored-By: Claude Opus 5.5 --- .../meshbay-cli/src/meshbay_cli/tui/widgets.py | 109 +++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 packages/meshbay-cli/src/meshbay_cli/tui/widgets.py (limited to 'packages/meshbay-cli/src/meshbay_cli/tui/widgets.py') diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/widgets.py b/packages/meshbay-cli/src/meshbay_cli/tui/widgets.py new file mode 100644 index 0000000..66fca4e --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/tui/widgets.py @@ -0,0 +1,109 @@ +"""A question with a few answers, and the strip that shows transfers.""" + +import time +from dataclasses import dataclass + +from rich.text import Text +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Static + +from ..output import human_size, safe + + +class Choice(ModalScreen[str]): + """One question, asked once for a whole batch — never once per file.""" + + DEFAULT_CSS = """ + Choice { align: center middle; } + Choice > Vertical { width: 72; height: auto; max-height: 80%; + border: thick $accent; background: $surface; padding: 1 2; } + Choice Static { height: auto; margin-bottom: 1; } + Choice Horizontal { height: auto; align: center middle; } + Choice Button { margin: 0 1; } + """ + BINDINGS = [("escape", "dismiss('cancel')", "Cancel")] + + def __init__(self, message: str, options: list[tuple[str, str]]): + super().__init__() + self.message = message + self.options = options + + def compose(self) -> ComposeResult: + with Vertical(): + yield Static(Text(self.message)) + with Horizontal(): + for value, label in self.options: + yield Button(label, id=f"choice-{value}") + + def on_mount(self) -> None: + self.query(Button).first().focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(event.button.id.removeprefix("choice-")) + + +@dataclass +class Job: + arrow: str + name: str + size: int + done: int = 0 + state: str = "waiting" # waiting, queued, running, done, failed + note: str = "" + started: float = 0.0 + + +class TransferStrip(Static): + """The running transfers, then the latest finished ones.""" + + DEFAULT_CSS = """ + TransferStrip { height: 6; border-top: solid $panel; padding: 0 1; } + """ + SHOWN = 5 + + def __init__(self, **kwargs): + super().__init__("", markup=False, **kwargs) + self.jobs: list[Job] = [] + + def on_mount(self) -> None: + self.refresh_text() + + def add(self, arrow: str, name: str, size: int) -> Job: + job = Job(arrow, name, size) + self.jobs.append(job) + self.refresh_text() + return job + + def update_job(self, job: Job, **fields) -> None: + for k, v in fields.items(): + setattr(job, k, v) + if job.state == "running" and not job.started: + job.started = time.monotonic() + self.refresh_text() + + def refresh_text(self) -> None: + active = [j for j in self.jobs if j.state in ("running", "queued", "waiting")] + ended = [j for j in self.jobs if j.state in ("done", "failed")] + lines = [self._line(j) for j in active[:self.SHOWN]] + if len(active) > self.SHOWN: + lines.append(f" … and {len(active) - self.SHOWN} more waiting") + for j in reversed(ended[-(self.SHOWN - len(lines)):] if len(lines) < self.SHOWN + else []): + lines.append(self._line(j)) + self.update(Text("\n".join(lines) or "No transfers.")) + + def _line(self, j: Job) -> str: + name = safe(j.name) + if j.state == "running": + pct = 100 * j.done // j.size if j.size else 100 + rate = j.done / max(time.monotonic() - j.started, 0.001) + return f"{j.arrow} {name} {pct}% {human_size(int(rate))}/s" + if j.state == "queued": + return f"{j.arrow} {name} waiting for a free slot on the node {j.note}" + if j.state == "waiting": + return f"{j.arrow} {name} waiting" + if j.state == "done": + return f"{j.arrow} {name} done{(' — ' + j.note) if j.note else ''}" + return f"{j.arrow} {name} FAILED: {j.note}" -- cgit v1.2.3