from __future__ import annotations import os import re import sys import termios import tty from collections.abc import Sequence class ComposerError(RuntimeError): pass def recipient_order( current_repo: str, latest_counterparty: str | None, session_repos: Sequence[str], ) -> tuple[str, ...]: """Put the latest sender first, then the remaining peer repositories.""" ordered: list[str] = [] for repo in ([latest_counterparty] if latest_counterparty else []): if ( repo != current_repo and repo not in ordered and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is not None ): ordered.append(repo) for repo in session_repos: if ( repo != current_repo and repo not in ordered and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is not None ): ordered.append(repo) return tuple(ordered) def _remove_last_character(value: bytearray) -> None: if not value: return removed = value.pop() if removed & 0xC0 == 0x80: while value and value[-1] & 0xC0 == 0x80: value.pop() if value and value[-1] & 0xC0 == 0xC0: value.pop() def compose_message( recipients: Sequence[str], *, input_fd: int | None = None, output_fd: int | None = None, ) -> tuple[str, str] | None: """Read one literal terminal line while Tab cycles the visible recipient.""" if not recipients: raise ComposerError("no recipient is available") try: source = sys.stdin.fileno() if input_fd is None else input_fd destination = sys.stderr.fileno() if output_fd is None else output_fd except (AttributeError, OSError) as exc: raise ComposerError("interactive composition requires a terminal") from exc if not os.isatty(source) or not os.isatty(destination): raise ComposerError("interactive composition requires a terminal") saved = termios.tcgetattr(source) draft = bytearray() recipient_index = 0 escape_state = 0 def write(value: bytes) -> None: offset = 0 while offset < len(value): offset += os.write(destination, value[offset:]) def render() -> None: prompt = f"@{recipients[recipient_index]}: ".encode("utf-8") write(b"\r\x1b[2K" + prompt + draft) try: tty.setraw(source) render() while True: value = os.read(source, 1) if not value: write(b"\r\n") return None byte = value[0] if escape_state == 1: escape_state = 2 if byte in (ord("["), ord("O")) else 0 continue if escape_state == 2: if 0x40 <= byte <= 0x7E: escape_state = 0 continue if byte == 0x1B: escape_state = 1 elif byte == 0x09: recipient_index = (recipient_index + 1) % len(recipients) render() elif byte in (0x0A, 0x0D): try: body = draft.decode("utf-8") except UnicodeDecodeError: write(b"\a") continue if not body.strip(): write(b"\a") continue write(b"\r\n") return recipients[recipient_index], body elif byte == 0x03: write(b"^C\r\n") return None elif byte == 0x04 and not draft: write(b"\r\n") return None elif byte in (0x08, 0x7F): _remove_last_character(draft) render() elif byte == 0x15: draft.clear() render() elif byte >= 0x20: draft.extend(value) write(value) else: write(b"\a") finally: termios.tcsetattr(source, termios.TCSADRAIN, saved)