smartcli-toolkit 0.1.4__tar.gz → 0.1.6__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.
Files changed (27) hide show
  1. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/PKG-INFO +35 -17
  2. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/README.md +34 -16
  3. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/pyproject.toml +1 -1
  4. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/smartcli_core/__init__.py +1 -1
  5. smartcli_toolkit-0.1.6/smartcli_core/__main__.py +122 -0
  6. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/smartcli_core/screen_model.py +26 -0
  7. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/smartcli_core/session.py +51 -1
  8. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/smartcli_toolkit.egg-info/PKG-INFO +35 -17
  9. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/smartcli_toolkit.egg-info/SOURCES.txt +5 -1
  10. smartcli_toolkit-0.1.6/tests/test_char_width.py +99 -0
  11. smartcli_toolkit-0.1.6/tests/test_cpr_reply.py +136 -0
  12. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/tests/test_golden_frames.py +29 -1
  13. smartcli_toolkit-0.1.6/tests/test_wait_change.py +115 -0
  14. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/LICENSE +0 -0
  15. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/setup.cfg +0 -0
  16. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/smartcli_core/pty_backend.py +0 -0
  17. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/smartcli_core/py.typed +0 -0
  18. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/smartcli_core/readiness.py +0 -0
  19. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/smartcli_core/snapshot.py +0 -0
  20. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/smartcli_toolkit.egg-info/dependency_links.txt +0 -0
  21. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/smartcli_toolkit.egg-info/requires.txt +0 -0
  22. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/smartcli_toolkit.egg-info/top_level.txt +0 -0
  23. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/tests/test_degenerate_inputs.py +0 -0
  24. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/tests/test_doc_counts.py +0 -0
  25. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/tests/test_fx_contract.py +0 -0
  26. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/tests/test_readiness.py +0 -0
  27. {smartcli_toolkit-0.1.4 → smartcli_toolkit-0.1.6}/tests/test_vendor_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartcli-toolkit
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core).
5
5
  Author: dwgx
6
6
  License-Expression: MIT
@@ -45,6 +45,10 @@ Requires-Dist: coverage[toml]>=7.0; extra == "dev"
45
45
  Dynamic: license-file
46
46
 
47
47
  <!-- Language: English | [简体中文](docs/i18n/README.zh-Hans.md) | [繁體中文](docs/i18n/README.zh-Hant.md) | [日本語](docs/i18n/README.ja.md) | [한국어](docs/i18n/README.ko.md) -->
48
+ <!-- mcp-name: io.github.dwgx/smartcli -->
49
+ <!-- ^ MCP Registry PyPI ownership marker: this string must appear in the
50
+ published package's README (= PyPI description) and match server.json's
51
+ "name". Do not remove or change without updating server.json. -->
48
52
 
49
53
  # SmartCLI
50
54
 
@@ -61,6 +65,20 @@ Dynamic: license-file
61
65
  [![Skills: 3](https://img.shields.io/badge/skills-3-purple)](#features)
62
66
  [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey)](#install)
63
67
 
68
+ **Let an AI drive, perceive, and render real terminal programs.** SmartCLI reads
69
+ the actual screen with a `pyte` cell model — not a byte pipe — so it knows which
70
+ menu row is highlighted, presses the right keys, and waits for the screen to
71
+ settle. Below: it drives the real **lazygit** TUI end-to-end (arrow-key
72
+ navigation, opening a commit diff, highlighting a branch) — no script, no mock.
73
+
74
+ <p align="center">
75
+ <img src="showcase/drive-lazygit.gif" alt="SmartCLI driving the real lazygit TUI: navigating panels, opening a commit diff, highlighting a branch" width="700">
76
+ </p>
77
+
78
+ ```bash
79
+ pip install smartcli-toolkit
80
+ ```
81
+
64
82
  ## What & why
65
83
 
66
84
  SmartCLI is a workspace for terminal work that agents and humans both do: **driving**
@@ -75,16 +93,12 @@ in place from the checkout.
75
93
 
76
94
  ## Driving a real TUI
77
95
 
78
- SmartCLI driving **lazygit** — a real full-screen curses app — through its
79
- perceive → act → confirm loop: it reads the `pyte` cell grid (which row is
80
- selected, the alt-screen diff), moves with arrow keys, opens a commit's diff,
81
- and highlights a branch. This is captured by driving the actual program in a
82
- Linux container, not scripted or mocked. A byte-stream matcher like pexpect
83
- can't perceive "which row is highlighted"; a screen model can.
84
-
85
- <p align="center">
86
- <img src="showcase/drive-lazygit.gif" alt="SmartCLI driving the real lazygit TUI: navigating panels, opening a commit diff, highlighting a branch" width="700">
87
- </p>
96
+ The demo above is SmartCLI driving **lazygit** — a real full-screen curses app —
97
+ through its perceive → act → confirm loop: it reads the `pyte` cell grid (which
98
+ row is selected, the alt-screen diff), moves with arrow keys, opens a commit's
99
+ diff, and highlights a branch. Captured by driving the actual program in a Linux
100
+ container, not scripted or mocked. A byte-stream matcher like pexpect can't
101
+ perceive "which row is highlighted"; a screen model can.
88
102
 
89
103
  > Honest scope: CI runs a Windows + Linux + macOS matrix. The POSIX pty backend
90
104
  > (spawn / read / drive / resize / zombie-free terminate) is verified on Linux
@@ -171,13 +185,17 @@ set PYTHONIOENCODING=utf-8
171
185
  Verified dep versions on the dev box (Windows 11, CPython 3.14.6): `pyte` 0.8.2,
172
186
  `pywinpty` 3.0.5, `pyfiglet` 1.0.4, `Pillow` 12.2.0, `wcwidth` 0.8.1.
173
187
 
188
+ **Diagnostics.** `python -m smartcli_core` prints your OS, Python, terminal, PTY
189
+ backend, and dependency versions — run it and paste the output when filing a bug
190
+ (SmartCLI's behavior is very terminal- and platform-sensitive).
191
+
174
192
  ## Quickstart
175
193
 
176
194
  ### cmd-art — terminal visual effects
177
195
 
178
196
  ```bash
179
197
  cd skills/cmd-art
180
- python -m fx list # list all 19 effects
198
+ python -m fx list # list all 28 effects
181
199
  python -m fx play donut --seconds 5 # play one effect (bounded)
182
200
  python -m fx gallery # one frame of each effect
183
201
  python -m fx show --seq "donut:fire:3,plasma::3"
@@ -187,7 +205,7 @@ python -m fx show --seq "donut:fire:3,plasma::3"
187
205
 
188
206
  ```bash
189
207
  cd skills/tui-ui
190
- python -m ui widgets # list all 15 widgets
208
+ python -m ui widgets # list all 17 widgets
191
209
  python -m ui gallery --width 100 --height 30
192
210
  python -m ui demo table --width 80 --height 12 --theme dashboard
193
211
  ```
@@ -233,9 +251,9 @@ suite, see **[`README-USAGE.md`](README-USAGE.md)**.
233
251
  ## Features
234
252
 
235
253
  **`cmd-art`** (`skills/cmd-art`) — a "living-template" effect engine: an `Effect` ABC +
236
- `@register` decorator + auto-discovery. **19 effects** (donut, solarsystem, fire, plasma,
254
+ `@register` decorator + auto-discovery. **28 effects** (donut, solarsystem, fire, plasma,
237
255
  rain, starfield, tunnel, text3d, cube, sphere, boids, life, fireworks, sparkle, decrypt,
238
- gradient_text, banner_scroll, image2ascii, typewriter) across **8 themes** (mono, fire,
256
+ gradient_text, banner_scroll, image2ascii, typewriter, julia, mandelbrot, perlin, flames, water, nebula, text_flyin, text_converge, text_decrypt) across **8 themes** (mono, fire,
239
257
  ocean, synthwave, viridis, pastel, matrix-green, rainbow). Effects are pure frame
240
258
  producers; `play` is bounded by default and always restores the terminal.
241
259
 
@@ -267,9 +285,9 @@ cross-links. See [`knowledge/INDEX.md`](knowledge/INDEX.md).
267
285
  ```text
268
286
  SmartCLI/
269
287
  smartcli_core/ shared PTY + pyte engine (importable package)
270
- skills/cmd-art/ fx effect package and CLI (19 effects, 8 themes)
288
+ skills/cmd-art/ fx effect package and CLI (28 effects, 8 themes)
271
289
  skills/drive-tui/ TUI pattern library and PTY driver CLI (8 recipes)
272
- skills/tui-ui/ terminal UI layout engine and widgets (15 widgets)
290
+ skills/tui-ui/ terminal UI layout engine and widgets (17 widgets)
273
291
  tools/screenshot/ pyte -> PNG smoke-test harness
274
292
  tools/agentcli/ agent-CLI control validation harness
275
293
  knowledge/ 122-note knowledge graph (see knowledge/INDEX.md)
@@ -1,4 +1,8 @@
1
1
  <!-- Language: English | [简体中文](docs/i18n/README.zh-Hans.md) | [繁體中文](docs/i18n/README.zh-Hant.md) | [日本語](docs/i18n/README.ja.md) | [한국어](docs/i18n/README.ko.md) -->
2
+ <!-- mcp-name: io.github.dwgx/smartcli -->
3
+ <!-- ^ MCP Registry PyPI ownership marker: this string must appear in the
4
+ published package's README (= PyPI description) and match server.json's
5
+ "name". Do not remove or change without updating server.json. -->
2
6
 
3
7
  # SmartCLI
4
8
 
@@ -15,6 +19,20 @@
15
19
  [![Skills: 3](https://img.shields.io/badge/skills-3-purple)](#features)
16
20
  [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey)](#install)
17
21
 
22
+ **Let an AI drive, perceive, and render real terminal programs.** SmartCLI reads
23
+ the actual screen with a `pyte` cell model — not a byte pipe — so it knows which
24
+ menu row is highlighted, presses the right keys, and waits for the screen to
25
+ settle. Below: it drives the real **lazygit** TUI end-to-end (arrow-key
26
+ navigation, opening a commit diff, highlighting a branch) — no script, no mock.
27
+
28
+ <p align="center">
29
+ <img src="showcase/drive-lazygit.gif" alt="SmartCLI driving the real lazygit TUI: navigating panels, opening a commit diff, highlighting a branch" width="700">
30
+ </p>
31
+
32
+ ```bash
33
+ pip install smartcli-toolkit
34
+ ```
35
+
18
36
  ## What & why
19
37
 
20
38
  SmartCLI is a workspace for terminal work that agents and humans both do: **driving**
@@ -29,16 +47,12 @@ in place from the checkout.
29
47
 
30
48
  ## Driving a real TUI
31
49
 
32
- SmartCLI driving **lazygit** — a real full-screen curses app — through its
33
- perceive → act → confirm loop: it reads the `pyte` cell grid (which row is
34
- selected, the alt-screen diff), moves with arrow keys, opens a commit's diff,
35
- and highlights a branch. This is captured by driving the actual program in a
36
- Linux container, not scripted or mocked. A byte-stream matcher like pexpect
37
- can't perceive "which row is highlighted"; a screen model can.
38
-
39
- <p align="center">
40
- <img src="showcase/drive-lazygit.gif" alt="SmartCLI driving the real lazygit TUI: navigating panels, opening a commit diff, highlighting a branch" width="700">
41
- </p>
50
+ The demo above is SmartCLI driving **lazygit** — a real full-screen curses app —
51
+ through its perceive → act → confirm loop: it reads the `pyte` cell grid (which
52
+ row is selected, the alt-screen diff), moves with arrow keys, opens a commit's
53
+ diff, and highlights a branch. Captured by driving the actual program in a Linux
54
+ container, not scripted or mocked. A byte-stream matcher like pexpect can't
55
+ perceive "which row is highlighted"; a screen model can.
42
56
 
43
57
  > Honest scope: CI runs a Windows + Linux + macOS matrix. The POSIX pty backend
44
58
  > (spawn / read / drive / resize / zombie-free terminate) is verified on Linux
@@ -125,13 +139,17 @@ set PYTHONIOENCODING=utf-8
125
139
  Verified dep versions on the dev box (Windows 11, CPython 3.14.6): `pyte` 0.8.2,
126
140
  `pywinpty` 3.0.5, `pyfiglet` 1.0.4, `Pillow` 12.2.0, `wcwidth` 0.8.1.
127
141
 
142
+ **Diagnostics.** `python -m smartcli_core` prints your OS, Python, terminal, PTY
143
+ backend, and dependency versions — run it and paste the output when filing a bug
144
+ (SmartCLI's behavior is very terminal- and platform-sensitive).
145
+
128
146
  ## Quickstart
129
147
 
130
148
  ### cmd-art — terminal visual effects
131
149
 
132
150
  ```bash
133
151
  cd skills/cmd-art
134
- python -m fx list # list all 19 effects
152
+ python -m fx list # list all 28 effects
135
153
  python -m fx play donut --seconds 5 # play one effect (bounded)
136
154
  python -m fx gallery # one frame of each effect
137
155
  python -m fx show --seq "donut:fire:3,plasma::3"
@@ -141,7 +159,7 @@ python -m fx show --seq "donut:fire:3,plasma::3"
141
159
 
142
160
  ```bash
143
161
  cd skills/tui-ui
144
- python -m ui widgets # list all 15 widgets
162
+ python -m ui widgets # list all 17 widgets
145
163
  python -m ui gallery --width 100 --height 30
146
164
  python -m ui demo table --width 80 --height 12 --theme dashboard
147
165
  ```
@@ -187,9 +205,9 @@ suite, see **[`README-USAGE.md`](README-USAGE.md)**.
187
205
  ## Features
188
206
 
189
207
  **`cmd-art`** (`skills/cmd-art`) — a "living-template" effect engine: an `Effect` ABC +
190
- `@register` decorator + auto-discovery. **19 effects** (donut, solarsystem, fire, plasma,
208
+ `@register` decorator + auto-discovery. **28 effects** (donut, solarsystem, fire, plasma,
191
209
  rain, starfield, tunnel, text3d, cube, sphere, boids, life, fireworks, sparkle, decrypt,
192
- gradient_text, banner_scroll, image2ascii, typewriter) across **8 themes** (mono, fire,
210
+ gradient_text, banner_scroll, image2ascii, typewriter, julia, mandelbrot, perlin, flames, water, nebula, text_flyin, text_converge, text_decrypt) across **8 themes** (mono, fire,
193
211
  ocean, synthwave, viridis, pastel, matrix-green, rainbow). Effects are pure frame
194
212
  producers; `play` is bounded by default and always restores the terminal.
195
213
 
@@ -221,9 +239,9 @@ cross-links. See [`knowledge/INDEX.md`](knowledge/INDEX.md).
221
239
  ```text
222
240
  SmartCLI/
223
241
  smartcli_core/ shared PTY + pyte engine (importable package)
224
- skills/cmd-art/ fx effect package and CLI (19 effects, 8 themes)
242
+ skills/cmd-art/ fx effect package and CLI (28 effects, 8 themes)
225
243
  skills/drive-tui/ TUI pattern library and PTY driver CLI (8 recipes)
226
- skills/tui-ui/ terminal UI layout engine and widgets (15 widgets)
244
+ skills/tui-ui/ terminal UI layout engine and widgets (17 widgets)
227
245
  tools/screenshot/ pyte -> PNG smoke-test harness
228
246
  tools/agentcli/ agent-CLI control validation harness
229
247
  knowledge/ 122-note knowledge graph (see knowledge/INDEX.md)
@@ -30,7 +30,7 @@ build-backend = "setuptools.build_meta"
30
30
 
31
31
  [project]
32
32
  name = "smartcli-toolkit"
33
- version = "0.1.4"
33
+ version = "0.1.6"
34
34
  description = "Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core)."
35
35
  readme = "README.md"
36
36
  license = "MIT"
@@ -55,4 +55,4 @@ __all__ = [
55
55
  "KEY_MAP",
56
56
  ]
57
57
 
58
- __version__ = "0.1.4"
58
+ __version__ = "0.1.6"
@@ -0,0 +1,122 @@
1
+ """python -m smartcli_core — environment diagnostics.
2
+
3
+ Prints the OS, Python, terminal, and dependency facts that most affect how
4
+ SmartCLI behaves, so a bug report can start from ground truth instead of a
5
+ round-trip of "which OS? which terminal? is pywinpty installed?". Inspired by
6
+ `textual diagnose`, which its maintainers called the single most valuable thing
7
+ for cutting issue back-and-forth on a cross-platform, terminal-sensitive tool.
8
+
9
+ Pure/read-only: it imports nothing that spawns a process and never opens a PTY.
10
+ Run it and paste the output into an issue.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import platform
16
+ import sys
17
+
18
+
19
+ def _ver(mod_name: str, dist_name: str | None = None) -> str:
20
+ """Best-effort version string for an optional dependency, or a clear absent
21
+ marker. Tries the module's __version__, then importlib.metadata."""
22
+ import importlib
23
+ import importlib.metadata as md
24
+ import importlib.util
25
+
26
+ if importlib.util.find_spec(mod_name) is None:
27
+ return "not installed"
28
+ try:
29
+ mod = importlib.import_module(mod_name)
30
+ v = getattr(mod, "__version__", None)
31
+ if v:
32
+ return str(v)
33
+ except Exception:
34
+ pass
35
+ try:
36
+ return md.version(dist_name or mod_name)
37
+ except Exception:
38
+ return "installed (version unknown)"
39
+
40
+
41
+ def _section(title: str, rows: list[tuple[str, str]]) -> None:
42
+ print(f"\n{title}")
43
+ print("-" * len(title))
44
+ width = max((len(k) for k, _ in rows), default=0)
45
+ for k, v in rows:
46
+ print(f" {k:<{width}} {v}")
47
+
48
+
49
+ def main() -> int:
50
+ try:
51
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
52
+ except Exception:
53
+ pass
54
+
55
+ try:
56
+ from . import __version__ as core_version
57
+ except Exception:
58
+ core_version = "?"
59
+
60
+ print("=" * 60)
61
+ print("SmartCLI diagnostics")
62
+ print("=" * 60)
63
+
64
+ _section("SmartCLI", [
65
+ ("smartcli_core", core_version),
66
+ ("import path", os.path.dirname(os.path.abspath(__file__))),
67
+ ])
68
+
69
+ _section("Python & OS", [
70
+ ("python", sys.version.split()[0]),
71
+ ("implementation", platform.python_implementation()),
72
+ ("executable", sys.executable),
73
+ ("platform", platform.platform()),
74
+ ("machine", platform.machine()),
75
+ ])
76
+
77
+ # Terminal facts that change SmartCLI's behavior (isatty gates keyboard
78
+ # input; TERM/COLORTERM affect what programs emit; on Windows the PTY path
79
+ # is ConPTY via pywinpty).
80
+ is_win = sys.platform == "win32"
81
+ _section("Terminal", [
82
+ ("stdout.isatty()", str(sys.stdout.isatty())),
83
+ ("stdin.isatty()", str(sys.stdin.isatty())),
84
+ ("TERM", os.environ.get("TERM", "(unset)")),
85
+ ("COLORTERM", os.environ.get("COLORTERM", "(unset)")),
86
+ ("PYTHONIOENCODING", os.environ.get("PYTHONIOENCODING", "(unset)")),
87
+ ("stdout.encoding", getattr(sys.stdout, "encoding", "?") or "?"),
88
+ ])
89
+
90
+ _section("PTY backend & deps", [
91
+ ("default backend", "ConPTY (pywinpty)" if is_win else "POSIX pty (stdlib)"),
92
+ ("pyte", _ver("pyte")),
93
+ ("pywinpty", _ver("winpty", "pywinpty") if is_win else "n/a (POSIX)"),
94
+ ])
95
+
96
+ _section("Optional extras", [
97
+ ("pyfiglet", _ver("pyfiglet")),
98
+ ("Pillow (PIL)", _ver("PIL", "Pillow")),
99
+ ("wcwidth", _ver("wcwidth")),
100
+ ])
101
+
102
+ # Notes that commonly explain reports.
103
+ notes = []
104
+ if is_win and (os.environ.get("PYTHONIOENCODING", "").lower() not in ("utf-8", "utf8")):
105
+ notes.append("Windows without PYTHONIOENCODING=utf-8 — box/CJK glyphs may "
106
+ "crash on a legacy codepage. Set it before running.")
107
+ if _ver("pyte") == "not installed":
108
+ notes.append("pyte is REQUIRED and missing — `pip install smartcli-toolkit`.")
109
+ if is_win and _ver("winpty", "pywinpty") == "not installed":
110
+ notes.append("pywinpty missing on Windows — the ConPTY backend can't run.")
111
+ if notes:
112
+ print("\nNotes")
113
+ print("-----")
114
+ for n in notes:
115
+ print(f" ! {n}")
116
+
117
+ print()
118
+ return 0
119
+
120
+
121
+ if __name__ == "__main__":
122
+ sys.exit(main())
@@ -74,6 +74,32 @@ class ScreenModel:
74
74
  # feed raise) is not silently indistinguishable from the occasional
75
75
  # garbled byte run it is meant to tolerate.
76
76
  self.feed_errors = 0
77
+ # Device-query replies (DSR-CPR "ESC[6n", DA "ESC[c") that pyte generates
78
+ # while parsing. pyte routes them to Screen.write_process_input, which is a
79
+ # no-op by default — so a program that SYNCHRONOUSLY waits for a cursor-
80
+ # position report can stall/degrade because nothing answers. We capture
81
+ # them here (pyte builds the correct reply from its own cursor/attrs) and
82
+ # PtySession.pump() writes them back to the PTY. See drain_replies().
83
+ self._reply_buf = bytearray()
84
+ self.screen.write_process_input = self._collect_reply # type: ignore[method-assign]
85
+
86
+ def _collect_reply(self, data) -> None:
87
+ """pyte hands us the bytes/str it wants sent back to the process."""
88
+ if isinstance(data, str):
89
+ data = data.encode("utf-8", "replace")
90
+ self._reply_buf.extend(data)
91
+
92
+ def drain_replies(self) -> bytes:
93
+ """Return and clear any pending device-query replies pyte generated.
94
+
95
+ PtySession.pump() calls this after feed() and writes the result back to
96
+ the PTY, so DSR-CPR / DA queries from the driven program get answered.
97
+ """
98
+ if not self._reply_buf:
99
+ return b""
100
+ out = bytes(self._reply_buf)
101
+ self._reply_buf.clear()
102
+ return out
77
103
 
78
104
  # -- feeding -----------------------------------------------------------
79
105
 
@@ -20,6 +20,7 @@ Key tokens (``send_keys``) are mapped to escape bytes via :data:`KEY_MAP`.
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ import time
23
24
  from typing import List, Optional, Tuple, Union, Sequence
24
25
 
25
26
  from .pty_backend import PtyBackend, get_default_backend
@@ -177,10 +178,24 @@ class PtySession:
177
178
  # -- io ----------------------------------------------------------------
178
179
 
179
180
  def pump(self) -> bytes:
180
- """Read whatever is available and feed it into the screen. Returns bytes."""
181
+ """Read whatever is available and feed it into the screen. Returns bytes.
182
+
183
+ After feeding, answer any device-status/attribute queries the program
184
+ emitted (DSR-CPR ``ESC[6n``, DA ``ESC[c``): pyte builds the correct reply
185
+ from its own cursor/attr state, and we write it back to the PTY. Without
186
+ this, a program that synchronously waits for a cursor-position report can
187
+ stall or fall back to a degraded mode. Best-effort: a write failure here
188
+ must never break perception.
189
+ """
181
190
  data = self.backend.read_nonblocking()
182
191
  if data:
183
192
  self.model.feed(data)
193
+ reply = self.model.drain_replies()
194
+ if reply:
195
+ try:
196
+ self.backend.write(reply)
197
+ except Exception:
198
+ pass
184
199
  return data
185
200
 
186
201
  def send_text(self, text: str) -> None:
@@ -281,3 +296,38 @@ class PtySession:
281
296
  flags=flags,
282
297
  )
283
298
  return matched, snap # type: ignore[return-value]
299
+
300
+ def wait_change(
301
+ self,
302
+ baseline_hash: Optional[str] = None,
303
+ timeout_ms: int = 10000,
304
+ poll_ms: int = 30,
305
+ ) -> Tuple[bool, Snapshot]:
306
+ """Wait until the screen content changes away from ``baseline_hash``.
307
+
308
+ The precise "did my action land?" primitive: after sending input, block
309
+ until the screen's content hash differs from the baseline (by default,
310
+ the hash at the moment of the call). Returns (changed, snapshot) —
311
+ ``changed`` is False on timeout, and the snapshot is always the latest
312
+ screen so the caller can inspect it either way. Complements wait_stable
313
+ (settle) / wait_for (a specific marker): this catches ANY change, which
314
+ is what you want right after acting, and it can't false-positive on a
315
+ screen that was already showing the target text.
316
+
317
+ This is a thin session-level poll over the existing pump + content_hash;
318
+ it adds no new core state.
319
+ """
320
+ if baseline_hash is None:
321
+ # Baseline = the screen as it stands NOW, WITHOUT draining pending
322
+ # bytes first — otherwise the very output we're waiting for could be
323
+ # folded into the baseline and never register as a change.
324
+ baseline_hash = self.model.content_hash()
325
+ deadline = time.monotonic() + timeout_ms / 1000.0
326
+ poll_s = max(0.0, poll_ms / 1000.0)
327
+ while True:
328
+ self.pump()
329
+ if self.model.content_hash() != baseline_hash:
330
+ return True, self.snapshot()
331
+ if time.monotonic() >= deadline:
332
+ return False, self.snapshot()
333
+ time.sleep(poll_s)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartcli-toolkit
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core).
5
5
  Author: dwgx
6
6
  License-Expression: MIT
@@ -45,6 +45,10 @@ Requires-Dist: coverage[toml]>=7.0; extra == "dev"
45
45
  Dynamic: license-file
46
46
 
47
47
  <!-- Language: English | [简体中文](docs/i18n/README.zh-Hans.md) | [繁體中文](docs/i18n/README.zh-Hant.md) | [日本語](docs/i18n/README.ja.md) | [한국어](docs/i18n/README.ko.md) -->
48
+ <!-- mcp-name: io.github.dwgx/smartcli -->
49
+ <!-- ^ MCP Registry PyPI ownership marker: this string must appear in the
50
+ published package's README (= PyPI description) and match server.json's
51
+ "name". Do not remove or change without updating server.json. -->
48
52
 
49
53
  # SmartCLI
50
54
 
@@ -61,6 +65,20 @@ Dynamic: license-file
61
65
  [![Skills: 3](https://img.shields.io/badge/skills-3-purple)](#features)
62
66
  [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey)](#install)
63
67
 
68
+ **Let an AI drive, perceive, and render real terminal programs.** SmartCLI reads
69
+ the actual screen with a `pyte` cell model — not a byte pipe — so it knows which
70
+ menu row is highlighted, presses the right keys, and waits for the screen to
71
+ settle. Below: it drives the real **lazygit** TUI end-to-end (arrow-key
72
+ navigation, opening a commit diff, highlighting a branch) — no script, no mock.
73
+
74
+ <p align="center">
75
+ <img src="showcase/drive-lazygit.gif" alt="SmartCLI driving the real lazygit TUI: navigating panels, opening a commit diff, highlighting a branch" width="700">
76
+ </p>
77
+
78
+ ```bash
79
+ pip install smartcli-toolkit
80
+ ```
81
+
64
82
  ## What & why
65
83
 
66
84
  SmartCLI is a workspace for terminal work that agents and humans both do: **driving**
@@ -75,16 +93,12 @@ in place from the checkout.
75
93
 
76
94
  ## Driving a real TUI
77
95
 
78
- SmartCLI driving **lazygit** — a real full-screen curses app — through its
79
- perceive → act → confirm loop: it reads the `pyte` cell grid (which row is
80
- selected, the alt-screen diff), moves with arrow keys, opens a commit's diff,
81
- and highlights a branch. This is captured by driving the actual program in a
82
- Linux container, not scripted or mocked. A byte-stream matcher like pexpect
83
- can't perceive "which row is highlighted"; a screen model can.
84
-
85
- <p align="center">
86
- <img src="showcase/drive-lazygit.gif" alt="SmartCLI driving the real lazygit TUI: navigating panels, opening a commit diff, highlighting a branch" width="700">
87
- </p>
96
+ The demo above is SmartCLI driving **lazygit** — a real full-screen curses app —
97
+ through its perceive → act → confirm loop: it reads the `pyte` cell grid (which
98
+ row is selected, the alt-screen diff), moves with arrow keys, opens a commit's
99
+ diff, and highlights a branch. Captured by driving the actual program in a Linux
100
+ container, not scripted or mocked. A byte-stream matcher like pexpect can't
101
+ perceive "which row is highlighted"; a screen model can.
88
102
 
89
103
  > Honest scope: CI runs a Windows + Linux + macOS matrix. The POSIX pty backend
90
104
  > (spawn / read / drive / resize / zombie-free terminate) is verified on Linux
@@ -171,13 +185,17 @@ set PYTHONIOENCODING=utf-8
171
185
  Verified dep versions on the dev box (Windows 11, CPython 3.14.6): `pyte` 0.8.2,
172
186
  `pywinpty` 3.0.5, `pyfiglet` 1.0.4, `Pillow` 12.2.0, `wcwidth` 0.8.1.
173
187
 
188
+ **Diagnostics.** `python -m smartcli_core` prints your OS, Python, terminal, PTY
189
+ backend, and dependency versions — run it and paste the output when filing a bug
190
+ (SmartCLI's behavior is very terminal- and platform-sensitive).
191
+
174
192
  ## Quickstart
175
193
 
176
194
  ### cmd-art — terminal visual effects
177
195
 
178
196
  ```bash
179
197
  cd skills/cmd-art
180
- python -m fx list # list all 19 effects
198
+ python -m fx list # list all 28 effects
181
199
  python -m fx play donut --seconds 5 # play one effect (bounded)
182
200
  python -m fx gallery # one frame of each effect
183
201
  python -m fx show --seq "donut:fire:3,plasma::3"
@@ -187,7 +205,7 @@ python -m fx show --seq "donut:fire:3,plasma::3"
187
205
 
188
206
  ```bash
189
207
  cd skills/tui-ui
190
- python -m ui widgets # list all 15 widgets
208
+ python -m ui widgets # list all 17 widgets
191
209
  python -m ui gallery --width 100 --height 30
192
210
  python -m ui demo table --width 80 --height 12 --theme dashboard
193
211
  ```
@@ -233,9 +251,9 @@ suite, see **[`README-USAGE.md`](README-USAGE.md)**.
233
251
  ## Features
234
252
 
235
253
  **`cmd-art`** (`skills/cmd-art`) — a "living-template" effect engine: an `Effect` ABC +
236
- `@register` decorator + auto-discovery. **19 effects** (donut, solarsystem, fire, plasma,
254
+ `@register` decorator + auto-discovery. **28 effects** (donut, solarsystem, fire, plasma,
237
255
  rain, starfield, tunnel, text3d, cube, sphere, boids, life, fireworks, sparkle, decrypt,
238
- gradient_text, banner_scroll, image2ascii, typewriter) across **8 themes** (mono, fire,
256
+ gradient_text, banner_scroll, image2ascii, typewriter, julia, mandelbrot, perlin, flames, water, nebula, text_flyin, text_converge, text_decrypt) across **8 themes** (mono, fire,
239
257
  ocean, synthwave, viridis, pastel, matrix-green, rainbow). Effects are pure frame
240
258
  producers; `play` is bounded by default and always restores the terminal.
241
259
 
@@ -267,9 +285,9 @@ cross-links. See [`knowledge/INDEX.md`](knowledge/INDEX.md).
267
285
  ```text
268
286
  SmartCLI/
269
287
  smartcli_core/ shared PTY + pyte engine (importable package)
270
- skills/cmd-art/ fx effect package and CLI (19 effects, 8 themes)
288
+ skills/cmd-art/ fx effect package and CLI (28 effects, 8 themes)
271
289
  skills/drive-tui/ TUI pattern library and PTY driver CLI (8 recipes)
272
- skills/tui-ui/ terminal UI layout engine and widgets (15 widgets)
290
+ skills/tui-ui/ terminal UI layout engine and widgets (17 widgets)
273
291
  tools/screenshot/ pyte -> PNG smoke-test harness
274
292
  tools/agentcli/ agent-CLI control validation harness
275
293
  knowledge/ 122-note knowledge graph (see knowledge/INDEX.md)
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  smartcli_core/__init__.py
5
+ smartcli_core/__main__.py
5
6
  smartcli_core/pty_backend.py
6
7
  smartcli_core/py.typed
7
8
  smartcli_core/readiness.py
@@ -13,9 +14,12 @@ smartcli_toolkit.egg-info/SOURCES.txt
13
14
  smartcli_toolkit.egg-info/dependency_links.txt
14
15
  smartcli_toolkit.egg-info/requires.txt
15
16
  smartcli_toolkit.egg-info/top_level.txt
17
+ tests/test_char_width.py
18
+ tests/test_cpr_reply.py
16
19
  tests/test_degenerate_inputs.py
17
20
  tests/test_doc_counts.py
18
21
  tests/test_fx_contract.py
19
22
  tests/test_golden_frames.py
20
23
  tests/test_readiness.py
21
- tests/test_vendor_sync.py
24
+ tests/test_vendor_sync.py
25
+ tests/test_wait_change.py
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ """test_char_width.py — width function: default stability + the new knobs.
3
+
4
+ Character cell width is a coordination problem (terminals ship different Unicode
5
+ DBs). ui.core.width/char_width gained two optional knobs — unicode_version and
6
+ ambiguous_wide — so a caller can pin the answer to its terminal. This locks:
7
+ * defaults are byte-identical to the old behavior (so golden/fx baselines
8
+ don't move),
9
+ * ambiguous_wide flips East-Asian Ambiguous glyphs 1<->2 without touching
10
+ unambiguous ones,
11
+ * CJK / emoji / ZWJ / combining widths are unaffected by the knob,
12
+ * pinning unicode_version doesn't crash.
13
+
14
+ Pure/in-memory: imports the ui package, computes widths. No process, no PTY.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ ROOT = Path(__file__).resolve().parents[1]
22
+ sys.path.insert(0, str(ROOT / "skills" / "tui-ui"))
23
+
24
+ from ui.core import char_width, width # noqa: E402
25
+
26
+ failures = 0
27
+
28
+
29
+ def check(cond: bool, label: str, detail: str = "") -> None:
30
+ global failures
31
+ if not cond:
32
+ failures += 1
33
+ print(f"{'PASS' if cond else 'FAIL'} {label}" + (f" -- {detail}" if detail else ""))
34
+
35
+
36
+ # East-Asian Ambiguous sample chars (unicodedata.east_asian_width == 'A').
37
+ AMBIGUOUS = ["§", "±", "…", "○", "×"]
38
+ # Unambiguous references that must never change with the knob.
39
+ CJK = "你好" # each 2 cells
40
+ ASCII = "hello" # each 1 cell
41
+ EMOJI = "😀" # 2 cells
42
+ ZWJ = "‍" # 0 cells
43
+
44
+
45
+ def test_defaults_unchanged() -> None:
46
+ # Default: ambiguous is narrow (1), matching the pre-change behavior.
47
+ for c in AMBIGUOUS:
48
+ check(char_width(c) == 1, f"default: ambiguous U+{ord(c):04X} is 1 cell",
49
+ detail=str(char_width(c)))
50
+ check(width(ASCII) == 5, "default: 'hello' is 5", detail=str(width(ASCII)))
51
+ check(width(CJK) == 4, "default: 2 CJK chars are 4", detail=str(width(CJK)))
52
+ check(char_width(EMOJI) == 2, "default: emoji is 2")
53
+ check(char_width(ZWJ) == 0, "default: ZWJ is 0")
54
+
55
+
56
+ def test_ambiguous_wide_knob() -> None:
57
+ # With the CJK-locale knob, ambiguous glyphs count as 2.
58
+ for c in AMBIGUOUS:
59
+ check(char_width(c, ambiguous_wide=True) == 2,
60
+ f"ambiguous_wide: U+{ord(c):04X} is 2 cells",
61
+ detail=str(char_width(c, ambiguous_wide=True)))
62
+ # A whole string of ambiguous chars scales.
63
+ s = "".join(AMBIGUOUS)
64
+ check(width(s, ambiguous_wide=True) == 2 * len(AMBIGUOUS),
65
+ "ambiguous_wide: string doubles", detail=str(width(s, ambiguous_wide=True)))
66
+
67
+
68
+ def test_knob_leaves_unambiguous_alone() -> None:
69
+ # The knob must ONLY affect ambiguous chars — CJK/ASCII/emoji/ZWJ unchanged.
70
+ check(width(ASCII, ambiguous_wide=True) == 5, "knob: ASCII still 5")
71
+ check(width(CJK, ambiguous_wide=True) == 4, "knob: CJK still 4")
72
+ check(char_width(EMOJI, ambiguous_wide=True) == 2, "knob: emoji still 2")
73
+ check(char_width(ZWJ, ambiguous_wide=True) == 0, "knob: ZWJ still 0")
74
+
75
+
76
+ def test_unicode_version_pin() -> None:
77
+ # Pinning a version must not crash and must return a sane width for ASCII.
78
+ try:
79
+ w = width("hello", unicode_version="9.0.0")
80
+ check(w == 5, "unicode_version='9.0.0' gives 'hello'==5", detail=str(w))
81
+ except Exception as exc:
82
+ check(False, "unicode_version pin did not crash", detail=repr(exc))
83
+
84
+
85
+ def main() -> int:
86
+ test_defaults_unchanged()
87
+ test_ambiguous_wide_knob()
88
+ test_knob_leaves_unambiguous_alone()
89
+ test_unicode_version_pin()
90
+ print()
91
+ if failures:
92
+ print(f"{failures} FAILURE(S)")
93
+ return 1
94
+ print("ALL PASS")
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ sys.exit(main())
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ """test_cpr_reply.py — device-query auto-answer regression (DSR-CPR / DA).
3
+
4
+ A program that emits a cursor-position query (``ESC[6n``) or a device-attributes
5
+ query (``ESC[c``) and then SYNCHRONOUSLY waits for the reply will stall or fall
6
+ back to a degraded mode if nothing answers. pyte generates the correct reply
7
+ (from its own cursor/attr state) and routes it to Screen.write_process_input;
8
+ SmartCLI now captures that and PtySession.pump() writes it back to the PTY.
9
+
10
+ This locks that wiring. Pure/in-memory: a fake backend feeds the query bytes and
11
+ captures what gets written back — no PTY, no process. Mutation check: if pump()
12
+ stops writing the reply (or ScreenModel stops capturing it), the writes-back
13
+ assertions fail.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ ROOT = Path(__file__).resolve().parents[1]
21
+ sys.path.insert(0, str(ROOT))
22
+
23
+ from smartcli_core import PtySession, ScreenModel # noqa: E402
24
+ from smartcli_core.pty_backend import PtyBackend # noqa: E402
25
+
26
+ failures = 0
27
+
28
+
29
+ def check(cond: bool, label: str, detail: str = "") -> None:
30
+ global failures
31
+ if not cond:
32
+ failures += 1
33
+ print(f"{'PASS' if cond else 'FAIL'} {label}" + (f" -- {detail}" if detail else ""))
34
+
35
+
36
+ class FakeBackend(PtyBackend):
37
+ """A minimal backend: hands out queued bytes on read, captures writes."""
38
+
39
+ def __init__(self) -> None:
40
+ self._to_read: list[bytes] = []
41
+ self.written = bytearray()
42
+ self._alive = True
43
+
44
+ def queue(self, data: bytes) -> None:
45
+ self._to_read.append(data)
46
+
47
+ # PtyBackend interface --------------------------------------------------
48
+ def spawn(self, cmd, cols, rows) -> None: # noqa: D401
49
+ pass
50
+
51
+ def read_nonblocking(self, timeout: float = 0.0) -> bytes:
52
+ return self._to_read.pop(0) if self._to_read else b""
53
+
54
+ def write(self, data: bytes) -> None:
55
+ self.written.extend(data)
56
+
57
+ def resize(self, cols: int, rows: int) -> None:
58
+ pass
59
+
60
+ def is_alive(self) -> bool:
61
+ return self._alive
62
+
63
+ def terminate(self) -> None:
64
+ self._alive = False
65
+
66
+
67
+ def test_screenmodel_captures_replies() -> None:
68
+ m = ScreenModel(80, 24)
69
+ # Move cursor to row 3, col 5 (1-based), then ask for a cursor-position report.
70
+ m.feed(b"\x1b[3;5H\x1b[6n")
71
+ reply = m.drain_replies()
72
+ check(reply == b"\x1b[3;5R", "ScreenModel answers DSR-CPR from its cursor",
73
+ detail=repr(reply))
74
+ # drain is one-shot.
75
+ check(m.drain_replies() == b"", "drain_replies clears after read")
76
+ # Device-attributes query.
77
+ m.feed(b"\x1b[c")
78
+ check(m.drain_replies() == b"\x1b[?6c", "ScreenModel answers DA1")
79
+
80
+
81
+ def test_pump_writes_reply_back() -> None:
82
+ be = FakeBackend()
83
+ sess = PtySession(cols=80, rows=24, backend=be)
84
+ # Program positions the cursor and queries it, all in one read.
85
+ be.queue(b"hello\x1b[2;3H\x1b[6n")
86
+ data = sess.pump()
87
+ check(b"hello" in data, "pump returns the screen bytes it read")
88
+ check(be.written == b"\x1b[2;3R", "pump wrote the CPR reply back to the PTY",
89
+ detail=repr(bytes(be.written)))
90
+
91
+
92
+ def test_no_query_no_write() -> None:
93
+ be = FakeBackend()
94
+ sess = PtySession(cols=80, rows=24, backend=be)
95
+ be.queue(b"just some text, no queries")
96
+ sess.pump()
97
+ check(be.written == b"", "no device query -> nothing written back",
98
+ detail=repr(bytes(be.written)))
99
+
100
+
101
+ def test_reply_survives_resize() -> None:
102
+ # pyte.Screen.resize is in-place (doesn't rebuild the screen or reset
103
+ # write_process_input), so the reply binding must survive a resize.
104
+ m = ScreenModel(80, 24)
105
+ m.resize(100, 30)
106
+ m.feed(b"\x1b[2;2H\x1b[6n")
107
+ check(m.drain_replies() == b"\x1b[2;2R", "CPR still answered after resize")
108
+
109
+
110
+ def test_no_feedback_loop() -> None:
111
+ # Our own replies, fed back in (as an echoing PTY might), must NOT generate a
112
+ # second reply — otherwise a CPR/DA answer could amplify into a loop. The
113
+ # reply terminators (R / ?..c / ..n status) are not queries.
114
+ m = ScreenModel(80, 24)
115
+ m.feed(b"\x1b[3;5R") # a CPR reply
116
+ m.feed(b"\x1b[?6c") # a DA reply
117
+ m.feed(b"\x1b[0n") # a DSR status reply
118
+ check(m.drain_replies() == b"", "replies fed back produce no second reply")
119
+
120
+
121
+ def main() -> int:
122
+ test_screenmodel_captures_replies()
123
+ test_pump_writes_reply_back()
124
+ test_no_query_no_write()
125
+ test_reply_survives_resize()
126
+ test_no_feedback_loop()
127
+ print()
128
+ if failures:
129
+ print(f"{failures} FAILURE(S)")
130
+ return 1
131
+ print("ALL PASS")
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())
@@ -49,6 +49,20 @@ WIDTH, HEIGHT = 60, 12
49
49
  THEME = "dashboard"
50
50
 
51
51
  results: list[tuple[str, bool, str]] = []
52
+ skipped: list[str] = []
53
+
54
+ # Widgets whose rendered frame depends on an OPTIONAL dependency: their output
55
+ # differs between "dep present" (real render) and "dep absent" (graceful stdlib
56
+ # fallback), so a single baseline can't cover both. When the dep is missing we
57
+ # SKIP the widget rather than fail — the baseline is only meaningful when the dep
58
+ # matches the environment that generated it. (banner -> pyfiglet FIGlet fonts;
59
+ # the baseline is the pyfiglet render. CI installs .[all] so it's exercised.)
60
+ OPTIONAL_DEP = {"banner": "pyfiglet"}
61
+
62
+
63
+ def _dep_available(mod: str) -> bool:
64
+ import importlib.util
65
+ return importlib.util.find_spec(mod) is not None
52
66
 
53
67
 
54
68
  def record(name: str, ok: bool, detail: str = "") -> None:
@@ -56,6 +70,11 @@ def record(name: str, ok: bool, detail: str = "") -> None:
56
70
  print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" -- {detail}" if detail else ""))
57
71
 
58
72
 
73
+ def note_skip(name: str, detail: str) -> None:
74
+ skipped.append(name)
75
+ print(f"SKIP {name} -- {detail}")
76
+
77
+
59
78
  def render_widget(cls) -> str:
60
79
  """Render ONE widget to a deterministic ANSI frame — mirrors ui.cli.cmd_demo:
61
80
  sample(theme) -> Box -> Page.to_ansi()."""
@@ -92,6 +111,14 @@ def _first_diff(a: str, b: str) -> str:
92
111
 
93
112
  def check_widget(cls, update: bool) -> None:
94
113
  key = cls.key
114
+ # If this widget's render depends on an optional dep that's absent, its
115
+ # fallback output won't match the baseline (generated with the dep present).
116
+ # Skip rather than fail — a missing optional dep is not a regression.
117
+ dep = OPTIONAL_DEP.get(key)
118
+ if dep and not _dep_available(dep) and not update:
119
+ note_skip(f"golden {key}", f"optional dep '{dep}' absent — baseline needs it")
120
+ return
121
+
95
122
  # Determinism gate: render twice, must be byte-identical. A widget that
96
123
  # depends on time/random would trip here instead of baking flakiness in.
97
124
  frame1 = render_widget(cls)
@@ -147,9 +174,10 @@ def main(argv: list[str]) -> int:
147
174
  check_widget(cls, update)
148
175
 
149
176
  failed = [r for r in results if not r[1]]
177
+ skip_note = f", {len(skipped)} skipped (optional dep absent)" if skipped else ""
150
178
  print(f"\n{len(results) - len(failed)}/{len(results)} widgets "
151
179
  f"{'baselined' if update else 'match golden frames'} "
152
- f"(size {WIDTH}x{HEIGHT}, theme {THEME})")
180
+ f"(size {WIDTH}x{HEIGHT}, theme {THEME}){skip_note}")
153
181
  if failed and not update:
154
182
  print("golden-frame drift — review, then `--update` if intended:")
155
183
  for name, _ok, detail in failed:
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env python3
2
+ """test_wait_change.py — PtySession.wait_change regression (deterministic).
3
+
4
+ wait_change is the precise "did my action land?" primitive: block until the
5
+ screen content hash changes away from a baseline. This locks:
6
+ * baseline is the screen NOW, without draining pending bytes first (so the
7
+ awaited output can't be folded into the baseline and missed),
8
+ * a real content change returns (True, snapshot),
9
+ * no change within the window returns (False, latest snapshot),
10
+ * an explicit baseline_hash lets a caller wait for change from a known state.
11
+
12
+ Pure/in-memory: a fake backend feeds bytes on demand. No PTY, no process.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ ROOT = Path(__file__).resolve().parents[1]
20
+ sys.path.insert(0, str(ROOT))
21
+
22
+ from smartcli_core import PtySession # noqa: E402
23
+ from smartcli_core.pty_backend import PtyBackend # noqa: E402
24
+
25
+ failures = 0
26
+
27
+
28
+ def check(cond, label, detail=""):
29
+ global failures
30
+ if not cond:
31
+ failures += 1
32
+ print(f"{'PASS' if cond else 'FAIL'} {label}" + (f" -- {detail}" if detail else ""))
33
+
34
+
35
+ class FakeBackend(PtyBackend):
36
+ def __init__(self):
37
+ self._q = []
38
+ self._alive = True
39
+
40
+ def queue(self, data):
41
+ self._q.append(data)
42
+
43
+ def spawn(self, cmd, cols, rows):
44
+ pass
45
+
46
+ def read_nonblocking(self, timeout=0.0):
47
+ return self._q.pop(0) if self._q else b""
48
+
49
+ def write(self, data):
50
+ pass
51
+
52
+ def resize(self, cols, rows):
53
+ pass
54
+
55
+ def is_alive(self):
56
+ return self._alive
57
+
58
+ def terminate(self):
59
+ self._alive = False
60
+
61
+
62
+ def test_change_detected():
63
+ be = FakeBackend()
64
+ sess = PtySession(cols=40, rows=10, backend=be)
65
+ be.queue(b"hello world")
66
+ changed, snap = sess.wait_change(timeout_ms=2000)
67
+ check(changed is True, "content change is detected")
68
+ check("hello" in snap.to_text(), "returned snapshot shows the new content")
69
+
70
+
71
+ def test_no_change_times_out():
72
+ be = FakeBackend()
73
+ sess = PtySession(cols=40, rows=10, backend=be)
74
+ changed, snap = sess.wait_change(timeout_ms=250)
75
+ check(changed is False, "no change within the window -> False (timeout)")
76
+ check(snap is not None, "timeout still returns the latest snapshot")
77
+
78
+
79
+ def test_baseline_not_polluted():
80
+ # The bug this guards: if wait_change drained pending bytes to form the
81
+ # baseline, the awaited output would be in the baseline and never seen.
82
+ be = FakeBackend()
83
+ sess = PtySession(cols=40, rows=10, backend=be)
84
+ be.queue(b"the output I am waiting for") # already queued before the call
85
+ changed, _ = sess.wait_change(timeout_ms=2000)
86
+ check(changed is True, "pending bytes at call time still count as a change")
87
+
88
+
89
+ def test_explicit_baseline():
90
+ be = FakeBackend()
91
+ sess = PtySession(cols=40, rows=10, backend=be)
92
+ be.queue(b"first")
93
+ sess.wait_change(timeout_ms=2000) # consume 'first'
94
+ h = sess.model.content_hash()
95
+ be.queue(b"\x1b[2J\x1b[Hsecond") # clear + new
96
+ changed, snap = sess.wait_change(baseline_hash=h, timeout_ms=2000)
97
+ check(changed is True, "explicit baseline_hash detects change from a known state")
98
+ check("second" in snap.to_text(), "snapshot reflects the change")
99
+
100
+
101
+ def main():
102
+ test_change_detected()
103
+ test_no_change_times_out()
104
+ test_baseline_not_polluted()
105
+ test_explicit_baseline()
106
+ print()
107
+ if failures:
108
+ print(f"{failures} FAILURE(S)")
109
+ return 1
110
+ print("ALL PASS")
111
+ return 0
112
+
113
+
114
+ if __name__ == "__main__":
115
+ sys.exit(main())