ok-serial-terminal 0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.3
2
+ Name: ok-serial-terminal
3
+ Version: 0.1
4
+ Summary: Interactive serial port terminal (based on ok-serial)
5
+ Author: Dan Egnor
6
+ Author-email: Dan Egnor <egnor@ofb.net>
7
+ Requires-Dist: click>=8.3.1
8
+ Requires-Dist: ok-logging-setup>=0.17
9
+ Requires-Dist: ok-serial>=0.4
10
+ Requires-Python: >=3.11
11
+ Project-URL: Homepage, https://github.com/egnor/ok-serial-terminal#readme
12
+ Project-URL: Repository, https://github.com/egnor/ok-serial-terminal.git
13
+ Description-Content-Type: text/markdown
14
+
15
+ # OK serial terminal &nbsp; ๐Ÿ”Œใ€กใ€‡ใ€กใ€‡ใ€ก๐Ÿ’ป
16
+
17
+ An interactive [serial port](https://en.wikipedia.org/wiki/Serial_port) terminal, built on [ok-serial](https://github.com/egnor/ok-py-serial#readme).
18
+
19
+ Think twice before using this! Consider something more established:
20
+
21
+ - [tio](https://github.com/tio/tio) - not Python, but a great serial terminal utility
22
+ - [picocom](https://github.com/npat-efault/picocom) - the classic minimal serial terminal
23
+ - [screen](https://www.gnu.org/software/screen/) - the terminal multiplexer is also a serial terminal
24
+ - [minicom](https://salsa.debian.org/minicom-team/minicom) - if you're nostalgic for the DOS era
25
+ - [pyserial's miniterm](https://pyserial.readthedocs.io/en/latest/tools.html#module-serial.tools.miniterm) - `python -m serial.tools.miniterm`, already installed if you have pyserial
26
+
27
+ ## Installation and Usage
28
+
29
+ Install the Python package, which installs the `okterm` utility:
30
+
31
+ ```sh
32
+ pip install ok-serial-terminal
33
+ # or 'uv add ok-serial-terminal', 'uv tool install ok-serial-terminal', etc.
34
+ okterm <port> [baud]
35
+ ```
36
+
37
+ OR, skip the package install and run it directly with [uvx](https://docs.astral.sh/uv/guides/tools/) or [pipx](https://pipx.pypa.io/stable/):
38
+
39
+ ```sh
40
+ uvx ok-serial-terminal <port> [baud]
41
+ # or `pipx run ok-serial-terminal <port> [baud]`
42
+ ```
43
+
44
+ The baud rate defaults to 115200 if omitted. The port is an [ok-serial match expression](https://github.com/egnor/ok-py-serial#port-matching), so `okterm RP2040`, `okterm 2e8a:0005`, and `okterm /dev/ttyACM0` all work. Run [`okserial`](https://github.com/egnor/ok-py-serial#readme) (or `uvx ok-py-serial`) to list visible ports and their attributes.
45
+
46
+ On a terminal (unless `--plain` is given), `okterm` decorates the display with connection status, control signal state, and an indicator for unechoed typed characters. In this mode, ctrl-`]` opens a menu and ctrl-`\` quits.
47
+
48
+ In plain mode (I/O redirected or `--plain` given), data is pass-through and ^C quits.
49
+
50
+ See `okterm --help` for more options (locking mode, etc).
51
+
52
+ ## Socat for testing and profit
53
+
54
+ On Unix-ish systems, [socat](http://www.dest-unreach.org/socat/) is handy for connecting serial-port apps (`okterm` or otherwise) to non-serial endpoints (like a Unix program or a TCP socket). Install it with your favorite package manager (eg. `sudo apt install socat`), and run something like this in one window:
55
+
56
+ ```sh
57
+ socat pty,raw,echo=0,link=socat.tmp exec:$SHELL,pty,stderr,setsid,ctty
58
+ ```
59
+
60
+ The first socat argument `pty,raw,echo=0,link=socat.tmp` allocates a pseudoterminal (pty) that looks like a serial port, and creates a `./socat.tmp` symlink to the device. The `,raw,echo=0` suppresses default pty echo behavior to avoid the shell looping on its own output.
61
+
62
+ The second socat argument starts a shell on its own pty, but this could be any socat endpoint (`exec:cat`, `tcp:localhost:8000`, etc).
63
+
64
+ Socat will then shuffle data between the two points. Try this in another window (in the same directory):
65
+
66
+ ```sh
67
+ okterm socat.tmp
68
+ ```
69
+
70
+ You should get a terminal connected to the pty socat allocated; hit enter and you should see a shell prompt.
71
+
72
+ (None of this is `okterm`-specific โ€” as far as [ok-serial](https://github.com/egnor/ok-py-serial#readme) is concerned `./socat.tmp` is just another serial port, so `ok_serial.SerialConnection(match="socat.tmp", baud=115200)` works the same way from your own code.)
@@ -0,0 +1,58 @@
1
+ # OK serial terminal &nbsp; ๐Ÿ”Œใ€กใ€‡ใ€กใ€‡ใ€ก๐Ÿ’ป
2
+
3
+ An interactive [serial port](https://en.wikipedia.org/wiki/Serial_port) terminal, built on [ok-serial](https://github.com/egnor/ok-py-serial#readme).
4
+
5
+ Think twice before using this! Consider something more established:
6
+
7
+ - [tio](https://github.com/tio/tio) - not Python, but a great serial terminal utility
8
+ - [picocom](https://github.com/npat-efault/picocom) - the classic minimal serial terminal
9
+ - [screen](https://www.gnu.org/software/screen/) - the terminal multiplexer is also a serial terminal
10
+ - [minicom](https://salsa.debian.org/minicom-team/minicom) - if you're nostalgic for the DOS era
11
+ - [pyserial's miniterm](https://pyserial.readthedocs.io/en/latest/tools.html#module-serial.tools.miniterm) - `python -m serial.tools.miniterm`, already installed if you have pyserial
12
+
13
+ ## Installation and Usage
14
+
15
+ Install the Python package, which installs the `okterm` utility:
16
+
17
+ ```sh
18
+ pip install ok-serial-terminal
19
+ # or 'uv add ok-serial-terminal', 'uv tool install ok-serial-terminal', etc.
20
+ okterm <port> [baud]
21
+ ```
22
+
23
+ OR, skip the package install and run it directly with [uvx](https://docs.astral.sh/uv/guides/tools/) or [pipx](https://pipx.pypa.io/stable/):
24
+
25
+ ```sh
26
+ uvx ok-serial-terminal <port> [baud]
27
+ # or `pipx run ok-serial-terminal <port> [baud]`
28
+ ```
29
+
30
+ The baud rate defaults to 115200 if omitted. The port is an [ok-serial match expression](https://github.com/egnor/ok-py-serial#port-matching), so `okterm RP2040`, `okterm 2e8a:0005`, and `okterm /dev/ttyACM0` all work. Run [`okserial`](https://github.com/egnor/ok-py-serial#readme) (or `uvx ok-py-serial`) to list visible ports and their attributes.
31
+
32
+ On a terminal (unless `--plain` is given), `okterm` decorates the display with connection status, control signal state, and an indicator for unechoed typed characters. In this mode, ctrl-`]` opens a menu and ctrl-`\` quits.
33
+
34
+ In plain mode (I/O redirected or `--plain` given), data is pass-through and ^C quits.
35
+
36
+ See `okterm --help` for more options (locking mode, etc).
37
+
38
+ ## Socat for testing and profit
39
+
40
+ On Unix-ish systems, [socat](http://www.dest-unreach.org/socat/) is handy for connecting serial-port apps (`okterm` or otherwise) to non-serial endpoints (like a Unix program or a TCP socket). Install it with your favorite package manager (eg. `sudo apt install socat`), and run something like this in one window:
41
+
42
+ ```sh
43
+ socat pty,raw,echo=0,link=socat.tmp exec:$SHELL,pty,stderr,setsid,ctty
44
+ ```
45
+
46
+ The first socat argument `pty,raw,echo=0,link=socat.tmp` allocates a pseudoterminal (pty) that looks like a serial port, and creates a `./socat.tmp` symlink to the device. The `,raw,echo=0` suppresses default pty echo behavior to avoid the shell looping on its own output.
47
+
48
+ The second socat argument starts a shell on its own pty, but this could be any socat endpoint (`exec:cat`, `tcp:localhost:8000`, etc).
49
+
50
+ Socat will then shuffle data between the two points. Try this in another window (in the same directory):
51
+
52
+ ```sh
53
+ okterm socat.tmp
54
+ ```
55
+
56
+ You should get a terminal connected to the pty socat allocated; hit enter and you should see a shell prompt.
57
+
58
+ (None of this is `okterm`-specific โ€” as far as [ok-serial](https://github.com/egnor/ok-py-serial#readme) is concerned `./socat.tmp` is just another serial port, so `ok_serial.SerialConnection(match="socat.tmp", baud=115200)` works the same way from your own code.)
@@ -0,0 +1,9 @@
1
+ """
2
+ An interactive serial port terminal built on
3
+ [ok-serial](https://github.com/egnor/ok-py-serial#readme).
4
+ This package is a CLI utility (`okterm`), not a library.
5
+ """
6
+
7
+ import importlib.metadata
8
+
9
+ __version__ = importlib.metadata.version(__package__)
@@ -0,0 +1,107 @@
1
+ import asyncio
2
+ import contextlib
3
+ import logging
4
+ import os
5
+ import select
6
+ import termios
7
+ import typing
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+ # Approach: Both reads and writes *attempt* to use the event loop; if
12
+ # loop.add_reader/writer rejects the fd (file, /dev/null, etc), proceed anyway.
13
+ #
14
+ # Other approaches considered
15
+ # - asyncio.streams.StreamReader/Writer: don't work on files, /dev/null, etc
16
+ # - asyncio.run_in_executor (or similar threading): cancellation is difficult
17
+ # - O_NONBLOCK: breaks other users of the file (eg. stderr writes to same tty)
18
+
19
+
20
+ class AsyncReader:
21
+ """Wraps an OS-level I/O stream with an async read() function.
22
+ Designed for stdio: pipes, files, ttys/ptys, and /dev/null.
23
+ """
24
+
25
+ def __init__(self, stream: typing.IO) -> None:
26
+ self._fd = stream.fileno()
27
+ self._lock = asyncio.Lock()
28
+ self._loop = asyncio.get_running_loop()
29
+ self._pollable = True
30
+
31
+ async def read(self, size: int) -> bytes:
32
+ async with self._lock:
33
+ while True:
34
+ if self._pollable:
35
+ future = self._loop.create_future()
36
+ try:
37
+ self._loop.add_reader(self._fd, future.set_result, None)
38
+ await future
39
+ except OSError:
40
+ log.debug("FD %d isn't pollable (reading)", self._fd)
41
+ self._pollable = False
42
+ finally:
43
+ self._loop.remove_reader(self._fd)
44
+
45
+ with contextlib.suppress(BlockingIOError):
46
+ return os.read(self._fd, size)
47
+
48
+
49
+ class AsyncWriter:
50
+ """Wraps an OS-level I/O stream with an async write() function.
51
+ Designed for stdio: pipes, files, ttys/ptys, and /dev/null.
52
+ """
53
+
54
+ def __init__(self, stream: typing.IO) -> None:
55
+ self._fd = stream.fileno()
56
+ self._lock = asyncio.Lock()
57
+ self._loop = asyncio.get_running_loop()
58
+ self._pollable = True
59
+
60
+ async def write(self, data: bytes) -> None:
61
+ view = memoryview(data)
62
+ async with self._lock:
63
+ while view:
64
+ if self._pollable:
65
+ future = self._loop.create_future()
66
+ try:
67
+ self._loop.add_writer(self._fd, future.set_result, None)
68
+ await future
69
+ except OSError:
70
+ log.debug("FD %d isn't pollable (writing)", self._fd)
71
+ self._pollable = False
72
+ finally:
73
+ self._loop.remove_writer(self._fd)
74
+
75
+ # cap write size to bound blocking (the fd is not O_NONBLOCK);
76
+ # for pipes/FIFOs, writable poll guarantees PIPE_BUF space
77
+ # TODO: for ttys/ptys, reopen it by name and use O_NONBLOCK?
78
+ # TODO: for sockets (eg. systemd logging), use a small write?
79
+ with contextlib.suppress(BlockingIOError):
80
+ view = view[os.write(self._fd, view[: select.PIPE_BUF]) :]
81
+
82
+
83
+ @contextlib.contextmanager
84
+ def raw_tty_context(fd: typing.Literal[0, 1, 2]) -> typing.Iterator[bool]:
85
+ """Returns a context manager that, on entry, if the stdio fd (0, 1, 2)
86
+ is a terminal, sets it to raw mode and restores original mode on exit."""
87
+
88
+ try:
89
+ old_attr = termios.tcgetattr(fd)
90
+ except termios.error:
91
+ logging.debug("FD %d is not a terminal, skipping raw mode", fd)
92
+ yield False # not a tty
93
+ return
94
+
95
+ if fd == 0:
96
+ raw_cc = [int(i == termios.VMIN) for i in range(len(old_attr[6]))]
97
+ raw_attr = [0, old_attr[1], 0, 0, *old_attr[4:6], raw_cc]
98
+ else:
99
+ raw_attr = [old_attr[0], 0, *old_attr[2:]]
100
+
101
+ logging.debug("Setting tty fd=%d to raw mode", fd)
102
+ try:
103
+ termios.tcsetattr(fd, termios.TCSADRAIN, raw_attr)
104
+ yield True # is a tty
105
+ finally:
106
+ logging.debug("Restoring tty fd=%d to original mode", fd)
107
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_attr)
@@ -0,0 +1,121 @@
1
+ import re
2
+ from threading import TIMEOUT_MAX
3
+
4
+ # TODO: maybe optimize TerminalChunker (and de-chunking); start with a
5
+ # chunker-focused profiling pass. Colorized `xxd` output (SGR codes every few
6
+ # bytes -> ~4-byte chunks) is a good stress test. Ideas: single-pass finditer
7
+ # instead of per-chunk match() calls, batch runs of small text/escape chunks,
8
+ # and accumulate chunk output into a bytearray rather than b"".join() of
9
+ # millions of pieces
10
+
11
+ _CHUNK_RX = re.compile(
12
+ # group 1: well-formed UTF-8 code points -- what str.decode() accepts
13
+ # Grammar: https://datatracker.ietf.org/doc/html/rfc3629#section-4
14
+ b"((?:"
15
+ b"[\x20-\x7e]|" # printable ASCII (other 1-byte are controls, group 5)
16
+ b"[\xc2-\xdf][\x80-\xbf]|" # 2-byte
17
+ # 3-byte, no overlong, no UTF-16 surrogates (U+D800..U+DFFF)
18
+ b"\xe0[\xa0-\xbf][\x80-\xbf]|[\xe1-\xec][\x80-\xbf]{2}|"
19
+ b"\xed[\x80-\x9f][\x80-\xbf]|[\xee-\xef][\x80-\xbf]{2}|"
20
+ # 4-byte, no overlong, no code points > U+10FFFF
21
+ b"\xf0[\x90-\xbf][\x80-\xbf]{2}|[\xf1-\xf3][\x80-\xbf]{3}|"
22
+ b"\xf4[\x80-\x8f][\x80-\xbf]{2}"
23
+ b")+)|"
24
+ # group 2: incomplete-but-valid UTF-8 prefix at end of data
25
+ b"("
26
+ b"[\xc2-\xf4]|"
27
+ b"\xe0[\xa0-\xbf]|[\xe1-\xec][\x80-\xbf]|"
28
+ b"\xed[\x80-\x9f]|[\xee-\xef][\x80-\xbf]|"
29
+ b"\xf0[\x90-\xbf][\x80-\xbf]?|[\xf1-\xf3][\x80-\xbf]{1,2}|"
30
+ b"\xf4[\x80-\x8f][\x80-\xbf]?"
31
+ b")\\Z|"
32
+ # group 3: one complete VTxxx control sequence
33
+ # https://vt100.net/emu/dec_ansi_parser
34
+ b"("
35
+ b"(?:\x1b\\[|\x9b)[\x20-\x3f]*[\x40-\x7e]|" # CSI
36
+ b"(?:\x1b[\x50\x58\\]-\x5f]|[\x90\x98\x9d-\x9f])" # DCS/SOS/OSC/PM/APC
37
+ b"[\x20-\x7f]*(?:\x07|\x9c|\x1b\\\\)|" # ...end DCS/SOS/OSC/PM/APC
38
+ b"(?:\x1b[\x4e\x4f]|[\x8e\x8f])[\x20-\x7e]|" # SS2/SS3 + char
39
+ b"\x1b[\x20-\x2f]+[\x30-\x7e]|" # ESC + intermediates + final (charset)
40
+ b"\x1b[\x30-\x4d\x51-\x57\x59\x5a\x60-\x7e]" # ESC-char controls
41
+ b")|"
42
+ # group 4: *partial* VTxxx control sequence at end of data
43
+ b"("
44
+ b"\x1b\\Z|" # ESC by itself
45
+ b"(?:\x1b[\x4e\x4f]|[\x8e\x8f])\\Z|" # SS2/SS3 awaiting char
46
+ b"\x1b[\x20-\x2f]+\\Z|" # ESC + intermediates awaiting final
47
+ b"(?:\x1b\\[|\x9b)[\x20-\x3f]*\\Z|" # CSI
48
+ b"(?:\x1b[\x50\x58\\]-\x5f]|[\x90\x98\x9d-\x9f])[\x20-\x7f]*\x1b?\\Z"
49
+ b")|"
50
+ # group 5: any other byte (control char, invalid, etc)
51
+ b"([\x00-\xff])"
52
+ )
53
+
54
+ _CHUNK_TIMEOUT = 0.1 # seconds to pause before giving up on partial data
55
+
56
+ _VALID_TEXT_RX = re.compile("[^\x00-\x1f]+") # non-control text
57
+
58
+
59
+ class TerminalChunker:
60
+ """Breaks VTxxx data into output characters and control sequences.
61
+
62
+ Output attributes:
63
+ - chunks: received escape codes (bytes) or text (str); caller removes
64
+ - data_deadline: when to call add_data(b"", now) if nothing received
65
+ """
66
+
67
+ def __init__(self) -> None:
68
+ self.chunks: list[str | bytes] = []
69
+ self.data_deadline = TIMEOUT_MAX
70
+ self._buffer = bytearray()
71
+
72
+ def add_data(self, data: bytes, data_time: float) -> None:
73
+ """Accepts terminal data to be chunked:
74
+ - data: bytes to process; use b"" if nothing received
75
+ - data_time: data timestamp in seconds (arbitrary epoch)
76
+ Appends output to .chunks and updates .data_deadline.
77
+ """
78
+
79
+ if data:
80
+ self.data_deadline = data_time + _CHUNK_TIMEOUT
81
+ self._buffer.extend(data)
82
+ self._process_buffer()
83
+
84
+ while self.data_deadline and data_time > self.data_deadline:
85
+ self.chunks.append(bytes(self._buffer[:1]))
86
+ del self._buffer[:1]
87
+ self._process_buffer()
88
+
89
+ def _process_buffer(self) -> None:
90
+ pos = 0
91
+ while pos < len(self._buffer):
92
+ match = _CHUNK_RX.match(self._buffer, pos)
93
+ assert match, self._buffer[pos:]
94
+ chars, char_part, esc, esc_part, other = match.groups()
95
+ if chars:
96
+ self.chunks.append(chars.decode()) # regexp enforces validity
97
+ pos += len(chars)
98
+ elif esc:
99
+ self.chunks.append(esc)
100
+ pos += len(esc)
101
+ elif other:
102
+ self.chunks.append(other)
103
+ assert len(other) == 1, other
104
+ pos += 1
105
+ else:
106
+ assert self._buffer[pos:] in (char_part, esc_part)
107
+ break
108
+
109
+ del self._buffer[:pos]
110
+ if not self._buffer:
111
+ self.data_deadline = TIMEOUT_MAX
112
+
113
+
114
+ def chunk_to_bytes(chunk: str | bytes):
115
+ """Returns the data-stream bytes for a TerminalChunker-type chunk."""
116
+ assert isinstance(chunk, (str, bytes)), chunk
117
+ if isinstance(chunk, bytes):
118
+ return chunk
119
+ else:
120
+ assert _VALID_TEXT_RX.fullmatch(chunk), chunk
121
+ return chunk.encode()
@@ -0,0 +1,243 @@
1
+ import re
2
+ from typing import Literal
3
+
4
+ from ok_serial_terminal.mode_tracker import TerminalModeTracker
5
+
6
+ QUERY_PASSTHRU_TIMEOUT = 1.0 # seconds
7
+ CURSOR_QUERY_RX = re.compile(b"(?:\x1b\\[|\x9b)6n")
8
+ CURSOR_REPLY_RX = re.compile(b"(?:\x1b\\[|\x9b)(\\d+);(\\d+)R")
9
+
10
+
11
+ class TerminalDecorator:
12
+ """Modifies terminal output to show extra text around the cursor (for
13
+ status messages, alerts, etc) without disrupting base rendering too much.
14
+ Does not perform I/O directly, but processes chunks (per TerminalChunker)
15
+ on their way to/from the terminal, via these properties:
16
+
17
+ Input *queues* (caller should append, culled by .update() as processed):
18
+ - .add_base (chunk list) - base terminal data from serial port
19
+ - .add_above (chunk lists) - message lines to insert above the cursor and
20
+ leave in place (eg. important status messages/logs)
21
+ - .add_from_terminal (chunk list) - input chunks received from the terminal
22
+
23
+ Input *values* (caller should set/update, .update() observes changes):
24
+ - .set_right - message (chunk list) to show immediately after the cursor,
25
+ moving with the cursor until removed or replaced
26
+ - .set_below - message lines (chunk lists) to insert below the cursor,
27
+ moving with the cursor until removed or replaced
28
+
29
+ *Output* queues (appended by .update(), caller should cull once handled):
30
+ - .out_to_terminal (chunk list) - to send directly to the terminal
31
+ - .out_from_terminal (chunk list) - filtered terminal input to handle
32
+
33
+ "Decorations" (.add_above/.set_below lines, .set_right) can include
34
+ SGR-type directives (starting from reset each time) but must be a single
35
+ line without cursor shenanigans. Auto-wrap is disabled so they will cut off.
36
+
37
+ Caveats: base rendering isn't disrupted "too much", but...
38
+ - adding decorations above/below moves lines around and can change the row
39
+ - adding and removing decorations to the right can erase existing content
40
+ - decorations get disrupted if base content switches primary/alt screens
41
+ - if the cursor is outside the scrolling margins, line display is glitchy
42
+ """
43
+
44
+ def __init__(self) -> None:
45
+ self.add_base: list[bytes | str] = []
46
+ self.add_above: list[list[bytes | str]] = []
47
+ self.set_right: list[bytes | str] = []
48
+ self.set_below: list[list[bytes | str]] = []
49
+ self.out_to_terminal: list[bytes | str] = []
50
+
51
+ self.add_from_terminal: list[bytes | str] = []
52
+ self.out_from_terminal: list[bytes | str] = []
53
+ self.pending_query_time: float | None = None # if query is outstanding
54
+
55
+ # terminal mode tracking: the mode set by base content, the mode
56
+ # to use for decorations, and what the terminal is actually doing
57
+ self._base_mode = TerminalModeTracker()
58
+ self._active_mode = self._base_mode
59
+
60
+ # cursor tracking: the base cursor column, and cursor excursion status
61
+ # (between cols, the cursor *row* remains aligned with the base cursor)
62
+ self._base_col: int | Literal["unknown", "querying"] = 1
63
+ self._cursor_pos: Literal["base", "roam"] = "base"
64
+ self._query_passthru: list[float] = [] # expiration times
65
+
66
+ # currently displayed right/below decorations for comparison
67
+ # (above decorations are inserted and left in place forever)
68
+ self._now_below: list[list[bytes | str]] = []
69
+ self._now_right: list[bytes | str] = []
70
+
71
+ def update(self, time: float) -> None:
72
+ """Processes input properties and updates output properties.
73
+ - time: clock time in seconds (with any consistent epoch)
74
+ """
75
+
76
+ # process input from terminal; match against pending passthru queries,
77
+ # then our own. (note, this assumes no passthru once in "querying")
78
+ for chunk in self.add_from_terminal:
79
+ if isinstance(chunk, bytes) and (m := CURSOR_REPLY_RX.match(chunk)):
80
+ if self._query_passthru:
81
+ del self._query_passthru[:1]
82
+ elif self.pending_query_time is not None:
83
+ self.pending_query_time = None
84
+ if self._base_col == "querying":
85
+ self._base_col = int(m.group(2))
86
+ continue # we issued the query; consume the result
87
+ self.out_from_terminal.append(chunk)
88
+ self.add_from_terminal.clear()
89
+
90
+ # expire pending passthru queries if we never saw a response
91
+ while self._query_passthru and time > self._query_passthru[0]:
92
+ del self._query_passthru[0]
93
+
94
+ # strategize - trim decorations right/below of cursor if:
95
+ # - base content is pending *and* reachable after trimming right/below
96
+ # - OR right/below decoration content changed and needs updating
97
+ if self.add_base and (
98
+ isinstance(self._base_col, int)
99
+ or (self._can_move_cursor_to_base() and not self._now_below)
100
+ ):
101
+ clear_right, keep_below = bool(self._now_right), 0
102
+ else:
103
+ clear_right, keep_below = (self.set_right != self._now_right), 0
104
+ while (
105
+ keep_below < len(self.set_below)
106
+ and keep_below < len(self._now_below)
107
+ and self.set_below[keep_below] == self._now_below[keep_below]
108
+ ):
109
+ keep_below += 1
110
+
111
+ # clear right of cursor if requested and possible
112
+ if clear_right and self._can_move_cursor_to_base():
113
+ self._switch_terminal_mode(self._new_decoration_mode())
114
+ self._move_cursor_to_base()
115
+ self._emit(b"\x1b[K") # caveat: leaves a hole right of cursor
116
+ self._now_right.clear()
117
+
118
+ # delete below decoration rows if requested
119
+ if del_below := len(self._now_below) - keep_below:
120
+ assert del_below > 0, (self._now_below, keep_below)
121
+ self._switch_terminal_mode(self._new_decoration_mode())
122
+ self._prepare_cursor_to_roam(time) # deleting rows moves left
123
+ self._emit(
124
+ b"\x1b[%dB" % (keep_below + 1), # move down
125
+ b"\x1b[%dM" % del_below, # delete rows
126
+ b"\x1b[%dA" % (keep_below + 1), # move back up
127
+ )
128
+ del self._now_below[-del_below:]
129
+
130
+ # add base content if provided, reachable, and clear of decorations
131
+ if self.add_base and (
132
+ self._can_move_cursor_to_base()
133
+ and not (self._now_right or self._now_below)
134
+ ):
135
+ assert self._base_col != "querying", self._base_col
136
+ self._move_cursor_to_base()
137
+ self._switch_terminal_mode(self._base_mode)
138
+ self._emit(*self.add_base)
139
+ for chunk in self.add_base:
140
+ self._base_col = 1 if chunk == b"\n" else "unknown"
141
+ if isinstance(chunk, bytes) and CURSOR_QUERY_RX.match(chunk):
142
+ self._query_passthru.append(time + QUERY_PASSTHRU_TIMEOUT)
143
+ self.add_base.clear()
144
+
145
+ # add/replace right decoration if provided and reachable
146
+ if self.set_right and (
147
+ self._can_move_cursor_to_base() and not self._now_right
148
+ ):
149
+ self._switch_terminal_mode(self._new_decoration_mode())
150
+ self._move_cursor_to_base()
151
+ self._prepare_cursor_to_roam(time) # adding content moves cursor
152
+ self._emit(*self.set_right)
153
+ self._now_right[:] = self.set_right
154
+
155
+ # insert lines above if requested
156
+ if self.add_above:
157
+ self._switch_terminal_mode(self._new_decoration_mode())
158
+ self._prepare_cursor_to_roam(time) # adding content moves cursor
159
+ self._emit(
160
+ *[b"\n"] * len(self.add_above), # scroll down to make room
161
+ b"\x1b[%dA" % len(self.add_above), # move back up
162
+ b"\x1b[%dL" % len(self.add_above), # insert rows
163
+ )
164
+ self._emit(b"\r", *self.add_above[0], b"\n")
165
+ for next_line in self.add_above[1:]:
166
+ self._switch_terminal_mode(self._new_decoration_mode())
167
+ self._emit(b"\r", *next_line, b"\n") # ends at base row
168
+ self.add_above.clear()
169
+
170
+ # insert lines below if requested
171
+ assert len(self.set_below) >= len(self._now_below)
172
+ if len(self.set_below) > len(self._now_below):
173
+ skip_lines = len(self._now_below)
174
+ assert self.set_below[:skip_lines] == self._now_below
175
+ self._switch_terminal_mode(self._new_decoration_mode())
176
+ self._prepare_cursor_to_roam(time) # adding content moves cursor
177
+ self._emit(*([b"\n"] * skip_lines))
178
+ for next_line in self.set_below[skip_lines:]:
179
+ self._switch_terminal_mode(self._new_decoration_mode())
180
+ self._emit(b"\r", b"\n", *next_line)
181
+ self._now_below.append(next_line[:])
182
+ self._emit(b"\x1b[%dA" % len(self.set_below)) # ends at base row
183
+
184
+ if self._can_move_cursor_to_base():
185
+ self._move_cursor_to_base() # leave the cursor there if possible
186
+
187
+ def reset(self) -> None:
188
+ """Adds cleanup to .out_to_terminal (*without* an update cycle):
189
+ - resets terminal mode to default state
190
+ - clears the screen after & below the cursor
191
+ - moves to a new line if we're not positioned at start of line
192
+ - (for possible restart) updates mode tracking
193
+ - (for possible restart) clears right & below decoration setting
194
+ """
195
+
196
+ self._base_mode = TerminalModeTracker() # reset to default state
197
+ self._switch_terminal_mode(self._base_mode)
198
+ self._emit(b"\x1b7", b"\x1b[r", b"\x1b8") # reset DECSTBM margins
199
+ self._emit(b"\x1b[J") # clear from cursor to end of display
200
+ if (self._cursor_pos, self._base_col) != ("base", 1):
201
+ self._cursor_pos, self._base_col = "base", 1 # in case of restart
202
+ self._emit(b"\r", b"\n") # newline to move past the base line
203
+ self.set_right.clear()
204
+ self.set_below.clear()
205
+
206
+ def _can_move_cursor_to_base(self) -> bool:
207
+ return self._cursor_pos == "base" or isinstance(self._base_col, int)
208
+
209
+ def _move_cursor_to_base(self) -> None:
210
+ assert self._can_move_cursor_to_base()
211
+ if self._cursor_pos != "base":
212
+ assert isinstance(self._base_col, int), self._base_col
213
+ self.out_to_terminal.append(b"\x1b[%dG" % self._base_col)
214
+ self._cursor_pos = "base"
215
+
216
+ def _prepare_cursor_to_roam(self, time: float) -> None:
217
+ if (self._cursor_pos, self._base_col) == ("base", "unknown"):
218
+ self.out_to_terminal.append(b"\x1b[6n")
219
+ self.pending_query_time = time
220
+ self._base_col = "querying"
221
+ self._cursor_pos = "roam"
222
+
223
+ def _switch_terminal_mode(self, mode: TerminalModeTracker) -> None:
224
+ if mode is not self._active_mode:
225
+ mode_chunks = mode.mode_chunks(base=self._active_mode)
226
+ self.out_to_terminal.extend(mode_chunks)
227
+ self._active_mode = mode
228
+
229
+ def _new_decoration_mode(self) -> TerminalModeTracker:
230
+ mode = self._base_mode.copy()
231
+ mode.add_chunk(b"\x0f") # use G0
232
+ mode.add_chunk(b"\x1b(B") # G0 = US-ASCII
233
+ mode.add_chunk(b'\x1b[0"q') # character protection off
234
+ mode.add_chunk(b"\x1b[m") # reset SGR
235
+ mode.add_chunk(b"\x1b[4l") # reset IRM - no insert mode
236
+ mode.add_chunk(b"\x1b[20l") # reset LNM - normal newline mode
237
+ mode.add_chunk(b"\x1b[?7l") # reset DECAWM - do not wrap at EOL
238
+ return mode
239
+
240
+ def _emit(self, *chunks: bytes | str) -> None:
241
+ self.out_to_terminal.extend(chunks)
242
+ for chunk in chunks:
243
+ self._active_mode.add_chunk(chunk)