Source code for pysnap.terminal.protocol

"""Helpers for terminal control-sequence handling over serial TCP."""

from __future__ import annotations

from dataclasses import dataclass, field

from pysnap.terminal.emulator import TerminalEmulator

_CSI_PREFIX = b"\x1b["


[docs] @dataclass class TerminalQueryResponder: """Recognize terminal queries and synthesize compatible replies. Raw serial consoles do not provide a PTY-style ``SIGWINCH`` channel into the guest system. PySnap therefore acts like a small terminal emulator and replies to xterm-compatible size queries that guest-side tools may emit. """ _buffer: bytearray = field(default_factory=bytearray)
[docs] def collect_responses( self, data: bytes, *, emulator: TerminalEmulator, columns: int, lines: int, ) -> list[bytes]: """Return terminal replies implied by one incoming byte chunk. :param data: Newly received bytes from the guest. :param emulator: Emulator holding the current cursor position. :param columns: Current visible terminal width. :param lines: Current visible terminal height. :returns: Zero or more response payloads to send back to the guest. """ if not data: return [] self._buffer.extend(data) responses: list[bytes] = [] while self._buffer: prefix_index = self._buffer.find(_CSI_PREFIX) if prefix_index < 0: self._buffer[:] = _tail_fragment(self._buffer) break if prefix_index > 0: del self._buffer[:prefix_index] final_index = _find_csi_final_byte(self._buffer) if final_index is None: break parameters = bytes(self._buffer[len(_CSI_PREFIX) : final_index]) final_byte = self._buffer[final_index] response = _build_response( parameters=parameters, final_byte=final_byte, emulator=emulator, columns=columns, lines=lines, ) if response is not None: responses.append(response) del self._buffer[: final_index + 1] return responses
def _build_response( *, parameters: bytes, final_byte: int, emulator: TerminalEmulator, columns: int, lines: int, ) -> bytes | None: """Build one response payload for a parsed CSI query sequence. :param parameters: Parameter bytes between ``CSI`` and the final byte. :param final_byte: Final CSI byte. :param emulator: Terminal emulator with current cursor position. :param columns: Current visible width. :param lines: Current visible height. :returns: Response bytes or ``None`` when the sequence is unsupported. """ if final_byte == ord("n") and parameters == b"6": row = emulator.screen.cursor.y + 1 column = emulator.screen.cursor.x + 1 return f"\x1b[{row};{column}R".encode() if final_byte == ord("n") and parameters == b"?6": row = emulator.screen.cursor.y + 1 column = emulator.screen.cursor.x + 1 return f"\x1b[?{row};{column}R".encode() if final_byte == ord("t") and parameters == b"18": return f"\x1b[8;{lines};{columns}t".encode() if final_byte == ord("t") and parameters == b"19": return f"\x1b[9;{lines};{columns}t".encode() return None def _find_csi_final_byte(buffer: bytearray) -> int | None: """Return the index of the next CSI final byte in ``buffer``. :param buffer: Pending CSI parser buffer that starts with ``ESC [``. :returns: Final-byte index or ``None`` when the sequence is incomplete. """ for index in range(len(_CSI_PREFIX), len(buffer)): candidate = buffer[index] if 0x40 <= candidate <= 0x7E: return index return None def _tail_fragment(buffer: bytearray) -> bytes: """Keep only the incomplete CSI prefix tail from a processed buffer. :param buffer: Parser buffer. :returns: Trailing bytes that may start a CSI sequence. """ if buffer.endswith(_CSI_PREFIX): return _CSI_PREFIX if buffer.endswith(b"\x1b"): return b"\x1b" return b""