Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
parent
788eb8e2ed
commit
92881b6b56
35 changed files with 1206 additions and 806 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue