smartcli-toolkit 0.1.0__py3-none-any.whl

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,53 @@
1
+ """SmartCLI shared core.
2
+
3
+ A pluggable-PTY + pyte screen model + semantic snapshot + readiness stack for
4
+ driving interactive terminal programs. Both SmartCLI skills build on this.
5
+
6
+ Quick start::
7
+
8
+ from smartcli_core import PtySession
9
+
10
+ with PtySession(cols=100, rows=30) as sess:
11
+ sess.start("python")
12
+ sess.wait_ready(marker=r">>> $")
13
+ sess.send_line("print('hello')")
14
+ reason, snap = sess.wait_ready(marker=r">>> $")
15
+ print(snap.to_text())
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from .pty_backend import (
21
+ PosixPtyBackend,
22
+ PtyBackend,
23
+ WinptyBackend,
24
+ get_default_backend,
25
+ )
26
+ from .readiness import wait_for_regex, wait_ready, wait_until_stable
27
+ from .screen_model import CellAttrs, ScreenModel
28
+ from .session import KEY_MAP, PtySession
29
+ from .snapshot import Snapshot, Span, build_snapshot
30
+
31
+ __all__ = [
32
+ # backends
33
+ "PtyBackend",
34
+ "WinptyBackend",
35
+ "PosixPtyBackend",
36
+ "get_default_backend",
37
+ # screen
38
+ "ScreenModel",
39
+ "CellAttrs",
40
+ # snapshot
41
+ "Snapshot",
42
+ "Span",
43
+ "build_snapshot",
44
+ # readiness
45
+ "wait_until_stable",
46
+ "wait_for_regex",
47
+ "wait_ready",
48
+ # session
49
+ "PtySession",
50
+ "KEY_MAP",
51
+ ]
52
+
53
+ __version__ = "0.1.0"
@@ -0,0 +1,259 @@
1
+ """Pluggable PTY backends for SmartCLI.
2
+
3
+ A :class:`PtyBackend` abstracts spawning a child process attached to a
4
+ pseudo-terminal and exchanging bytes with it. Two concrete backends ship:
5
+
6
+ * :class:`WinptyBackend` -- uses ``pywinpty`` (ConPTY) on Windows. ``pywinpty``
7
+ reads are blocking and return ``str``; a background reader thread drains the
8
+ process into a queue so :meth:`read_nonblocking` never blocks.
9
+ * :class:`PosixPtyBackend` -- uses the stdlib ``pty``/``os``/``select`` stack.
10
+ The POSIX-only imports are guarded so this module imports cleanly on Windows.
11
+
12
+ Both backends normalise their read side to **bytes** so the rest of the core
13
+ (screen model, readiness) can feed a single long-lived ``pyte.ByteStream``.
14
+
15
+ Use :func:`get_default_backend` to obtain the right backend for the host.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import abc
21
+ import queue
22
+ import sys
23
+ import threading
24
+ import time
25
+ from typing import Optional, Sequence, Union
26
+
27
+
28
+ class PtyBackend(abc.ABC):
29
+ """Abstract pseudo-terminal backend.
30
+
31
+ Implementations own a child process running under a PTY sized ``cols`` x
32
+ ``rows``. All reads are non-blocking and return raw ``bytes`` (possibly
33
+ empty). Writes accept ``bytes``.
34
+ """
35
+
36
+ @abc.abstractmethod
37
+ def spawn(self, cmd: Union[str, Sequence[str]], cols: int, rows: int) -> None:
38
+ """Launch ``cmd`` in a PTY of size ``cols`` x ``rows``."""
39
+
40
+ @abc.abstractmethod
41
+ def read_nonblocking(self) -> bytes:
42
+ """Return any bytes available right now; ``b""`` if none. Never blocks."""
43
+
44
+ @abc.abstractmethod
45
+ def write(self, data: bytes) -> None:
46
+ """Write ``data`` to the child's stdin."""
47
+
48
+ @abc.abstractmethod
49
+ def resize(self, cols: int, rows: int) -> None:
50
+ """Resize the PTY window to ``cols`` x ``rows``."""
51
+
52
+ @abc.abstractmethod
53
+ def is_alive(self) -> bool:
54
+ """Return ``True`` while the child process is running."""
55
+
56
+ @abc.abstractmethod
57
+ def terminate(self) -> None:
58
+ """Terminate the child and release resources. Idempotent."""
59
+
60
+
61
+ class WinptyBackend(PtyBackend):
62
+ """PTY backend built on ``pywinpty`` (ConPTY).
63
+
64
+ ``pywinpty`` returns already-decoded ``str`` and its ``read`` can block, so a
65
+ daemon reader thread performs blocking reads and pushes UTF-8 encoded chunks
66
+ into a :class:`queue.Queue`. :meth:`read_nonblocking` drains that queue.
67
+ """
68
+
69
+ def __init__(self) -> None:
70
+ self._proc = None # winpty.PtyProcess
71
+ self._queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
72
+ self._reader: Optional[threading.Thread] = None
73
+ self._eof = False
74
+
75
+ def spawn(self, cmd: Union[str, Sequence[str]], cols: int, rows: int) -> None:
76
+ import winpty # imported lazily so non-Windows hosts don't require it
77
+
78
+ # winpty.spawn accepts a command string or an argv list; dimensions are
79
+ # (rows, cols).
80
+ self._proc = winpty.PtyProcess.spawn(cmd, dimensions=(rows, cols))
81
+ self._reader = threading.Thread(target=self._read_loop, daemon=True)
82
+ self._reader.start()
83
+
84
+ def _read_loop(self) -> None:
85
+ """Blocking-read the child until EOF, pushing bytes into the queue."""
86
+ proc = self._proc
87
+ while True:
88
+ try:
89
+ data = proc.read(65536) # returns str; blocks if nothing ready
90
+ except EOFError:
91
+ break
92
+ except OSError:
93
+ break
94
+ if data:
95
+ self._queue.put(data.encode("utf-8", "replace"))
96
+ self._queue.put(None) # EOF sentinel
97
+
98
+ def read_nonblocking(self) -> bytes:
99
+ out = bytearray()
100
+ while True:
101
+ try:
102
+ item = self._queue.get_nowait()
103
+ except queue.Empty:
104
+ break
105
+ if item is None:
106
+ self._eof = True
107
+ break
108
+ out.extend(item)
109
+ return bytes(out)
110
+
111
+ def write(self, data: bytes) -> None:
112
+ if self._proc is None:
113
+ raise RuntimeError("spawn() must be called before write()")
114
+ # pywinpty.write expects str.
115
+ self._proc.write(data.decode("utf-8", "replace"))
116
+
117
+ def resize(self, cols: int, rows: int) -> None:
118
+ if self._proc is not None:
119
+ self._proc.setwinsize(rows, cols)
120
+
121
+ def is_alive(self) -> bool:
122
+ if self._proc is None:
123
+ return False
124
+ try:
125
+ return bool(self._proc.isalive())
126
+ except Exception:
127
+ return False
128
+
129
+ def terminate(self) -> None:
130
+ if self._proc is None:
131
+ return
132
+ try:
133
+ self._proc.terminate(force=True)
134
+ except Exception:
135
+ pass
136
+ finally:
137
+ self._proc = None
138
+
139
+
140
+ class PosixPtyBackend(PtyBackend):
141
+ """PTY backend built on the stdlib ``pty``/``os``/``select`` stack.
142
+
143
+ Only usable on POSIX hosts. The heavy imports happen inside :meth:`spawn`
144
+ (and a small module-level guard) so importing this file on Windows is safe.
145
+ """
146
+
147
+ def __init__(self) -> None:
148
+ self._pid: Optional[int] = None
149
+ self._fd: Optional[int] = None
150
+ self._eof = False
151
+
152
+ def spawn(self, cmd: Union[str, Sequence[str]], cols: int, rows: int) -> None:
153
+ import fcntl
154
+ import os
155
+ import pty
156
+ import struct
157
+ import termios
158
+
159
+ if isinstance(cmd, str):
160
+ argv = ["/bin/sh", "-c", cmd]
161
+ else:
162
+ argv = list(cmd)
163
+
164
+ pid, master_fd = pty.fork()
165
+ if pid == 0: # child
166
+ try:
167
+ os.execvp(argv[0], argv)
168
+ except Exception:
169
+ os._exit(127)
170
+ # parent
171
+ self._pid = pid
172
+ self._fd = master_fd
173
+ os.set_blocking(master_fd, False)
174
+ fcntl.ioctl(
175
+ master_fd,
176
+ termios.TIOCSWINSZ,
177
+ struct.pack("HHHH", rows, cols, 0, 0),
178
+ )
179
+
180
+ def read_nonblocking(self) -> bytes:
181
+ import os
182
+ import select
183
+
184
+ if self._fd is None:
185
+ return b""
186
+ out = bytearray()
187
+ while True:
188
+ r, _, _ = select.select([self._fd], [], [], 0)
189
+ if not r:
190
+ break
191
+ try:
192
+ chunk = os.read(self._fd, 65536)
193
+ except BlockingIOError:
194
+ break
195
+ except OSError: # EIO on child exit (Linux)
196
+ self._eof = True
197
+ break
198
+ if not chunk: # EOF (BSD/mac)
199
+ self._eof = True
200
+ break
201
+ out.extend(chunk)
202
+ return bytes(out)
203
+
204
+ def write(self, data: bytes) -> None:
205
+ import os
206
+
207
+ if self._fd is None:
208
+ raise RuntimeError("spawn() must be called before write()")
209
+ os.write(self._fd, data)
210
+
211
+ def resize(self, cols: int, rows: int) -> None:
212
+ import fcntl
213
+ import struct
214
+ import termios
215
+
216
+ if self._fd is not None:
217
+ fcntl.ioctl(
218
+ self._fd,
219
+ termios.TIOCSWINSZ,
220
+ struct.pack("HHHH", rows, cols, 0, 0),
221
+ )
222
+
223
+ def is_alive(self) -> bool:
224
+ import os
225
+
226
+ if self._pid is None:
227
+ return False
228
+ try:
229
+ pid, _ = os.waitpid(self._pid, os.WNOHANG)
230
+ return pid == 0
231
+ except OSError:
232
+ return False
233
+
234
+ def terminate(self) -> None:
235
+ import os
236
+ import signal
237
+
238
+ if self._pid is not None:
239
+ try:
240
+ os.kill(self._pid, signal.SIGTERM)
241
+ except OSError:
242
+ pass
243
+ if self._fd is not None:
244
+ try:
245
+ os.close(self._fd)
246
+ except OSError:
247
+ pass
248
+ self._pid = None
249
+ self._fd = None
250
+
251
+
252
+ def get_default_backend() -> PtyBackend:
253
+ """Return a backend appropriate for the current platform.
254
+
255
+ Windows -> :class:`WinptyBackend`; everything else -> :class:`PosixPtyBackend`.
256
+ """
257
+ if sys.platform == "win32":
258
+ return WinptyBackend()
259
+ return PosixPtyBackend()
@@ -0,0 +1,210 @@
1
+ """Readiness synchronisation for driving an interactive PTY program.
2
+
3
+ Three independent signals, combined so the agent never fires input into a
4
+ program that isn't ready and never hangs on an animation:
5
+
6
+ * **Quiescence** -- no bytes arriving (transport level).
7
+ * **Screen stability** -- the pyte content hash stops changing (semantic level,
8
+ survives chunked/bursty reads). Cursor-only and attribute-only changes are
9
+ excluded from the hash upstream in :meth:`ScreenModel.content_hash`.
10
+ * **Marker match** -- an expected regex appears (strongest signal).
11
+
12
+ Every wait has a hard ``max_wait`` ceiling so spinners/progress bars return the
13
+ last screen instead of hanging.
14
+
15
+ The functions take plain callables (``read_fn`` to pump bytes, ``get_screen_hash_fn``
16
+ to sample stability, ``get_snapshot_fn`` to produce a result) so they stay
17
+ decoupled from the session/backend concrete types.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ import time
24
+ from typing import Callable, Optional, Tuple
25
+
26
+ # Callable aliases (documentation only)
27
+ ReadFn = Callable[[], bytes] # pump: read+feed one batch, return bytes read
28
+ HashFn = Callable[[], int] # sample the cursor-excluded content hash
29
+ TextFn = Callable[[], str] # current rendered screen text (for regex)
30
+
31
+
32
+ def _ms(seconds: float) -> float:
33
+ return seconds
34
+
35
+
36
+ def wait_until_stable(
37
+ read_fn: ReadFn,
38
+ get_screen_hash_fn: HashFn,
39
+ quiet_ms: int = 200,
40
+ poll_ms: int = 30,
41
+ max_wait_ms: int = 8000,
42
+ grace_ms: int = 40,
43
+ min_wait_ms: int = 0,
44
+ ) -> bool:
45
+ """Pump reads until the screen hash is unchanged for ``quiet_ms``.
46
+
47
+ The stable timer resets on *either* new bytes arriving *or* the hash
48
+ changing; quiet time accumulates only when both are absent. After stability
49
+ is declared a short ``grace`` sleep absorbs a late flush, then one final
50
+ drain re-checks; if the flush changed the screen, the wait resumes.
51
+
52
+ Args:
53
+ read_fn: called each poll to read+feed a batch; returns bytes read.
54
+ get_screen_hash_fn: returns the current cursor-excluded content hash.
55
+ quiet_ms: continuous no-change duration required to declare stable.
56
+ poll_ms: sleep between polls when idle.
57
+ max_wait_ms: hard ceiling; returns ``False`` if reached.
58
+ grace_ms: final settle sleep after stability, before returning.
59
+ min_wait_ms: minimum elapsed time before stability may be declared
60
+ (guards the stale-screen race right after sending input).
61
+
62
+ Returns:
63
+ ``True`` if the screen settled, ``False`` on timeout.
64
+ """
65
+ poll = poll_ms / 1000.0
66
+ quiet = quiet_ms / 1000.0
67
+ grace = grace_ms / 1000.0
68
+ min_wait = min_wait_ms / 1000.0
69
+
70
+ start = time.monotonic()
71
+ deadline = start + (max_wait_ms / 1000.0)
72
+ last_hash: Optional[int] = None
73
+ stable_since: Optional[float] = None
74
+
75
+ while True:
76
+ now = time.monotonic()
77
+ data = read_fn()
78
+ h = get_screen_hash_fn()
79
+ elapsed = now - start
80
+
81
+ if not data and h == last_hash:
82
+ if stable_since is None:
83
+ stable_since = now
84
+ elif (now - stable_since) >= quiet and elapsed >= min_wait:
85
+ if grace > 0:
86
+ time.sleep(grace)
87
+ tail = read_fn()
88
+ if tail:
89
+ # late flush: resume waiting
90
+ stable_since = None
91
+ last_hash = get_screen_hash_fn()
92
+ continue
93
+ return True
94
+ else:
95
+ stable_since = None
96
+ last_hash = h
97
+
98
+ if now >= deadline:
99
+ return False
100
+ time.sleep(poll)
101
+
102
+
103
+ def wait_for_regex(
104
+ read_fn: ReadFn,
105
+ get_text_fn: TextFn,
106
+ get_snapshot_fn: Callable[[], object],
107
+ pattern: str,
108
+ timeout_ms: int = 10000,
109
+ poll_ms: int = 30,
110
+ min_wait_ms: int = 0,
111
+ flags: int = 0,
112
+ ) -> Tuple[bool, object]:
113
+ """Pump reads until ``pattern`` matches the rendered screen, or timeout.
114
+
115
+ Args:
116
+ read_fn: called each poll to read+feed a batch.
117
+ get_text_fn: returns the current screen text searched by the regex.
118
+ get_snapshot_fn: builds the :class:`Snapshot` returned to the caller.
119
+ pattern: regular expression searched against the whole screen text.
120
+ timeout_ms: hard ceiling.
121
+ poll_ms: sleep between polls when idle.
122
+ min_wait_ms: ignore matches before this much time has elapsed (guards
123
+ against matching a stale prior prompt).
124
+ flags: extra ``re`` flags (``re.I`` etc.).
125
+
126
+ Returns:
127
+ ``(matched, snapshot)`` -- ``snapshot`` is always the current screen,
128
+ even on timeout, so the agent can act on the last state.
129
+ """
130
+ rx = re.compile(pattern, flags)
131
+ poll = poll_ms / 1000.0
132
+ min_wait = min_wait_ms / 1000.0
133
+ start = time.monotonic()
134
+ deadline = start + (timeout_ms / 1000.0)
135
+
136
+ while True:
137
+ now = time.monotonic()
138
+ read_fn()
139
+ elapsed = now - start
140
+ if elapsed >= min_wait and rx.search(get_text_fn()):
141
+ return True, get_snapshot_fn()
142
+ if now >= deadline:
143
+ return False, get_snapshot_fn()
144
+ time.sleep(poll)
145
+
146
+
147
+ def wait_ready(
148
+ read_fn: ReadFn,
149
+ get_screen_hash_fn: HashFn,
150
+ get_text_fn: TextFn,
151
+ get_snapshot_fn: Callable[[], object],
152
+ marker: Optional[str] = None,
153
+ quiet_ms: int = 200,
154
+ poll_ms: int = 30,
155
+ max_wait_ms: int = 10000,
156
+ min_wait_ms: int = 50,
157
+ grace_ms: int = 40,
158
+ flags: int = 0,
159
+ ) -> Tuple[str, object]:
160
+ """Unified wait: satisfy on ``marker`` OR screen stability, capped by max_wait.
161
+
162
+ A single loop races the marker (if given) against stability so callers get
163
+ the earliest safe moment. ``min_wait_ms`` guards the stale-screen race after
164
+ sending input.
165
+
166
+ Returns:
167
+ ``(reason, snapshot)`` where ``reason`` is ``"MARKER"``, ``"STABLE"`` or
168
+ ``"TIMEOUT"``. The snapshot is always the current screen.
169
+ """
170
+ rx = re.compile(marker, flags) if marker else None
171
+ poll = poll_ms / 1000.0
172
+ quiet = quiet_ms / 1000.0
173
+ grace = grace_ms / 1000.0
174
+ min_wait = min_wait_ms / 1000.0
175
+
176
+ start = time.monotonic()
177
+ deadline = start + (max_wait_ms / 1000.0)
178
+ last_hash: Optional[int] = None
179
+ stable_since: Optional[float] = None
180
+
181
+ while True:
182
+ now = time.monotonic()
183
+ data = read_fn()
184
+ elapsed = now - start
185
+
186
+ # 1) marker wins immediately (respect min_wait)
187
+ if rx is not None and elapsed >= min_wait and rx.search(get_text_fn()):
188
+ return "MARKER", get_snapshot_fn()
189
+
190
+ # 2) stability
191
+ h = get_screen_hash_fn()
192
+ if not data and h == last_hash:
193
+ if stable_since is None:
194
+ stable_since = now
195
+ elif (now - stable_since) >= quiet and elapsed >= min_wait:
196
+ if grace > 0:
197
+ time.sleep(grace)
198
+ tail = read_fn()
199
+ if tail:
200
+ stable_since = None
201
+ last_hash = get_screen_hash_fn()
202
+ continue
203
+ return "STABLE", get_snapshot_fn()
204
+ else:
205
+ stable_since = None
206
+ last_hash = h
207
+
208
+ if now >= deadline:
209
+ return "TIMEOUT", get_snapshot_fn()
210
+ time.sleep(poll)
@@ -0,0 +1,132 @@
1
+ """Thin wrapper over ``pyte`` that turns a byte stream into an inspectable grid.
2
+
3
+ :class:`ScreenModel` owns a single long-lived ``pyte.ByteStream`` + ``pyte.Screen``
4
+ pair. Feed raw PTY bytes with :meth:`feed`; the stream is stateful and stream-safe
5
+ so partial ANSI escapes and split multibyte UTF-8 across reads are handled
6
+ correctly. Never recreate the stream per read.
7
+
8
+ Exposes plain text (``pyte.screen.display``), the cursor, a stability hash, and a
9
+ per-cell attribute reader that copes with the sparse dict-of-dicts buffer.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import zlib
15
+ from typing import List, NamedTuple, Tuple
16
+
17
+ import pyte
18
+
19
+
20
+ class CellAttrs(NamedTuple):
21
+ """Reduced view of a single :class:`pyte.screens.Char`."""
22
+
23
+ data: str
24
+ fg: str
25
+ bg: str
26
+ bold: bool
27
+ reverse: bool
28
+
29
+
30
+ class ScreenModel:
31
+ """Feed PTY bytes into a ``pyte`` screen and read structured state back out."""
32
+
33
+ def __init__(self, cols: int = 80, rows: int = 24) -> None:
34
+ self._cols = cols
35
+ self._rows = rows
36
+ self.screen = pyte.Screen(cols, rows)
37
+ # ByteStream decodes UTF-8 incrementally, so multibyte chars split across
38
+ # feed() boundaries are reassembled correctly.
39
+ self.stream = pyte.ByteStream(self.screen)
40
+
41
+ # -- feeding -----------------------------------------------------------
42
+
43
+ def feed(self, data: bytes) -> None:
44
+ """Feed raw bytes from the PTY into the screen. Safe with partial data."""
45
+ if data:
46
+ self.stream.feed(data)
47
+
48
+ def resize(self, cols: int, rows: int) -> None:
49
+ """Resize the underlying screen. Keep this in sync with the PTY winsize."""
50
+ self._cols = cols
51
+ self._rows = rows
52
+ # pyte.Screen.resize takes (lines, columns).
53
+ self.screen.resize(rows, cols)
54
+
55
+ # -- geometry ----------------------------------------------------------
56
+
57
+ @property
58
+ def cols(self) -> int:
59
+ return self.screen.columns
60
+
61
+ @property
62
+ def rows(self) -> int:
63
+ return self.screen.lines
64
+
65
+ # -- plain text --------------------------------------------------------
66
+
67
+ @property
68
+ def display(self) -> List[str]:
69
+ """List of rendered lines (wide-char aware, right-padded to ``cols``)."""
70
+ return list(self.screen.display)
71
+
72
+ def text(self) -> str:
73
+ """The full screen joined with newlines (trailing padding preserved)."""
74
+ return "\n".join(self.screen.display)
75
+
76
+ # -- cursor ------------------------------------------------------------
77
+
78
+ @property
79
+ def cursor(self) -> Tuple[int, int]:
80
+ """Cursor as ``(row, col)``, both 0-based."""
81
+ return (self.screen.cursor.y, self.screen.cursor.x)
82
+
83
+ @property
84
+ def cursor_hidden(self) -> bool:
85
+ return bool(self.screen.cursor.hidden)
86
+
87
+ @property
88
+ def title(self) -> str:
89
+ return self.screen.title or ""
90
+
91
+ @property
92
+ def base_reverse(self) -> bool:
93
+ """Screen-wide reverse baseline (DECSCNM). Highlight is measured vs this."""
94
+ return bool(self.screen.default_char.reverse)
95
+
96
+ # -- attributes --------------------------------------------------------
97
+
98
+ def cell(self, row: int, col: int) -> CellAttrs:
99
+ """Return attributes for one cell.
100
+
101
+ Safe against the sparse buffer: ``screen.buffer`` is a real ``defaultdict``
102
+ (indexing a missing *row* would insert it), so we only index rows within
103
+ range; missing *cells* fall back to the screen default char without
104
+ mutating anything.
105
+ """
106
+ if not (0 <= row < self.screen.lines and 0 <= col < self.screen.columns):
107
+ dc = self.screen.default_char
108
+ return CellAttrs(dc.data, dc.fg, dc.bg, dc.bold, dc.reverse)
109
+ ch = self.screen.buffer[row][col] # StaticDefaultDict: missing col -> default
110
+ return CellAttrs(ch.data, ch.fg, ch.bg, ch.bold, ch.reverse)
111
+
112
+ def row_cells(self, row: int) -> List[CellAttrs]:
113
+ """Return the attribute cells for a whole row, left to right."""
114
+ if not (0 <= row < self.screen.lines):
115
+ return []
116
+ buf = self.screen.buffer[row]
117
+ out = []
118
+ for col in range(self.screen.columns):
119
+ ch = buf[col]
120
+ out.append(CellAttrs(ch.data, ch.fg, ch.bg, ch.bold, ch.reverse))
121
+ return out
122
+
123
+ # -- stability ---------------------------------------------------------
124
+
125
+ def content_hash(self) -> int:
126
+ """CRC32 of the plain-text display.
127
+
128
+ Excludes cursor position and all attributes, so cursor movement and
129
+ attribute-only churn (blink/reverse cycling) do not count as changes for
130
+ stability detection.
131
+ """
132
+ return zlib.crc32(self.text().encode("utf-8", "replace"))