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,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
+ )