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.
- smartcli_core/__init__.py +53 -0
- smartcli_core/pty_backend.py +259 -0
- smartcli_core/readiness.py +210 -0
- smartcli_core/screen_model.py +132 -0
- smartcli_core/session.py +244 -0
- smartcli_core/snapshot.py +297 -0
- smartcli_toolkit-0.1.0.dist-info/METADATA +226 -0
- smartcli_toolkit-0.1.0.dist-info/RECORD +11 -0
- smartcli_toolkit-0.1.0.dist-info/WHEEL +5 -0
- smartcli_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- smartcli_toolkit-0.1.0.dist-info/top_level.txt +1 -0
smartcli_core/session.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""High-level interactive PTY session -- the entry point the skills call.
|
|
2
|
+
|
|
3
|
+
:class:`PtySession` wires together a pluggable :class:`PtyBackend`, a
|
|
4
|
+
:class:`ScreenModel` (pyte), the semantic :func:`build_snapshot`, and the
|
|
5
|
+
:mod:`readiness` waits. Typical use::
|
|
6
|
+
|
|
7
|
+
sess = PtySession(cols=100, rows=30)
|
|
8
|
+
sess.start("python")
|
|
9
|
+
sess.wait_ready(marker=r">>> $")
|
|
10
|
+
sess.send_text("print('hi')")
|
|
11
|
+
sess.send_keys(["Enter"])
|
|
12
|
+
snap = sess.wait_ready(marker=r">>> $")[1]
|
|
13
|
+
print(snap.to_text())
|
|
14
|
+
sess.close()
|
|
15
|
+
|
|
16
|
+
Key tokens (``send_keys``) are mapped to escape bytes via :data:`KEY_MAP`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import List, Optional, Tuple, Union, Sequence
|
|
22
|
+
|
|
23
|
+
from .pty_backend import PtyBackend, get_default_backend
|
|
24
|
+
from .readiness import wait_for_regex, wait_ready, wait_until_stable
|
|
25
|
+
from .screen_model import ScreenModel
|
|
26
|
+
from .snapshot import Snapshot, build_snapshot
|
|
27
|
+
|
|
28
|
+
# Named key tokens -> the raw bytes to write to the PTY.
|
|
29
|
+
# Control keys use their ASCII control code; navigation keys use the common
|
|
30
|
+
# xterm/VT100 escape sequences that virtually every TUI understands.
|
|
31
|
+
KEY_MAP: dict[str, bytes] = {
|
|
32
|
+
# Whitespace / editing
|
|
33
|
+
"Enter": b"\r",
|
|
34
|
+
"Return": b"\r",
|
|
35
|
+
"Tab": b"\t",
|
|
36
|
+
"BackTab": b"\x1b[Z",
|
|
37
|
+
"Space": b" ",
|
|
38
|
+
"Backspace": b"\x7f",
|
|
39
|
+
"Delete": b"\x1b[3~",
|
|
40
|
+
"Escape": b"\x1b",
|
|
41
|
+
"Esc": b"\x1b",
|
|
42
|
+
# Arrows
|
|
43
|
+
"Up": b"\x1b[A",
|
|
44
|
+
"Down": b"\x1b[B",
|
|
45
|
+
"Right": b"\x1b[C",
|
|
46
|
+
"Left": b"\x1b[D",
|
|
47
|
+
# Navigation
|
|
48
|
+
"Home": b"\x1b[H",
|
|
49
|
+
"End": b"\x1b[F",
|
|
50
|
+
"PageUp": b"\x1b[5~",
|
|
51
|
+
"PageDown": b"\x1b[6~",
|
|
52
|
+
"Insert": b"\x1b[2~",
|
|
53
|
+
# Function keys
|
|
54
|
+
"F1": b"\x1bOP",
|
|
55
|
+
"F2": b"\x1bOQ",
|
|
56
|
+
"F3": b"\x1bOR",
|
|
57
|
+
"F4": b"\x1bOS",
|
|
58
|
+
"F5": b"\x1b[15~",
|
|
59
|
+
"F6": b"\x1b[17~",
|
|
60
|
+
"F7": b"\x1b[18~",
|
|
61
|
+
"F8": b"\x1b[19~",
|
|
62
|
+
"F9": b"\x1b[20~",
|
|
63
|
+
"F10": b"\x1b[21~",
|
|
64
|
+
"F11": b"\x1b[23~",
|
|
65
|
+
"F12": b"\x1b[24~",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_key(token: str) -> bytes:
|
|
70
|
+
"""Map a single key token to bytes.
|
|
71
|
+
|
|
72
|
+
Recognises :data:`KEY_MAP` names, ``C-x`` control combos (Ctrl+letter), and
|
|
73
|
+
``M-x`` meta/alt combos (ESC prefix). Unknown single characters are sent
|
|
74
|
+
literally.
|
|
75
|
+
"""
|
|
76
|
+
if token in KEY_MAP:
|
|
77
|
+
return KEY_MAP[token]
|
|
78
|
+
|
|
79
|
+
# Ctrl combos: "C-c", "C-x", "^C"
|
|
80
|
+
if (token.startswith("C-") or token.startswith("^")) and len(token) >= 2:
|
|
81
|
+
letter = token[2:] if token.startswith("C-") else token[1:]
|
|
82
|
+
if len(letter) == 1:
|
|
83
|
+
c = letter.upper()
|
|
84
|
+
if "A" <= c <= "Z":
|
|
85
|
+
return bytes([ord(c) - 64]) # Ctrl-A == 0x01
|
|
86
|
+
if c == "@" or c == " ":
|
|
87
|
+
return b"\x00"
|
|
88
|
+
if c == "[":
|
|
89
|
+
return b"\x1b"
|
|
90
|
+
if c == "\\":
|
|
91
|
+
return b"\x1c"
|
|
92
|
+
if c == "]":
|
|
93
|
+
return b"\x1d"
|
|
94
|
+
|
|
95
|
+
# Meta/Alt combos: "M-x" -> ESC + x
|
|
96
|
+
if token.startswith("M-") and len(token) == 3:
|
|
97
|
+
return b"\x1b" + token[2].encode("utf-8")
|
|
98
|
+
|
|
99
|
+
# Fallback: send the literal token text.
|
|
100
|
+
return token.encode("utf-8")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PtySession:
|
|
104
|
+
"""A running interactive program behind a PTY, with a semantic screen view."""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
cols: int = 80,
|
|
109
|
+
rows: int = 24,
|
|
110
|
+
backend: Optional[PtyBackend] = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.cols = cols
|
|
113
|
+
self.rows = rows
|
|
114
|
+
self.backend: PtyBackend = backend or get_default_backend()
|
|
115
|
+
self.model = ScreenModel(cols, rows)
|
|
116
|
+
self._started = False
|
|
117
|
+
|
|
118
|
+
# -- lifecycle ---------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def start(self, cmd: Union[str, Sequence[str]]) -> None:
|
|
121
|
+
"""Spawn ``cmd`` in the PTY. The pyte screen matches the PTY winsize."""
|
|
122
|
+
self.backend.spawn(cmd, self.cols, self.rows)
|
|
123
|
+
self._started = True
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
"""Terminate the child and release resources. Idempotent."""
|
|
127
|
+
self.backend.terminate()
|
|
128
|
+
self._started = False
|
|
129
|
+
|
|
130
|
+
def __enter__(self) -> "PtySession":
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
def __exit__(self, *exc) -> None:
|
|
134
|
+
self.close()
|
|
135
|
+
|
|
136
|
+
def is_alive(self) -> bool:
|
|
137
|
+
return self.backend.is_alive()
|
|
138
|
+
|
|
139
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
140
|
+
"""Resize both the PTY and the pyte screen together (keep them in sync)."""
|
|
141
|
+
self.cols = cols
|
|
142
|
+
self.rows = rows
|
|
143
|
+
self.backend.resize(cols, rows)
|
|
144
|
+
self.model.resize(cols, rows)
|
|
145
|
+
|
|
146
|
+
# -- io ----------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def pump(self) -> bytes:
|
|
149
|
+
"""Read whatever is available and feed it into the screen. Returns bytes."""
|
|
150
|
+
data = self.backend.read_nonblocking()
|
|
151
|
+
if data:
|
|
152
|
+
self.model.feed(data)
|
|
153
|
+
return data
|
|
154
|
+
|
|
155
|
+
def send_text(self, text: str) -> None:
|
|
156
|
+
"""Type literal text (no trailing newline added)."""
|
|
157
|
+
self.backend.write(text.encode("utf-8"))
|
|
158
|
+
|
|
159
|
+
def send_keys(self, keys: List[str]) -> None:
|
|
160
|
+
"""Send a sequence of key tokens (see :data:`KEY_MAP` and ``C-x``/``M-x``)."""
|
|
161
|
+
for token in keys:
|
|
162
|
+
self.backend.write(_resolve_key(token))
|
|
163
|
+
|
|
164
|
+
def send_line(self, text: str) -> None:
|
|
165
|
+
"""Type ``text`` followed by Enter."""
|
|
166
|
+
self.send_text(text)
|
|
167
|
+
self.backend.write(KEY_MAP["Enter"])
|
|
168
|
+
|
|
169
|
+
# -- snapshot ----------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def snapshot(self) -> Snapshot:
|
|
172
|
+
"""Build a semantic :class:`Snapshot` of the current screen."""
|
|
173
|
+
return build_snapshot(self.model)
|
|
174
|
+
|
|
175
|
+
# -- readiness ---------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def wait_ready(
|
|
178
|
+
self,
|
|
179
|
+
marker: Optional[str] = None,
|
|
180
|
+
quiet_ms: int = 200,
|
|
181
|
+
poll_ms: int = 30,
|
|
182
|
+
max_wait_ms: int = 10000,
|
|
183
|
+
min_wait_ms: int = 50,
|
|
184
|
+
grace_ms: int = 40,
|
|
185
|
+
flags: int = 0,
|
|
186
|
+
) -> Tuple[str, Snapshot]:
|
|
187
|
+
"""Wait for ``marker`` OR screen stability. See :func:`readiness.wait_ready`.
|
|
188
|
+
|
|
189
|
+
Returns ``(reason, snapshot)`` with reason in ``MARKER``/``STABLE``/``TIMEOUT``.
|
|
190
|
+
"""
|
|
191
|
+
reason, snap = wait_ready(
|
|
192
|
+
read_fn=self.pump,
|
|
193
|
+
get_screen_hash_fn=self.model.content_hash,
|
|
194
|
+
get_text_fn=self.model.text,
|
|
195
|
+
get_snapshot_fn=self.snapshot,
|
|
196
|
+
marker=marker,
|
|
197
|
+
quiet_ms=quiet_ms,
|
|
198
|
+
poll_ms=poll_ms,
|
|
199
|
+
max_wait_ms=max_wait_ms,
|
|
200
|
+
min_wait_ms=min_wait_ms,
|
|
201
|
+
grace_ms=grace_ms,
|
|
202
|
+
flags=flags,
|
|
203
|
+
)
|
|
204
|
+
return reason, snap # type: ignore[return-value]
|
|
205
|
+
|
|
206
|
+
def wait_stable(
|
|
207
|
+
self,
|
|
208
|
+
quiet_ms: int = 200,
|
|
209
|
+
poll_ms: int = 30,
|
|
210
|
+
max_wait_ms: int = 8000,
|
|
211
|
+
grace_ms: int = 40,
|
|
212
|
+
min_wait_ms: int = 0,
|
|
213
|
+
) -> bool:
|
|
214
|
+
"""Wait until the screen settles. See :func:`readiness.wait_until_stable`."""
|
|
215
|
+
return wait_until_stable(
|
|
216
|
+
read_fn=self.pump,
|
|
217
|
+
get_screen_hash_fn=self.model.content_hash,
|
|
218
|
+
quiet_ms=quiet_ms,
|
|
219
|
+
poll_ms=poll_ms,
|
|
220
|
+
max_wait_ms=max_wait_ms,
|
|
221
|
+
grace_ms=grace_ms,
|
|
222
|
+
min_wait_ms=min_wait_ms,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def wait_for(
|
|
226
|
+
self,
|
|
227
|
+
pattern: str,
|
|
228
|
+
timeout_ms: int = 10000,
|
|
229
|
+
poll_ms: int = 30,
|
|
230
|
+
min_wait_ms: int = 0,
|
|
231
|
+
flags: int = 0,
|
|
232
|
+
) -> Tuple[bool, Snapshot]:
|
|
233
|
+
"""Wait for ``pattern`` on the screen. See :func:`readiness.wait_for_regex`."""
|
|
234
|
+
matched, snap = wait_for_regex(
|
|
235
|
+
read_fn=self.pump,
|
|
236
|
+
get_text_fn=self.model.text,
|
|
237
|
+
get_snapshot_fn=self.snapshot,
|
|
238
|
+
pattern=pattern,
|
|
239
|
+
timeout_ms=timeout_ms,
|
|
240
|
+
poll_ms=poll_ms,
|
|
241
|
+
min_wait_ms=min_wait_ms,
|
|
242
|
+
flags=flags,
|
|
243
|
+
)
|
|
244
|
+
return matched, snap # type: ignore[return-value]
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Semantic, token-cheap snapshot of a terminal screen for an LLM agent.
|
|
2
|
+
|
|
3
|
+
A raw screen dump loses the single most actionable fact in a TUI: *which row is
|
|
4
|
+
selected*. Reverse-video and themed selection bars are invisible in plain text.
|
|
5
|
+
:func:`build_snapshot` scans the per-cell attributes, reduces them to a few
|
|
6
|
+
meaning-bearing fields (selected row, menu spans, errors, status bar), and throws
|
|
7
|
+
the color grid away.
|
|
8
|
+
|
|
9
|
+
The :class:`Snapshot` dataclass carries the reduced view and renders two
|
|
10
|
+
representations:
|
|
11
|
+
|
|
12
|
+
* :meth:`Snapshot.to_text` -- a compact view for feeding an LLM: the visible
|
|
13
|
+
screen (blank runs collapsed) plus a one-line header describing cursor,
|
|
14
|
+
selection and status bar.
|
|
15
|
+
* :meth:`Snapshot.to_json` -- the full structured form for programmatic use.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
from .screen_model import ScreenModel
|
|
26
|
+
|
|
27
|
+
_ERROR_RE = re.compile(r"\b(error|failed|traceback|exception)\b", re.I)
|
|
28
|
+
_RED_FGS = {"red", "brightred"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Span:
|
|
33
|
+
"""A contiguous run of highlighted columns on one row (``col_end`` exclusive)."""
|
|
34
|
+
|
|
35
|
+
row: int
|
|
36
|
+
col_start: int
|
|
37
|
+
col_end: int
|
|
38
|
+
text: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Snapshot:
|
|
43
|
+
"""Reduced semantic view of a terminal screen.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
size: ``(rows, cols)``.
|
|
47
|
+
lines: rendered non-blank lines; collapsed blank runs are the literal
|
|
48
|
+
marker ``"..."``. Kept as ``(row, text, sel)`` tuples so positional
|
|
49
|
+
reasoning survives collapsing.
|
|
50
|
+
cursor: ``(row, col)``, 0-based.
|
|
51
|
+
cursor_hidden: whether the terminal cursor is hidden.
|
|
52
|
+
selected_line: best-guess active row index, or ``None``.
|
|
53
|
+
status_bar: bottom hint/status line text, or ``None``.
|
|
54
|
+
title: OSC window title, or ``None``.
|
|
55
|
+
menu_items: every highlighted span in reading order.
|
|
56
|
+
errors: lines flagged as errors, as ``(row, text, reason)``.
|
|
57
|
+
screen_reverse: DECSCNM (screen-wide reverse) active.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
size: Tuple[int, int]
|
|
61
|
+
lines: List[object] # list[tuple[int, str, bool]] | "..."
|
|
62
|
+
cursor: Tuple[int, int]
|
|
63
|
+
cursor_hidden: bool = False
|
|
64
|
+
selected_line: Optional[int] = None
|
|
65
|
+
selected: Optional[Span] = None
|
|
66
|
+
selected_reason: Optional[str] = None
|
|
67
|
+
status_bar: Optional[str] = None
|
|
68
|
+
status_bar_row: Optional[int] = None
|
|
69
|
+
title: Optional[str] = None
|
|
70
|
+
menu_items: List[Span] = field(default_factory=list)
|
|
71
|
+
errors: List[Tuple[int, str, str]] = field(default_factory=list)
|
|
72
|
+
screen_reverse: bool = False
|
|
73
|
+
|
|
74
|
+
# -- rendering ---------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def to_text(self) -> str:
|
|
77
|
+
"""Compact LLM-facing view: header line + visible screen.
|
|
78
|
+
|
|
79
|
+
The header summarises cursor, selection, status bar and any errors so the
|
|
80
|
+
agent gets the semantics without a per-cell color grid.
|
|
81
|
+
"""
|
|
82
|
+
rows, cols = self.size
|
|
83
|
+
header_bits = [f"cursor=r{self.cursor[0]}c{self.cursor[1]}"]
|
|
84
|
+
if self.cursor_hidden:
|
|
85
|
+
header_bits.append("cursor:hidden")
|
|
86
|
+
if self.selected is not None:
|
|
87
|
+
sel = self.selected
|
|
88
|
+
header_bits.append(
|
|
89
|
+
f"selected=r{sel.row}[{sel.col_start}:{sel.col_end}]"
|
|
90
|
+
f'"{sel.text}"({self.selected_reason})'
|
|
91
|
+
)
|
|
92
|
+
elif self.selected_line is not None:
|
|
93
|
+
header_bits.append(f"selected=r{self.selected_line}")
|
|
94
|
+
if self.status_bar:
|
|
95
|
+
header_bits.append(f'status="{self.status_bar}"')
|
|
96
|
+
if self.title:
|
|
97
|
+
header_bits.append(f'title="{self.title}"')
|
|
98
|
+
if self.errors:
|
|
99
|
+
header_bits.append(f"errors={len(self.errors)}")
|
|
100
|
+
if self.screen_reverse:
|
|
101
|
+
header_bits.append("screen_reverse")
|
|
102
|
+
|
|
103
|
+
header = f"[screen {rows}x{cols}] " + " ".join(header_bits)
|
|
104
|
+
|
|
105
|
+
body_lines = []
|
|
106
|
+
for entry in self.lines:
|
|
107
|
+
if entry == "...":
|
|
108
|
+
body_lines.append("...")
|
|
109
|
+
continue
|
|
110
|
+
row, text, sel = entry # type: ignore[misc]
|
|
111
|
+
marker = "*" if sel else " "
|
|
112
|
+
body_lines.append(f"{row:>3}{marker}| {text}")
|
|
113
|
+
|
|
114
|
+
return header + "\n" + "\n".join(body_lines)
|
|
115
|
+
|
|
116
|
+
def to_json(self, indent: Optional[int] = None) -> str:
|
|
117
|
+
"""Full structured JSON. Empty/None fields are omitted to save tokens."""
|
|
118
|
+
rows, cols = self.size
|
|
119
|
+
obj: dict = {
|
|
120
|
+
"size": {"rows": rows, "cols": cols},
|
|
121
|
+
"cursor": {
|
|
122
|
+
"row": self.cursor[0],
|
|
123
|
+
"col": self.cursor[1],
|
|
124
|
+
"hidden": self.cursor_hidden,
|
|
125
|
+
},
|
|
126
|
+
"lines": [
|
|
127
|
+
"..." if e == "..." else {
|
|
128
|
+
"row": e[0], # type: ignore[index]
|
|
129
|
+
"text": e[1], # type: ignore[index]
|
|
130
|
+
**({"sel": True} if e[2] else {}), # type: ignore[index]
|
|
131
|
+
}
|
|
132
|
+
for e in self.lines
|
|
133
|
+
],
|
|
134
|
+
}
|
|
135
|
+
if self.title:
|
|
136
|
+
obj["title"] = self.title
|
|
137
|
+
if self.selected is not None:
|
|
138
|
+
obj["selected"] = {
|
|
139
|
+
"row": self.selected.row,
|
|
140
|
+
"col_start": self.selected.col_start,
|
|
141
|
+
"col_end": self.selected.col_end,
|
|
142
|
+
"text": self.selected.text,
|
|
143
|
+
"reason": self.selected_reason,
|
|
144
|
+
}
|
|
145
|
+
regions: dict = {}
|
|
146
|
+
if self.status_bar is not None:
|
|
147
|
+
regions["status_bar"] = {
|
|
148
|
+
"row": self.status_bar_row,
|
|
149
|
+
"text": self.status_bar,
|
|
150
|
+
}
|
|
151
|
+
if self.menu_items:
|
|
152
|
+
regions["menu_items"] = [
|
|
153
|
+
{
|
|
154
|
+
"row": s.row,
|
|
155
|
+
"col_start": s.col_start,
|
|
156
|
+
"col_end": s.col_end,
|
|
157
|
+
"text": s.text,
|
|
158
|
+
}
|
|
159
|
+
for s in self.menu_items
|
|
160
|
+
]
|
|
161
|
+
if regions:
|
|
162
|
+
obj["regions"] = regions
|
|
163
|
+
hints: dict = {
|
|
164
|
+
"has_hidden_cursor": self.cursor_hidden,
|
|
165
|
+
"screen_reverse": self.screen_reverse,
|
|
166
|
+
}
|
|
167
|
+
if self.errors:
|
|
168
|
+
hints["errors"] = [
|
|
169
|
+
{"row": r, "text": t, "reason": reason}
|
|
170
|
+
for (r, t, reason) in self.errors
|
|
171
|
+
]
|
|
172
|
+
obj["hints"] = hints
|
|
173
|
+
return json.dumps(obj, indent=indent, ensure_ascii=False)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _contiguous_spans(cols: List[int]) -> List[Tuple[int, int]]:
|
|
177
|
+
"""``[0,1,2,5,6]`` -> ``[(0,3),(5,7)]`` (end exclusive)."""
|
|
178
|
+
spans: List[Tuple[int, int]] = []
|
|
179
|
+
for x in sorted(cols):
|
|
180
|
+
if spans and x == spans[-1][1]:
|
|
181
|
+
spans[-1] = (spans[-1][0], x + 1)
|
|
182
|
+
else:
|
|
183
|
+
spans.append((x, x + 1))
|
|
184
|
+
return spans
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def build_snapshot(model: ScreenModel) -> Snapshot:
|
|
188
|
+
"""Build a :class:`Snapshot` from a :class:`ScreenModel`.
|
|
189
|
+
|
|
190
|
+
The reverse-video predicate is measured relative to the screen's baseline
|
|
191
|
+
(``default_char.reverse``) so a full-screen-reverse app (DECSCNM) does not
|
|
192
|
+
report every line as selected.
|
|
193
|
+
"""
|
|
194
|
+
screen = model.screen
|
|
195
|
+
rows, cols = screen.lines, screen.columns
|
|
196
|
+
display = model.display
|
|
197
|
+
base_reverse = model.base_reverse
|
|
198
|
+
|
|
199
|
+
# ---- per-cell attribute scan, reduced to per-line facts ----
|
|
200
|
+
line_hi_spans: List[List[Tuple[int, int]]] = []
|
|
201
|
+
line_red: List[bool] = []
|
|
202
|
+
for y in range(rows):
|
|
203
|
+
cells = model.row_cells(y)
|
|
204
|
+
hi_cols: List[int] = []
|
|
205
|
+
red = False
|
|
206
|
+
for x, ch in enumerate(cells):
|
|
207
|
+
is_blank = ch.data == " "
|
|
208
|
+
# Selection/highlight signal: reverse-video, a distinct background,
|
|
209
|
+
# or bold. Foreground colour alone is deliberately NOT treated as a
|
|
210
|
+
# highlight — syntax-coloured REPL/output lines use non-default fg
|
|
211
|
+
# everywhere and would flood menu detection with false positives.
|
|
212
|
+
highlit = (
|
|
213
|
+
(ch.reverse != base_reverse)
|
|
214
|
+
or (ch.bg != "default")
|
|
215
|
+
or ch.bold
|
|
216
|
+
)
|
|
217
|
+
if is_blank and not highlit:
|
|
218
|
+
# plain padding: ignore
|
|
219
|
+
pass
|
|
220
|
+
elif highlit:
|
|
221
|
+
hi_cols.append(x)
|
|
222
|
+
if ch.fg in _RED_FGS:
|
|
223
|
+
red = True
|
|
224
|
+
line_hi_spans.append(_contiguous_spans(hi_cols))
|
|
225
|
+
line_red.append(red)
|
|
226
|
+
|
|
227
|
+
# ---- lines array with blank collapsing ----
|
|
228
|
+
lines_out: List[object] = []
|
|
229
|
+
blank_run = False
|
|
230
|
+
for y in range(rows):
|
|
231
|
+
text = display[y].rstrip()
|
|
232
|
+
if text == "":
|
|
233
|
+
if not blank_run and lines_out: # collapse; drop leading blanks
|
|
234
|
+
lines_out.append("...")
|
|
235
|
+
blank_run = True
|
|
236
|
+
continue
|
|
237
|
+
blank_run = False
|
|
238
|
+
sel = bool(line_hi_spans[y])
|
|
239
|
+
lines_out.append((y, text, sel))
|
|
240
|
+
while lines_out and lines_out[-1] == "...":
|
|
241
|
+
lines_out.pop()
|
|
242
|
+
|
|
243
|
+
# ---- menu items: every highlighted span with its text ----
|
|
244
|
+
menu_items: List[Span] = []
|
|
245
|
+
for y in range(rows):
|
|
246
|
+
for (a, b) in line_hi_spans[y]:
|
|
247
|
+
menu_items.append(Span(y, a, b, display[y][a:b].strip()))
|
|
248
|
+
|
|
249
|
+
# ---- selected: widest highlighted span, else cursor line ----
|
|
250
|
+
selected: Optional[Span] = None
|
|
251
|
+
selected_reason: Optional[str] = None
|
|
252
|
+
if menu_items:
|
|
253
|
+
selected = max(menu_items, key=lambda s: s.col_end - s.col_start)
|
|
254
|
+
selected_reason = "reverse_or_bg"
|
|
255
|
+
elif not screen.cursor.hidden:
|
|
256
|
+
cy = screen.cursor.y
|
|
257
|
+
selected = Span(cy, 0, cols, display[cy].rstrip())
|
|
258
|
+
selected_reason = "cursor_line"
|
|
259
|
+
|
|
260
|
+
# ---- status bar: last non-blank row if it sits in the bottom 1-2 rows ----
|
|
261
|
+
status_bar: Optional[str] = None
|
|
262
|
+
status_bar_row: Optional[int] = None
|
|
263
|
+
last_nonblank = None
|
|
264
|
+
for y in range(rows - 1, -1, -1):
|
|
265
|
+
if display[y].rstrip():
|
|
266
|
+
last_nonblank = y
|
|
267
|
+
break
|
|
268
|
+
if last_nonblank is not None and last_nonblank >= rows - 2:
|
|
269
|
+
status_bar = display[last_nonblank].rstrip()
|
|
270
|
+
status_bar_row = last_nonblank
|
|
271
|
+
|
|
272
|
+
# ---- errors: red fg lines or keyword matches ----
|
|
273
|
+
errors: List[Tuple[int, str, str]] = []
|
|
274
|
+
for y in range(rows):
|
|
275
|
+
text = display[y].rstrip()
|
|
276
|
+
if not text:
|
|
277
|
+
continue
|
|
278
|
+
if line_red[y]:
|
|
279
|
+
errors.append((y, text, "red_fg"))
|
|
280
|
+
elif _ERROR_RE.search(text):
|
|
281
|
+
errors.append((y, text, "keyword"))
|
|
282
|
+
|
|
283
|
+
return Snapshot(
|
|
284
|
+
size=(rows, cols),
|
|
285
|
+
lines=lines_out,
|
|
286
|
+
cursor=(screen.cursor.y, screen.cursor.x),
|
|
287
|
+
cursor_hidden=bool(screen.cursor.hidden),
|
|
288
|
+
selected_line=(selected.row if selected is not None else None),
|
|
289
|
+
selected=selected,
|
|
290
|
+
selected_reason=selected_reason,
|
|
291
|
+
status_bar=status_bar,
|
|
292
|
+
status_bar_row=status_bar_row,
|
|
293
|
+
title=(screen.title or None),
|
|
294
|
+
menu_items=menu_items,
|
|
295
|
+
errors=errors,
|
|
296
|
+
screen_reverse=base_reverse,
|
|
297
|
+
)
|