feat: add interactive recipient composer
Some checks failed
tamq-ci / test (push) Failing after 6s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
tegwick 2026-08-24 23:24:18 +02:00
parent d7d92502b4
commit 8186550c9a
10 changed files with 379 additions and 33 deletions

View file

@ -24,6 +24,7 @@ from .tmux import TmuxError, TmuxManager
from .broker import BrokerIdentity, InputBroker
from .ptytap import PtyTap
from .control import ControlModeClient
from .composer import ComposerError, compose_message, recipient_order
from .diagnostics import configure
from .policy import load_profile
from .registry import RegistryError, validate_targets
@ -162,7 +163,8 @@ def build_parser() -> argparse.ArgumentParser:
manual messaging from a managed shell:
@TARGET: MESSAGE...
@ MESSAGE... reply to the latest sender for this window
@ compose with a visible, Tab-selectable recipient
@ MESSAGE... fast reply (ordinary shell quoting applies)
tamq inbox [--filter COMMAND]
""",
)
@ -208,7 +210,7 @@ manual messaging from a managed shell:
send.add_argument("--endpoint-id")
send.add_argument("--from", dest="sender_repo", help="sender repository (default: TAMQ_REPO or local)")
reply = subparsers.add_parser("reply", help="reply to the latest sender for the current repository")
reply.add_argument("body", nargs="+", help="message body")
reply.add_argument("body", nargs="*", help="message body; omit to open the interactive composer")
export = subparsers.add_parser("export", help="export history as JSONL")
export.add_argument("--output", required=True)
export.add_argument("--repo", dest="target_repo")
@ -383,11 +385,33 @@ def main(argv: list[str] | None = None) -> int:
if not sender:
print("tamq reply requires a managed window with TAMQ_REPO", file=sys.stderr)
return 2
body = " ".join(args.body).strip()
target = store.latest_counterparty(sender)
if target is None:
print(f"tamq: no counterparty has sent a message to {sender}", file=sys.stderr)
return 2
latest = store.latest_counterparty(sender)
if args.body:
body = " ".join(args.body).strip()
target = latest
if target is None:
print(f"tamq: no counterparty has sent a message to {sender}", file=sys.stderr)
return 2
else:
try:
encoded_recipients = json.loads(os.environ.get("TAMQ_RECIPIENTS", "[]"))
except json.JSONDecodeError:
encoded_recipients = []
session_repos = (
encoded_recipients
if isinstance(encoded_recipients, list)
and all(isinstance(repo, str) for repo in encoded_recipients)
else []
)
recipients = recipient_order(sender, latest, session_repos)
try:
composed = compose_message(recipients)
except ComposerError as exc:
print(f"tamq: {exc}", file=sys.stderr)
return 2
if composed is None:
return 130
target, body = composed
else:
text = " ".join([args.address, *args.body]).strip()
if not text.startswith("@") or ":" not in text:

131
src/tamq/composer.py Normal file
View file

@ -0,0 +1,131 @@
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)

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import json
import os
import re
import shlex
@ -78,13 +79,7 @@ class TmuxManager:
self.command_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
if not os.access(self.command_dir, os.W_OK | os.X_OK):
raise TmuxError(f"address command directory is not writable: {self.command_dir}")
scripts = {
"@": (
"#!/bin/sh\n"
"# tamq-address-command v1\n"
f"exec {shell_join(list(self.tamq_command))} reply -- \"$@\"\n"
)
}
scripts: dict[str, str] = {}
for repo in repos:
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is None:
raise TmuxError(
@ -98,6 +93,14 @@ class TmuxManager:
)
for name in (f"@{repo}", target):
scripts[name] = script
recipient_data = shlex.quote(json.dumps(list(repos), separators=(",", ":")))
scripts["@"] = (
"#!/bin/sh\n"
"# tamq-address-command v1\n"
f"TAMQ_RECIPIENTS={recipient_data}\n"
"export TAMQ_RECIPIENTS\n"
f"exec {shell_join(list(self.tamq_command))} reply -- \"$@\"\n"
)
for name in scripts:
destination = self.command_dir / name
if destination.exists() or destination.is_symlink():