feat: add conversational replies and stable output
Some checks failed
tamq-ci / test (push) Failing after 6s
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:
parent
704eeea5c1
commit
a4ba6e32e5
17 changed files with 363 additions and 59 deletions
|
|
@ -31,7 +31,7 @@ from .terminal import format_comment
|
|||
|
||||
|
||||
SUBCOMMANDS = frozenset(
|
||||
"start attach serve stop status ping inbox history inspect ack send export replay purge completion db-version config tap".split()
|
||||
"start attach serve stop status ping inbox history inspect ack send reply export replay purge completion db-version config tap".split()
|
||||
)
|
||||
START_OPTIONS = frozenset({"--command", "--cmd", "--tap", "--detach", "--no-service", "--no-display"})
|
||||
GLOBAL_FLAGS = frozenset({"--orwell", "--verbose"})
|
||||
|
|
@ -162,6 +162,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
|
||||
manual messaging from a managed shell:
|
||||
@TARGET: MESSAGE...
|
||||
@ MESSAGE... reply to the latest sender for this window
|
||||
tamq inbox [--filter COMMAND]
|
||||
""",
|
||||
)
|
||||
|
|
@ -206,6 +207,8 @@ manual messaging from a managed shell:
|
|||
send.add_argument("body", nargs="*", help="message body when address is a repo slug")
|
||||
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")
|
||||
export = subparsers.add_parser("export", help="export history as JSONL")
|
||||
export.add_argument("--output", required=True)
|
||||
export.add_argument("--repo", dest="target_repo")
|
||||
|
|
@ -371,15 +374,31 @@ def main(argv: list[str] | None = None) -> int:
|
|||
store = Store(db_path())
|
||||
try:
|
||||
advisory = history_advisory(store)
|
||||
if advisory and args.command in {"start", "serve", "status", "history", "inbox", "send"}:
|
||||
if advisory and args.command in {"start", "serve", "status", "history", "inbox", "send", "reply"}:
|
||||
print(advisory, file=sys.stderr)
|
||||
if args.command == "send":
|
||||
text = " ".join([args.address, *args.body]).strip()
|
||||
if not text.startswith("@") or ":" not in text:
|
||||
print("tamq send expects @repo: message", file=sys.stderr); return 2
|
||||
target, body = text[1:].split(":", 1); body = body.strip()
|
||||
if not target or not body: print("tamq send requires a target and body", file=sys.stderr); return 2
|
||||
sender = args.sender_repo or os.environ.get("TAMQ_REPO") or "local"
|
||||
if args.command in {"send", "reply"}:
|
||||
endpoint_id = None
|
||||
if args.command == "reply":
|
||||
sender = os.environ.get("TAMQ_REPO")
|
||||
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
|
||||
else:
|
||||
text = " ".join([args.address, *args.body]).strip()
|
||||
if not text.startswith("@") or ":" not in text:
|
||||
print("tamq send expects @repo: message", file=sys.stderr); return 2
|
||||
target, body = text[1:].split(":", 1); body = body.strip()
|
||||
if not target or not body: print("tamq send requires a target and body", file=sys.stderr); return 2
|
||||
sender = args.sender_repo or os.environ.get("TAMQ_REPO") or "local"
|
||||
endpoint_id = args.endpoint_id
|
||||
if not body:
|
||||
print("tamq reply requires a message body", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
validate_targets([target])
|
||||
if sender != "local":
|
||||
|
|
@ -388,13 +407,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||
print(f"tamq: {exc}", file=sys.stderr); return 2
|
||||
if asyncio.run(ping()):
|
||||
payload = {"op": "send", "sender_repo": sender, "target_repo": target, "body": body}
|
||||
if args.endpoint_id:
|
||||
payload["endpoint_id"] = args.endpoint_id
|
||||
if endpoint_id:
|
||||
payload["endpoint_id"] = endpoint_id
|
||||
response = asyncio.run(request(payload))
|
||||
if not response.get("ok"):
|
||||
print(f"tamq: {response.get('error', 'send failed')}", file=sys.stderr); return 1
|
||||
print(response["message_id"]); return 0
|
||||
if args.endpoint_id:
|
||||
if endpoint_id:
|
||||
print("tamq: service is not running", file=sys.stderr); return 1
|
||||
try:
|
||||
print(store.add(sender, target, body))
|
||||
|
|
|
|||
|
|
@ -10,6 +10,15 @@ class ControlModeError(RuntimeError):
|
|||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaneDisplay:
|
||||
tty_path: str
|
||||
cursor_x: int
|
||||
cursor_y: int
|
||||
pane_height: int
|
||||
alternate_on: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlModeClient:
|
||||
session: str
|
||||
|
|
@ -35,16 +44,32 @@ class ControlModeClient:
|
|||
return expected_pid is None or result.stdout.strip() == str(expected_pid)
|
||||
|
||||
def pane_tty(self, window: str) -> str:
|
||||
return self.pane_display(window).tty_path
|
||||
|
||||
def pane_display(self, window: str) -> PaneDisplay:
|
||||
result = subprocess.run(
|
||||
[*self._command(), "display-message", "-p", "-t", window, "#{pane_tty}"],
|
||||
[
|
||||
*self._command(), "display-message", "-p", "-t", window,
|
||||
"#{pane_tty}|#{cursor_x}|#{cursor_y}|#{pane_height}|#{alternate_on}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
tty_path = result.stdout.strip()
|
||||
if result.returncode != 0 or not tty_path:
|
||||
output = result.stdout.strip()
|
||||
if result.returncode != 0 or not output:
|
||||
raise ControlModeError(result.stderr.strip() or f"cannot resolve pane tty: {window}")
|
||||
return tty_path
|
||||
try:
|
||||
tty_path, cursor_x, cursor_y, pane_height, alternate_on = output.split("|", 4)
|
||||
return PaneDisplay(
|
||||
tty_path=tty_path,
|
||||
cursor_x=int(cursor_x),
|
||||
cursor_y=int(cursor_y),
|
||||
pane_height=int(pane_height),
|
||||
alternate_on=alternate_on == "1",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ControlModeError(f"invalid pane display metadata: {output!r}") from exc
|
||||
|
||||
def start(self) -> None:
|
||||
if self.process is not None:
|
||||
|
|
|
|||
|
|
@ -100,11 +100,16 @@ class Service:
|
|||
try:
|
||||
target = f'{endpoint["session"]}:{row["target_repo"]}'
|
||||
if delivery_mode == "output":
|
||||
tty_path = control.pane_tty(target)
|
||||
display = control.pane_display(target)
|
||||
write_terminal_output(
|
||||
tty_path,
|
||||
display.tty_path,
|
||||
terminal_frame(
|
||||
row["sender_repo"], row["body"], row["message_id"]
|
||||
row["sender_repo"],
|
||||
row["body"],
|
||||
row["message_id"],
|
||||
cursor_y=display.cursor_y,
|
||||
pane_height=display.pane_height,
|
||||
alternate_on=display.alternate_on,
|
||||
),
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -153,6 +153,15 @@ class Store:
|
|||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
return list(self.db.execute(f"SELECT * FROM messages{where} ORDER BY created_at", values))
|
||||
|
||||
def latest_counterparty(self, target: str) -> str | None:
|
||||
row = self.db.execute(
|
||||
"SELECT sender_repo FROM messages "
|
||||
"WHERE target_repo=? AND sender_repo<>? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT 1",
|
||||
(target, target),
|
||||
).fetchone()
|
||||
return None if row is None else str(row["sender_repo"])
|
||||
|
||||
def set_state(self, message_id: str, state: str) -> None:
|
||||
column = {"injected": "injected_at", "acknowledged": "acknowledged_at"}.get(state)
|
||||
if column:
|
||||
|
|
|
|||
|
|
@ -29,9 +29,35 @@ def format_comment(sender: str, body: str, message_id: str) -> str:
|
|||
return "\n".join(rendered)
|
||||
|
||||
|
||||
def terminal_frame(sender: str, body: str, message_id: str) -> str:
|
||||
"""Frame asynchronous output away from the current visual input line."""
|
||||
def terminal_frame(
|
||||
sender: str,
|
||||
body: str,
|
||||
message_id: str,
|
||||
*,
|
||||
cursor_y: int | None = None,
|
||||
pane_height: int | None = None,
|
||||
alternate_on: bool = False,
|
||||
) -> str:
|
||||
"""Frame output above the cursor when a safe scroll region is available."""
|
||||
content = format_comment(sender, body, message_id).replace("\n", "\r\n")
|
||||
line_count = content.count("\r\n") + 1
|
||||
if (
|
||||
cursor_y is not None
|
||||
and pane_height is not None
|
||||
and not alternate_on
|
||||
and 0 < cursor_y < pane_height
|
||||
and line_count <= cursor_y
|
||||
):
|
||||
lines = content.split("\r\n")
|
||||
output = "".join(f"\n\r{line}" for line in lines)
|
||||
return (
|
||||
"\x1b7"
|
||||
f"\x1b[1;{cursor_y}r"
|
||||
f"\x1b[{cursor_y};1H"
|
||||
f"{output}"
|
||||
"\x1b[r"
|
||||
"\x1b8"
|
||||
)
|
||||
return f"\r\n{content}\r\n"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,13 @@ 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"
|
||||
)
|
||||
}
|
||||
for repo in repos:
|
||||
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is None:
|
||||
raise TmuxError(
|
||||
|
|
@ -90,20 +97,24 @@ class TmuxManager:
|
|||
f"exec {shell_join(list(self.tamq_command))} send -- {shlex.quote(target)} \"$@\"\n"
|
||||
)
|
||||
for name in (f"@{repo}", target):
|
||||
destination = self.command_dir / name
|
||||
if destination.exists() or destination.is_symlink():
|
||||
if destination.is_symlink() or not destination.is_file():
|
||||
raise TmuxError(f"refusing to replace existing command: {destination}")
|
||||
try:
|
||||
existing = destination.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise TmuxError(f"cannot inspect existing command: {destination}") from exc
|
||||
if "# tamq-address-command v1" not in existing:
|
||||
raise TmuxError(f"refusing to replace existing command: {destination}")
|
||||
temporary = self.command_dir / f".{name}.{uuid4().hex}.tmp"
|
||||
temporary.write_text(script, encoding="utf-8")
|
||||
temporary.chmod(0o700)
|
||||
temporary.replace(destination)
|
||||
scripts[name] = script
|
||||
for name in scripts:
|
||||
destination = self.command_dir / name
|
||||
if destination.exists() or destination.is_symlink():
|
||||
if destination.is_symlink() or not destination.is_file():
|
||||
raise TmuxError(f"refusing to replace existing command: {destination}")
|
||||
try:
|
||||
existing = destination.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise TmuxError(f"cannot inspect existing command: {destination}") from exc
|
||||
if "# tamq-address-command v1" not in existing:
|
||||
raise TmuxError(f"refusing to replace existing command: {destination}")
|
||||
for name, script in scripts.items():
|
||||
destination = self.command_dir / name
|
||||
temporary = self.command_dir / f".{name}.{uuid4().hex}.tmp"
|
||||
temporary.write_text(script, encoding="utf-8")
|
||||
temporary.chmod(0o700)
|
||||
temporary.replace(destination)
|
||||
|
||||
def _window_environment(self, repo: str) -> tuple[str, ...]:
|
||||
path = os.environ.get("PATH", "")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue