aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/tui/chat.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 09:57:18 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 09:57:18 +0200
commit2f90f4b60cd50f7e9649daaf8e76a120e608cff6 (patch)
tree9d3724602959b05602f7fadb8a45bd26d0fc9b91 /packages/meshbay-cli/src/meshbay_cli/tui/chat.py
parent5044bdfdfffceceecc0d92a776983ad361079839 (diff)
downloadmeshbay-2f90f4b60cd50f7e9649daaf8e76a120e608cff6.tar.gz
feat(cli): /put and /get in the interactive chat
/put FILE sends one local file, relative to the current directory or absolute, into the chat's folder, then posts the application's attachment message naming it ({"text": "", "attachment": {filename, size, type}}), so the page shows it as its paperclip's. /get NAME saves a file of that folder, named from it, into the current directory, never over a file already there. The chat's folder is the page's rule: the operator's choice while its root is writable and present, else the first such root; a chat_directory_ack moves it. A line above the one written in shows the transfer's progress. Attachment messages, from the page or from /put, read as [file: name, size] in the room and in `chat`, and as an `attachment` field with --json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/tui/chat.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/chat.py178
1 files changed, 173 insertions, 5 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py
index 0717141..e4ea1fe 100644
--- a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py
+++ b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py
@@ -7,14 +7,19 @@ shows the last messages, then the ones that arrive (`chat_msg` pushed by the
node), and a line at the bottom to write in. Esc, or `/part`, goes back to the
list; Ctrl+Q, or `/quit`, quits. A right-click copies — the text selected with
the mouse, else the line under the pointer — and pastes into the line written
-in. Messages are read and posted exactly as the
+in. `/put FILE` sends one local file (relative to the current directory, or
+absolute) into the chat's folder and names it in the conversation, as the
+application's paperclip does; `/get NAME` saves a file of that folder, named
+from it, into the current directory. Messages are read and posted exactly as the
`chat` action reads and posts them — signature, epoch key, content, then the
roster's verdict on the signing device — and drawn as text, never markup.
"""
import asyncio
import contextlib
+import os
import time
+from pathlib import Path
from rich.segment import Segment
from rich.text import Text
@@ -27,19 +32,30 @@ from textual.strip import Strip
from textual.widgets import DataTable, Footer, Input, RichLog, Static
from ..client.account import Account
-from ..client.chat import Message, chat_keys, history, open_row, post
+from ..client.chat import (
+ Message,
+ attachment_text,
+ chat_folder,
+ chat_keys,
+ history,
+ open_row,
+ post,
+)
from ..client.groups import my_groups
from ..client.paths import handle
from ..client.pool import Pool
from ..client.roster import RosterCheck
+from ..client.transfers import PART_SUFFIX, download, upload
+from ..client.tree import Tree
+from ..client.upload_plan import refused_names
from ..errors import CliError
-from ..output import safe
+from ..output import human_size, safe
from . import clipboard
# How much of the conversation a room opens on.
BACKLOG = 50
-HELP = ("/part or Esc: back to the groups /quit or Ctrl+Q: quit "
- "right-click: copy, paste /help: this")
+HELP = ("/put FILE: send a file /get NAME: save one here /part or Esc: back "
+ "/quit or Ctrl+Q: quit right-click: copy, paste /help: this")
# Under a message's first line, its next ones and what is said about it.
INDENT = " " * 6
KEY_CHANGED = ("! This account is using a key you have not seen before: a new device "
@@ -203,6 +219,7 @@ class ChatRoomScreen(Screen):
DEFAULT_CSS = """
ChatRoomScreen > .title { height: 1; padding: 0 1; background: $panel; }
ChatRoomScreen > ChatLog { height: 1fr; padding: 0 1; }
+ ChatRoomScreen > .transfer { height: 1; padding: 0 1; display: none; }
ChatRoomScreen > ChatInput { dock: bottom; }
"""
@@ -215,11 +232,13 @@ class ChatRoomScreen(Screen):
self.check = RosterCheck(account, session)
self._day = ""
self._ready = asyncio.Event()
+ self._transferring = False
def compose(self) -> ComposeResult:
yield Static(Text(f"#{safe(handle(self.group))} — {HELP}", no_wrap=True,
overflow="ellipsis"), classes="title")
yield ChatLog(wrap=True, markup=False, highlight=False, auto_scroll=True)
+ yield Static("", classes="transfer", markup=False)
yield ChatInput(placeholder=f"Message to {safe(handle(self.group))}",
select_on_focus=False)
yield Footer()
@@ -232,11 +251,13 @@ class ChatRoomScreen(Screen):
self.query_one(Input).focus()
self.session.ch.on_push("chat_msg", self._pushed)
self.session.ch.on_push("chat_epoch_ack", self._epoch_moved)
+ self.session.ch.on_push("chat_directory_ack", self._folder_moved)
self.run_worker(self._open(), group="room")
def on_unmount(self) -> None:
self.session.ch.off_push("chat_msg", self._pushed)
self.session.ch.off_push("chat_epoch_ack", self._epoch_moved)
+ self.session.ch.off_push("chat_directory_ack", self._folder_moved)
async def _open(self) -> None:
try:
@@ -267,6 +288,11 @@ class ChatRoomScreen(Screen):
if m.unreadable:
line.append(f"[this message cannot be read: {m.unreadable}]", style="italic")
else:
+ if m.attachment:
+ line.append(f"[file: {safe(m.attachment['filename'])}, "
+ f"{human_size(m.attachment['size'])}]", style="cyan")
+ if m.text:
+ line.append("\n" + INDENT)
line.append(("\n" + INDENT).join(safe(part) for part in m.text.split("\n")))
self.log_view.write(line)
if m.trust == "changed":
@@ -297,6 +323,10 @@ class ChatRoomScreen(Screen):
with contextlib.suppress(CliError):
self.keys = await chat_keys(self.session)
+ def _folder_moved(self, msg: dict) -> None:
+ """The operator chose another folder for the chat's files."""
+ self.session.ack["chat_directory"] = str(msg.get("path") or "")
+
# ── Writing ──────────────────────────────────────────────────────────────
async def on_input_submitted(self, event: Input.Submitted) -> None:
@@ -314,6 +344,19 @@ class ChatRoomScreen(Screen):
if command == "/help":
self.note(HELP)
return
+ verb, _, arg = text.strip().partition(" ")
+ if verb.lower() in ("/put", "/get"):
+ arg = _unquote(arg.strip())
+ if not arg:
+ self.note(f"{verb.lower()} FILE: " + ("the local file to send."
+ if verb.lower() == "/put" else "the file to save, named from "
+ "the chat's folder."))
+ elif self._transferring:
+ self.note("! A file is on its way already; wait for it.", style="bold red")
+ else:
+ self.run_worker(self._put(arg) if verb.lower() == "/put" else self._get(arg),
+ group="transfer")
+ return
if command.startswith("/") and not command.startswith("//"):
self.note(f"Unknown command {safe(text.split()[0])}. {HELP}")
return
@@ -330,3 +373,128 @@ class ChatRoomScreen(Screen):
def action_part(self) -> None:
self.app.pop_screen()
+
+ # ── Files ────────────────────────────────────────────────────────────────
+
+ async def _folder(self) -> tuple[Tree, str] | None:
+ tree = Tree.from_index(await self.session.index())
+ folder = chat_folder(self.session.ack, tree.roots)
+ if not folder:
+ self.note("! This group has no folder for the chat's files: every one is "
+ "read-only or unavailable.", style="bold red")
+ return None
+ return tree, folder
+
+ def _progress(self, arrow: str, name: str, size: int):
+ bar = self.query_one(".transfer", Static)
+ done = [0]
+ started = time.monotonic()
+
+ def advance(n: int) -> None:
+ done[0] += n
+ rate = done[0] / max(time.monotonic() - started, 0.001)
+ pct = 100 * done[0] // size if size else 100
+ bar.update(Text(f"{arrow} {safe(name)} {pct}% {human_size(int(rate))}/s",
+ no_wrap=True, overflow="ellipsis"))
+
+ def queued(n: int) -> None:
+ bar.update(Text(f"{arrow} {safe(name)} waiting for a free slot on the node"
+ + (f" ({n} ahead)" if n else ""), no_wrap=True))
+ bar.update(Text(f"{arrow} {safe(name)}", no_wrap=True))
+ bar.display = True
+ return advance, queued
+
+ def _transfer_ended(self) -> None:
+ self._transferring = False
+ bar = self.query_one(".transfer", Static)
+ bar.display = False
+ bar.update("")
+
+ async def _put(self, arg: str) -> None:
+ path = Path(os.path.abspath(os.path.expanduser(arg)))
+ if not path.is_file():
+ self.note(f"! {safe(arg)}: " + ("a folder; /put sends one file."
+ if path.is_dir() else "no such file."), style="bold red")
+ return
+ name = path.name
+ if problems := refused_names([name], []):
+ self.note(f"! Not sent: {safe(problems[0])}", style="bold red")
+ return
+ self._transferring = True
+ try:
+ await self._ready.wait()
+ if self.keys is None:
+ self.note("! Not sent: the chat could not be opened.", style="bold red")
+ return
+ found = await self._folder()
+ if found is None:
+ return
+ _, folder = found
+ size = path.stat().st_size
+ advance, queued = self._progress("↑", name, size)
+ with open(path, "rb") as fh:
+ def read(offset: int, length: int) -> bytes:
+ fh.seek(offset)
+ return fh.read(length)
+ stored = await upload(self.session, read, size, name, folder,
+ progress=advance, on_queued=queued)
+ try:
+ await post(self.session, self.keys, attachment_text(stored, size),
+ self.account.username)
+ except CliError as e:
+ self.note(f"! {safe(stored)} is in {safe(folder)}, but the message naming "
+ f"it was not sent: {safe(e.message)}", style="bold red")
+ return
+ except OSError as e:
+ self.note(f"! Not sent: {safe(arg)}: {e.strerror}", style="bold red")
+ return
+ except CliError as e:
+ self.note(f"! Not sent: {safe(e.message)}", style="bold red")
+ return
+ finally:
+ self._transfer_ended()
+ self.show(Message(0, time.time(), self.account.username, "", trust="pinned",
+ attachment={"filename": stored, "size": size, "type": "file"}))
+ if stored != name:
+ self.note(f"{INDENT}stored as {safe(stored)}: {safe(name)} was taken.")
+
+ async def _get(self, arg: str) -> None:
+ name = arg.replace("\\", "/").strip("/")
+ if not name or any(p in ("", ".", "..") for p in name.split("/")):
+ self.note(f"! {safe(arg)}: name a file of the chat's folder.", style="bold red")
+ return
+ self._transferring = True
+ try:
+ found = await self._folder()
+ if found is None:
+ return
+ tree, folder = found
+ entry = tree.file(f"{folder}/{name}")
+ if entry is None:
+ self.note(f"! No file {safe(name)} in the chat's folder ({safe(folder)}).",
+ style="bold red")
+ return
+ target = Path.cwd() / name.rsplit("/", 1)[-1]
+ if target.exists() and not target.with_name(target.name + PART_SUFFIX).exists():
+ self.note(f"! {safe(str(target))} is here already; nothing was downloaded.",
+ style="bold red")
+ return
+ size = int(entry["size"])
+ advance, queued = self._progress("↓", name, size)
+ await download(self.session, entry, target, progress=advance, on_queued=queued)
+ except OSError as e:
+ self.note(f"! Not saved: {e.strerror}", style="bold red")
+ return
+ except CliError as e:
+ self.note(f"! Not saved: {safe(e.message)}", style="bold red")
+ return
+ finally:
+ self._transfer_ended()
+ self.note(f"Saved {safe(str(target))} ({human_size(size)}).")
+
+
+def _unquote(text: str) -> str:
+ """A path pasted with its quotes, as a file manager or a shell gives it."""
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'":
+ return text[1:-1]
+ return text