feat: add readable duplex messaging
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-25 19:26:27 +02:00
parent 788eb8e2ed
commit 92881b6b56
35 changed files with 1206 additions and 806 deletions

View file

@ -9,9 +9,14 @@ import signal
import sys
import termios
import tty
import codecs
import time
from collections import deque
from collections.abc import Callable, Sequence
from pathlib import Path
from .broker import InputBroker
from .terminal import terminal_safe
ENHANCED_ENTER = re.compile(
@ -43,13 +48,99 @@ def observed_line(raw: bytes) -> str:
return "".join(text)
class TerminalOutputObserver:
"""Normalize PTY output into logical lines without interpreting its content."""
_CURSOR_BOUNDARIES = frozenset("ABCDEFGHfJKd")
def __init__(self, on_line: Callable[[str], None], *, echo_seconds: float = 5.0):
self.on_line = on_line
self.echo_seconds = echo_seconds
self.decoder = codecs.getincrementaldecoder("utf-8")("replace")
self.text: list[str] = []
self.state = "normal"
self.string_kind = ""
self.operator_lines: deque[tuple[str, float]] = deque(maxlen=32)
self.last_redraw: str | None = None
def note_operator_line(self, line: str) -> None:
self.operator_lines.append((line, time.monotonic() + self.echo_seconds))
def _is_recent_operator_echo(self, line: str) -> bool:
now = time.monotonic()
while self.operator_lines and self.operator_lines[0][1] < now:
self.operator_lines.popleft()
return any(candidate == line for candidate, _ in self.operator_lines)
def _emit(self, *, redraw: bool = False) -> None:
line = "".join(self.text)
self.text.clear()
if not line:
return
if self._is_recent_operator_echo(line):
return
if redraw and line == self.last_redraw:
return
self.last_redraw = line if redraw else None
self.on_line(line)
def feed(self, data: bytes) -> None:
for character in self.decoder.decode(data):
if self.state == "normal":
if character == "\x1b":
self.state = "esc"
elif character in "\r\n":
self._emit(redraw=character == "\r")
elif character in ("\b", "\x7f"):
if self.text:
self.text.pop()
elif character == "\t" or character >= " ":
self.text.append(character)
elif self.state == "esc":
if character == "[":
self.state = "csi"
elif character in "]P_^":
self.state = "string"
self.string_kind = character
else:
self.state = "normal"
elif self.state == "csi":
if "@" <= character <= "~":
if character in self._CURSOR_BOUNDARIES:
self._emit(redraw=True)
self.state = "normal"
elif self.state == "string":
if character == "\x07" and self.string_kind == "]":
self.state = "normal"
elif character == "\x1b":
self.state = "string_esc"
elif self.state == "string_esc":
self.state = "normal" if character == "\\" else "string"
def flush(self) -> None:
self.decoder.decode(b"", final=True)
self._emit()
class PtyTap:
"""Full-duplex PTY proxy. Input is observed, never rewritten."""
def __init__(self, command: Sequence[str], broker: InputBroker, *, on_line: Callable[[str], None] | None = None):
def __init__(self, command: Sequence[str], broker: InputBroker, *, on_line: Callable[[str], None] | None = None, ready_file: Path | None = None):
self.command = list(command)
self.broker = broker
self.on_line = on_line
self.ready_file = ready_file
self.output_observer = TerminalOutputObserver(self._observe_worker_line)
def _observe_worker_line(self, line: str) -> None:
inspector = getattr(self.broker, "inspect_worker_line", None)
routed = inspector(line) if inspector is not None else None
if routed and self.on_line:
self.on_line(line)
def _local_notice(self, line: str) -> None:
payload = f"\x1b7\r\n{terminal_safe(line)}\r\n\x1b8".encode("utf-8")
write_all(sys.stdout.fileno(), payload)
def _observe_input(self, buffer: bytearray, data: bytes) -> None:
buffer.extend(data)
@ -67,7 +158,9 @@ class PtyTap:
raw = bytes(buffer[:start])
del buffer[:end]
line = observed_line(raw)
routed = self.broker.inspect_line(line)
self.output_observer.note_operator_line(line)
inspector = getattr(self.broker, "inspect_operator_line", self.broker.inspect_line)
routed = inspector(line)
if routed and self.on_line:
self.on_line(line)
@ -107,6 +200,10 @@ class PtyTap:
status = 0
try:
self.broker.notify = self._local_notice
if self.ready_file is not None:
self.ready_file.parent.mkdir(parents=True, exist_ok=True)
self.ready_file.write_text(str(os.getpid()), encoding="utf-8")
if saved_terminal is not None:
tty.setraw(stdin_fd)
saved_handlers[signal.SIGWINCH] = signal.signal(signal.SIGWINCH, resize)
@ -129,6 +226,7 @@ class PtyTap:
if not data:
break
write_all(stdout_fd, data)
self.output_observer.feed(data)
if input_open and stdin_fd in readable:
data = os.read(stdin_fd, 65536)
if not data:
@ -147,6 +245,9 @@ class PtyTap:
pass
raise
finally:
if self.ready_file is not None:
self.ready_file.unlink(missing_ok=True)
self.output_observer.flush()
for signum, handler in saved_handlers.items():
signal.signal(signum, handler)
if saved_terminal is not None: