smartcli-toolkit 0.1.6__tar.gz → 0.1.8__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 (30) hide show
  1. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/PKG-INFO +5 -5
  2. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/README.md +4 -4
  3. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/pyproject.toml +1 -1
  4. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_core/__init__.py +3 -2
  5. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_core/readiness.py +62 -1
  6. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_core/session.py +28 -1
  7. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_toolkit.egg-info/PKG-INFO +5 -5
  8. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_toolkit.egg-info/SOURCES.txt +3 -0
  9. smartcli_toolkit-0.1.8/tests/test_sixel.py +211 -0
  10. smartcli_toolkit-0.1.8/tests/test_tbench_adapter.py +217 -0
  11. smartcli_toolkit-0.1.8/tests/test_wait_any.py +149 -0
  12. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/LICENSE +0 -0
  13. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/setup.cfg +0 -0
  14. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_core/__main__.py +0 -0
  15. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_core/pty_backend.py +0 -0
  16. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_core/py.typed +0 -0
  17. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_core/screen_model.py +0 -0
  18. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_core/snapshot.py +0 -0
  19. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_toolkit.egg-info/dependency_links.txt +0 -0
  20. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_toolkit.egg-info/requires.txt +0 -0
  21. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/smartcli_toolkit.egg-info/top_level.txt +0 -0
  22. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/tests/test_char_width.py +0 -0
  23. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/tests/test_cpr_reply.py +0 -0
  24. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/tests/test_degenerate_inputs.py +0 -0
  25. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/tests/test_doc_counts.py +0 -0
  26. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/tests/test_fx_contract.py +0 -0
  27. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/tests/test_golden_frames.py +0 -0
  28. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/tests/test_readiness.py +0 -0
  29. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/tests/test_vendor_sync.py +0 -0
  30. {smartcli_toolkit-0.1.6 → smartcli_toolkit-0.1.8}/tests/test_wait_change.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartcli-toolkit
3
- Version: 0.1.6
3
+ Version: 0.1.8
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
@@ -195,7 +195,7 @@ backend, and dependency versions — run it and paste the output when filing a b
195
195
 
196
196
  ```bash
197
197
  cd skills/cmd-art
198
- python -m fx list # list all 28 effects
198
+ python -m fx list # list all 30 effects
199
199
  python -m fx play donut --seconds 5 # play one effect (bounded)
200
200
  python -m fx gallery # one frame of each effect
201
201
  python -m fx show --seq "donut:fire:3,plasma::3"
@@ -251,9 +251,9 @@ suite, see **[`README-USAGE.md`](README-USAGE.md)**.
251
251
  ## Features
252
252
 
253
253
  **`cmd-art`** (`skills/cmd-art`) — a "living-template" effect engine: an `Effect` ABC +
254
- `@register` decorator + auto-discovery. **28 effects** (donut, solarsystem, fire, plasma,
254
+ `@register` decorator + auto-discovery. **30 effects** (donut, solarsystem, fire, plasma,
255
255
  rain, starfield, tunnel, text3d, cube, sphere, boids, life, fireworks, sparkle, decrypt,
256
- gradient_text, banner_scroll, image2ascii, typewriter, julia, mandelbrot, perlin, flames, water, nebula, text_flyin, text_converge, text_decrypt) across **8 themes** (mono, fire,
256
+ gradient_text, banner_scroll, image2ascii, typewriter, julia, mandelbrot, perlin, flames, water, nebula, text_flyin, text_converge, text_decrypt, spectrum_bars, cbonsai) across **8 themes** (mono, fire,
257
257
  ocean, synthwave, viridis, pastel, matrix-green, rainbow). Effects are pure frame
258
258
  producers; `play` is bounded by default and always restores the terminal.
259
259
 
@@ -285,7 +285,7 @@ cross-links. See [`knowledge/INDEX.md`](knowledge/INDEX.md).
285
285
  ```text
286
286
  SmartCLI/
287
287
  smartcli_core/ shared PTY + pyte engine (importable package)
288
- skills/cmd-art/ fx effect package and CLI (28 effects, 8 themes)
288
+ skills/cmd-art/ fx effect package and CLI (30 effects, 8 themes)
289
289
  skills/drive-tui/ TUI pattern library and PTY driver CLI (8 recipes)
290
290
  skills/tui-ui/ terminal UI layout engine and widgets (17 widgets)
291
291
  tools/screenshot/ pyte -> PNG smoke-test harness
@@ -149,7 +149,7 @@ backend, and dependency versions — run it and paste the output when filing a b
149
149
 
150
150
  ```bash
151
151
  cd skills/cmd-art
152
- python -m fx list # list all 28 effects
152
+ python -m fx list # list all 30 effects
153
153
  python -m fx play donut --seconds 5 # play one effect (bounded)
154
154
  python -m fx gallery # one frame of each effect
155
155
  python -m fx show --seq "donut:fire:3,plasma::3"
@@ -205,9 +205,9 @@ suite, see **[`README-USAGE.md`](README-USAGE.md)**.
205
205
  ## Features
206
206
 
207
207
  **`cmd-art`** (`skills/cmd-art`) — a "living-template" effect engine: an `Effect` ABC +
208
- `@register` decorator + auto-discovery. **28 effects** (donut, solarsystem, fire, plasma,
208
+ `@register` decorator + auto-discovery. **30 effects** (donut, solarsystem, fire, plasma,
209
209
  rain, starfield, tunnel, text3d, cube, sphere, boids, life, fireworks, sparkle, decrypt,
210
- gradient_text, banner_scroll, image2ascii, typewriter, julia, mandelbrot, perlin, flames, water, nebula, text_flyin, text_converge, text_decrypt) across **8 themes** (mono, fire,
210
+ gradient_text, banner_scroll, image2ascii, typewriter, julia, mandelbrot, perlin, flames, water, nebula, text_flyin, text_converge, text_decrypt, spectrum_bars, cbonsai) across **8 themes** (mono, fire,
211
211
  ocean, synthwave, viridis, pastel, matrix-green, rainbow). Effects are pure frame
212
212
  producers; `play` is bounded by default and always restores the terminal.
213
213
 
@@ -239,7 +239,7 @@ cross-links. See [`knowledge/INDEX.md`](knowledge/INDEX.md).
239
239
  ```text
240
240
  SmartCLI/
241
241
  smartcli_core/ shared PTY + pyte engine (importable package)
242
- skills/cmd-art/ fx effect package and CLI (28 effects, 8 themes)
242
+ skills/cmd-art/ fx effect package and CLI (30 effects, 8 themes)
243
243
  skills/drive-tui/ TUI pattern library and PTY driver CLI (8 recipes)
244
244
  skills/tui-ui/ terminal UI layout engine and widgets (17 widgets)
245
245
  tools/screenshot/ pyte -> PNG smoke-test harness
@@ -30,7 +30,7 @@ build-backend = "setuptools.build_meta"
30
30
 
31
31
  [project]
32
32
  name = "smartcli-toolkit"
33
- version = "0.1.6"
33
+ version = "0.1.8"
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"
@@ -27,7 +27,7 @@ from .pty_backend import (
27
27
  WinptyBackend,
28
28
  get_default_backend,
29
29
  )
30
- from .readiness import wait_for_regex, wait_ready, wait_until_stable
30
+ from .readiness import wait_any, wait_for_regex, wait_ready, wait_until_stable
31
31
  from .screen_model import CellAttrs, ScreenModel, safe_screen_display
32
32
  from .session import KEY_MAP, PtySession
33
33
  from .snapshot import Snapshot, Span, build_snapshot
@@ -50,9 +50,10 @@ __all__ = [
50
50
  "wait_until_stable",
51
51
  "wait_for_regex",
52
52
  "wait_ready",
53
+ "wait_any",
53
54
  # session
54
55
  "PtySession",
55
56
  "KEY_MAP",
56
57
  ]
57
58
 
58
- __version__ = "0.1.6"
59
+ __version__ = "0.1.8"
@@ -21,7 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  import re
23
23
  import time
24
- from typing import Callable, Optional, Tuple
24
+ from typing import Callable, Optional, Sequence, Tuple
25
25
 
26
26
  # Callable aliases (documentation only)
27
27
  ReadFn = Callable[[], bytes] # pump: read+feed one batch, return bytes read
@@ -155,6 +155,67 @@ def wait_for_regex(
155
155
  time.sleep(poll)
156
156
 
157
157
 
158
+ def wait_any(
159
+ read_fn: ReadFn,
160
+ get_text_fn: TextFn,
161
+ get_snapshot_fn: Callable[[], object],
162
+ patterns: Sequence[str],
163
+ timeout_ms: int = 10000,
164
+ poll_ms: int = 30,
165
+ min_wait_ms: int = 0,
166
+ flags: int = 0,
167
+ ) -> Tuple[int, object]:
168
+ """Pump reads until ANY of ``patterns`` matches the screen, or timeout.
169
+
170
+ The pexpect ``expect([...])`` analogue: race several possible outcomes
171
+ (prompt vs error vs EOF banner) and report WHICH one appeared first. Patterns
172
+ are scanned **in list order** each poll, so when two would match on the same
173
+ poll the earliest in the list wins (deterministic, documented) — order the
174
+ list most-specific-first if that matters.
175
+
176
+ Args:
177
+ read_fn: called each poll to read+feed a batch.
178
+ get_text_fn: returns the current screen text searched by the regexes.
179
+ get_snapshot_fn: builds the :class:`Snapshot` returned to the caller.
180
+ patterns: regexes searched against the whole screen text, in priority order.
181
+ timeout_ms: hard ceiling.
182
+ poll_ms: sleep between polls when idle.
183
+ min_wait_ms: ignore matches before this much time has elapsed (guards
184
+ against matching a stale prior prompt right after sending input).
185
+ flags: extra ``re`` flags (``re.I`` etc.) applied to every pattern.
186
+
187
+ Returns:
188
+ ``(index, snapshot)`` — ``index`` is the 0-based position in ``patterns``
189
+ of the pattern that matched, or ``-1`` on timeout. An empty ``patterns``
190
+ list can never match, so it returns ``(-1, snapshot)`` immediately (one
191
+ pump, no spin to the deadline). The snapshot is always the current screen
192
+ so the caller can act on the last state either way.
193
+ """
194
+ rxs = [re.compile(p, flags) for p in patterns]
195
+ if not rxs:
196
+ # Nothing to match — pump once (so the snapshot is fresh) and report the
197
+ # timeout sentinel now instead of spinning the whole timeout window.
198
+ read_fn()
199
+ return -1, get_snapshot_fn()
200
+ poll = poll_ms / 1000.0
201
+ min_wait = min_wait_ms / 1000.0
202
+ start = time.monotonic()
203
+ deadline = start + (timeout_ms / 1000.0)
204
+
205
+ while True:
206
+ now = time.monotonic()
207
+ read_fn()
208
+ elapsed = now - start
209
+ if elapsed >= min_wait:
210
+ text = get_text_fn()
211
+ for i, rx in enumerate(rxs):
212
+ if rx.search(text):
213
+ return i, get_snapshot_fn()
214
+ if now >= deadline:
215
+ return -1, get_snapshot_fn()
216
+ time.sleep(poll)
217
+
218
+
158
219
  def wait_ready(
159
220
  read_fn: ReadFn,
160
221
  get_screen_hash_fn: HashFn,
@@ -24,7 +24,7 @@ import time
24
24
  from typing import List, Optional, Tuple, Union, Sequence
25
25
 
26
26
  from .pty_backend import PtyBackend, get_default_backend
27
- from .readiness import wait_for_regex, wait_ready, wait_until_stable
27
+ from .readiness import wait_any, wait_for_regex, wait_ready, wait_until_stable
28
28
  from .screen_model import ScreenModel
29
29
  from .snapshot import Snapshot, build_snapshot
30
30
 
@@ -297,6 +297,33 @@ class PtySession:
297
297
  )
298
298
  return matched, snap # type: ignore[return-value]
299
299
 
300
+ def wait_any(
301
+ self,
302
+ patterns: Sequence[str],
303
+ timeout_ms: int = 10000,
304
+ poll_ms: int = 30,
305
+ min_wait_ms: int = 0,
306
+ flags: int = 0,
307
+ ) -> Tuple[int, Snapshot]:
308
+ """Wait for ANY of ``patterns`` (pexpect ``expect([...])`` style).
309
+
310
+ Returns ``(index, snapshot)`` where ``index`` is the 0-based position of
311
+ the pattern that matched (earliest in the list wins a same-poll tie), or
312
+ ``-1`` on timeout. The snapshot is always the current screen. See
313
+ :func:`readiness.wait_any`.
314
+ """
315
+ index, snap = wait_any(
316
+ read_fn=self.pump,
317
+ get_text_fn=self.model.text,
318
+ get_snapshot_fn=self.snapshot,
319
+ patterns=patterns,
320
+ timeout_ms=timeout_ms,
321
+ poll_ms=poll_ms,
322
+ min_wait_ms=min_wait_ms,
323
+ flags=flags,
324
+ )
325
+ return index, snap # type: ignore[return-value]
326
+
300
327
  def wait_change(
301
328
  self,
302
329
  baseline_hash: Optional[str] = None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartcli-toolkit
3
- Version: 0.1.6
3
+ Version: 0.1.8
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
@@ -195,7 +195,7 @@ backend, and dependency versions — run it and paste the output when filing a b
195
195
 
196
196
  ```bash
197
197
  cd skills/cmd-art
198
- python -m fx list # list all 28 effects
198
+ python -m fx list # list all 30 effects
199
199
  python -m fx play donut --seconds 5 # play one effect (bounded)
200
200
  python -m fx gallery # one frame of each effect
201
201
  python -m fx show --seq "donut:fire:3,plasma::3"
@@ -251,9 +251,9 @@ suite, see **[`README-USAGE.md`](README-USAGE.md)**.
251
251
  ## Features
252
252
 
253
253
  **`cmd-art`** (`skills/cmd-art`) — a "living-template" effect engine: an `Effect` ABC +
254
- `@register` decorator + auto-discovery. **28 effects** (donut, solarsystem, fire, plasma,
254
+ `@register` decorator + auto-discovery. **30 effects** (donut, solarsystem, fire, plasma,
255
255
  rain, starfield, tunnel, text3d, cube, sphere, boids, life, fireworks, sparkle, decrypt,
256
- gradient_text, banner_scroll, image2ascii, typewriter, julia, mandelbrot, perlin, flames, water, nebula, text_flyin, text_converge, text_decrypt) across **8 themes** (mono, fire,
256
+ gradient_text, banner_scroll, image2ascii, typewriter, julia, mandelbrot, perlin, flames, water, nebula, text_flyin, text_converge, text_decrypt, spectrum_bars, cbonsai) across **8 themes** (mono, fire,
257
257
  ocean, synthwave, viridis, pastel, matrix-green, rainbow). Effects are pure frame
258
258
  producers; `play` is bounded by default and always restores the terminal.
259
259
 
@@ -285,7 +285,7 @@ cross-links. See [`knowledge/INDEX.md`](knowledge/INDEX.md).
285
285
  ```text
286
286
  SmartCLI/
287
287
  smartcli_core/ shared PTY + pyte engine (importable package)
288
- skills/cmd-art/ fx effect package and CLI (28 effects, 8 themes)
288
+ skills/cmd-art/ fx effect package and CLI (30 effects, 8 themes)
289
289
  skills/drive-tui/ TUI pattern library and PTY driver CLI (8 recipes)
290
290
  skills/tui-ui/ terminal UI layout engine and widgets (17 widgets)
291
291
  tools/screenshot/ pyte -> PNG smoke-test harness
@@ -21,5 +21,8 @@ tests/test_doc_counts.py
21
21
  tests/test_fx_contract.py
22
22
  tests/test_golden_frames.py
23
23
  tests/test_readiness.py
24
+ tests/test_sixel.py
25
+ tests/test_tbench_adapter.py
24
26
  tests/test_vendor_sync.py
27
+ tests/test_wait_any.py
25
28
  tests/test_wait_change.py
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env python3
2
+ """test_sixel.py — Sixel encoder regression (deterministic, pure-memory).
3
+
4
+ Locks the wire format against the VT330/340 spec's known-good facts:
5
+ * DCS introducer ESC P 0;1;0 q, ST terminator ESC \\, raster attrs "1;1;W;H,
6
+ * char = 0x3F + mask, bit0 = TOP pixel (verified via the canonical DEC "HI" math),
7
+ * color registers #Pc;2;R;G;B with R/G/B in 0..100 PERCENT (not 0..255),
8
+ * `$` between colors in a band (never trailing), `-` between bands (bands-1 of them),
9
+ * a round-trip: decode the emitted stream back to a pixel grid and compare.
10
+
11
+ No terminal, no process — the bytes are checked structurally + by decode.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ ROOT = Path(__file__).resolve().parents[1]
19
+ sys.path.insert(0, str(ROOT))
20
+ sys.path.insert(0, str(ROOT / "skills" / "tui-ui"))
21
+
22
+ from ui.sixel import (encode_sixel, _quantize_index, _index_to_pct, # noqa: E402
23
+ raster_to_sixel)
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
+ RED = (255, 0, 0)
36
+ BLUE = (0, 0, 255)
37
+ WHITE = (255, 255, 255)
38
+
39
+
40
+ def test_red_blue_fixture():
41
+ """Spec worked example: 2px x 6px, col0 red, col1 blue. One band, two colors."""
42
+ px = [[RED, BLUE] for _ in range(6)]
43
+ s = encode_sixel(px)
44
+ check(s.startswith("\x1bP0;1;0q"), "starts with DCS ESC P 0;1;0 q", repr(s[:9]))
45
+ check(s.endswith("\x1b\\"), "ends with ST ESC backslash")
46
+ check('"1;1;2;6' in s, "raster attrs 1:1 aspect + WxH = 2x6")
47
+ ri, bi = _quantize_index(RED), _quantize_index(BLUE)
48
+ check(_index_to_pct(ri) == (100, 0, 0), "red register defined as 100;0;0 percent",
49
+ str(_index_to_pct(ri)))
50
+ check(_index_to_pct(bi) == (0, 0, 100), "blue register defined as 0;0;100 percent",
51
+ str(_index_to_pct(bi)))
52
+ # red column0 = all 6 rows lit = mask 63 = '~'; its column1 empty = '?'
53
+ check(f"#{ri}~?" in s, "red pass: col0 all-six '~', col1 none '?'",
54
+ f"want #{ri}~?")
55
+ check("$" in s, "a graphics-CR '$' separates the two colors in the band")
56
+ check(f"#{bi}?~" in s, "blue pass: col0 '?', col1 all-six '~'", f"want #{bi}?~")
57
+ check(s.count("-") == 0, "single band -> no graphics-NL '-'", f"count={s.count('-')}")
58
+
59
+
60
+ def test_bit_math_matches_dec_hi_example():
61
+ """The canonical DEC 'HI' example proves char=0x3F+mask, bit0=top:
62
+ '~'=0x7E->63 (all six), '@'=0x40->1 (top only), '}'=0x7D->62 (top off)."""
63
+ check(chr(0x3F + 0b111111) == "~", "mask 63 (all six) encodes to '~'")
64
+ check(chr(0x3F + 0b000001) == "@", "mask 1 (top pixel only) encodes to '@'")
65
+ check(chr(0x3F + 0b111110) == "}", "mask 62 (top off, rest on) encodes to '}'")
66
+ check(chr(0x3F + 0) == "?", "mask 0 (no pixels) encodes to '?'")
67
+ # a single top-lit pixel in a 1x1 image must emit '@' for its color.
68
+ s = encode_sixel([[RED]])
69
+ ri = _quantize_index(RED)
70
+ check(f"#{ri}@" in s, "1x1 top pixel -> sixel char '@' (bit0=top)")
71
+
72
+
73
+ def test_percent_scaling_not_255():
74
+ """Colors must be 0..100 percent, never 0..255 (the classic gotcha)."""
75
+ for c in (RED, BLUE, WHITE, (128, 64, 200)):
76
+ r, g, b = _index_to_pct(_quantize_index(c))
77
+ check(0 <= r <= 100 and 0 <= g <= 100 and 0 <= b <= 100,
78
+ f"{c} -> percent in 0..100", f"got {(r, g, b)}")
79
+
80
+
81
+ def test_band_advance_count():
82
+ """A 2-wide x 13-tall image spans ceil(13/6)=3 bands -> exactly 2 '-'."""
83
+ px = [[WHITE, WHITE] for _ in range(13)]
84
+ s = encode_sixel(px)
85
+ check(s.count("-") == 2, "13 rows -> 3 bands -> 2 graphics-NL", f"count={s.count('-')}")
86
+ check(not s.rstrip("\x1b\\").endswith("-"), "no trailing '-' before ST")
87
+
88
+
89
+ def _decode_sixel(s: str):
90
+ """Minimal decoder for our own output: returns {(x,y): register} for lit px.
91
+ Enough to prove the encoder is self-consistent (round-trip)."""
92
+ body = s
93
+ assert body.startswith("\x1bP")
94
+ body = body[2:]
95
+ # strip params up to the 'q'
96
+ body = body[body.index("q") + 1:]
97
+ body = body[:body.rindex("\x1b\\")]
98
+ lit = {}
99
+ band = 0
100
+ x = 0
101
+ cur = None
102
+ i = 0
103
+ n = len(body)
104
+ while i < n:
105
+ ch = body[i]
106
+ if ch == '"': # raster attrs: skip to next control
107
+ j = i + 1
108
+ while j < n and body[j] not in "#$-":
109
+ j += 1
110
+ i = j
111
+ continue
112
+ if ch == '#': # color: #Pc or #Pc;2;r;g;b
113
+ j = i + 1
114
+ num = ""
115
+ while j < n and body[j].isdigit():
116
+ num += body[j]; j += 1
117
+ if j < n and body[j] == ';': # definition — skip the ;2;r;g;b
118
+ while j < n and body[j] not in "#$-?~" and not (0x3F <= ord(body[j]) <= 0x7E and body[j] not in ";0123456789"):
119
+ j += 1
120
+ # after a definition the register is also selected
121
+ cur = int(num)
122
+ x = 0
123
+ i = j
124
+ continue
125
+ if ch == '$': # CR: same band, back to x=0
126
+ x = 0; i += 1; continue
127
+ if ch == '-': # NL: next band
128
+ band += 1; x = 0; i += 1; continue
129
+ if ch == '!': # RLE: !Pn<char>
130
+ j = i + 1; num = ""
131
+ while j < n and body[j].isdigit():
132
+ num += body[j]; j += 1
133
+ rep = int(num); sc = body[j]
134
+ mask = ord(sc) - 0x3F
135
+ for _ in range(rep):
136
+ for dy in range(6):
137
+ if mask & (1 << dy):
138
+ lit[(x, band * 6 + dy)] = cur
139
+ x += 1
140
+ i = j + 1
141
+ continue
142
+ if 0x3F <= ord(ch) <= 0x7E: # a data char
143
+ mask = ord(ch) - 0x3F
144
+ for dy in range(6):
145
+ if mask & (1 << dy):
146
+ lit[(x, band * 6 + dy)] = cur
147
+ x += 1
148
+ i += 1
149
+ continue
150
+ i += 1
151
+ return lit
152
+
153
+
154
+ def test_round_trip():
155
+ """Encode a known pattern, decode our own stream, confirm lit pixels match."""
156
+ # 3x6 checkerboard of red/blue
157
+ px = [[RED if (x + y) % 2 == 0 else BLUE for x in range(3)] for y in range(6)]
158
+ s = encode_sixel(px)
159
+ lit = _decode_sixel(s)
160
+ ri, bi = _quantize_index(RED), _quantize_index(BLUE)
161
+ good = True
162
+ for y in range(6):
163
+ for x in range(3):
164
+ want = ri if (x + y) % 2 == 0 else bi
165
+ if lit.get((x, y)) != want:
166
+ good = False
167
+ check(good, "round-trip: decoded pixels match the source checkerboard",
168
+ f"{len(lit)} lit px")
169
+
170
+
171
+ def test_transparent_pixels_skipped():
172
+ """None pixels must not be painted (P2=1 transparent)."""
173
+ px = [[RED, None], [None, RED]] # 2x2, diagonal
174
+ s = encode_sixel(px)
175
+ lit = _decode_sixel(s)
176
+ ri = _quantize_index(RED)
177
+ check(lit.get((0, 0)) == ri and lit.get((1, 1)) == ri, "lit diagonal present")
178
+ check((1, 0) not in lit and (0, 1) not in lit, "None pixels stay transparent")
179
+
180
+
181
+ def test_raster_adapter():
182
+ """raster_to_sixel forwards a SubcellRaster's .px buffer."""
183
+ from ui.raster import SubcellRaster
184
+ r = SubcellRaster(2, 2, "quad") # pw=4, ph=4
185
+ r.px[0][0] = RED
186
+ s = raster_to_sixel(r)
187
+ check(s.startswith("\x1bP") and s.endswith("\x1b\\"), "raster adapter emits a DCS")
188
+ check('"1;1;4;4' in s, "raster adapter uses the pixel-buffer dims (4x4)")
189
+
190
+
191
+ def test_empty_image():
192
+ s = encode_sixel([])
193
+ check(s == "\x1bP0;1;0q\x1b\\", "empty image -> bare DCS+ST wrapper", repr(s))
194
+
195
+
196
+ def main():
197
+ for fn in (test_red_blue_fixture, test_bit_math_matches_dec_hi_example,
198
+ test_percent_scaling_not_255, test_band_advance_count,
199
+ test_round_trip, test_transparent_pixels_skipped,
200
+ test_raster_adapter, test_empty_image):
201
+ fn()
202
+ print("-" * 60)
203
+ if failures:
204
+ print(f"test_sixel FAIL -- {failures} check(s) failed")
205
+ return 1
206
+ print("test_sixel PASS -- sixel wire format locked to spec")
207
+ return 0
208
+
209
+
210
+ if __name__ == "__main__":
211
+ sys.exit(main())
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env python3
2
+ """test_tbench_adapter.py — smartcli_tbench driver + loop (deterministic, no Docker).
3
+
4
+ Proves the Terminal-Bench adapter's LOGIC on any box (incl. Windows) without
5
+ Docker, terminal-bench, or an LLM:
6
+ * TmuxSessionDriver.wait_for / wait_any / wait_change / wait_stable fire correctly
7
+ over a scripted FakeTmuxSession (fake clock + no-op sleep — instant, deterministic),
8
+ * run_agent_loop executes perceive→decide→act→wait→confirm, terminates on the
9
+ decider's done signal, and respects the step budget,
10
+ * send_keys / send_line reach the session.
11
+
12
+ The LLM-backed SmartCliAgent itself needs terminal-bench (CI/Linux); this locks
13
+ everything below that seam.
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_tbench.driver import TmuxSessionDriver # noqa: E402
24
+ from smartcli_tbench.loop import AgentStep, run_agent_loop # noqa: E402
25
+
26
+ failures = 0
27
+
28
+
29
+ def check(cond, label, detail=""):
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 FakeClock:
37
+ """Monotonic clock that only advances when the driver sleeps, so wait loops
38
+ hit their deadline deterministically without real time passing."""
39
+
40
+ def __init__(self):
41
+ self.t = 0.0
42
+
43
+ def now(self):
44
+ return self.t
45
+
46
+ def sleep(self, s):
47
+ self.t += s
48
+
49
+
50
+ class FakeTmuxSession:
51
+ """Scripts capture_pane() outputs: each poll returns the next screen; the last
52
+ screen repeats forever. Records sent keys. With ``cycle=True`` the script loops
53
+ (to model a screen that alternates forever and truly never settles)."""
54
+
55
+ def __init__(self, screens, cycle=False):
56
+ self._screens = list(screens)
57
+ self._i = 0
58
+ self._cycle = cycle
59
+ self.sent = []
60
+
61
+ def capture_pane(self, capture_entire=False):
62
+ if self._cycle:
63
+ s = self._screens[self._i % len(self._screens)]
64
+ else:
65
+ s = self._screens[min(self._i, len(self._screens) - 1)]
66
+ self._i += 1
67
+ return s
68
+
69
+ def send_keys(self, keys, block=False, max_timeout_sec=180.0, **kw):
70
+ self.sent.append(list(keys))
71
+
72
+
73
+ def _driver(screens):
74
+ clk = FakeClock()
75
+ sess = FakeTmuxSession(screens)
76
+ return TmuxSessionDriver(sess, poll_ms=100, clock=clk.now, sleep=clk.sleep), sess, clk
77
+
78
+
79
+ def test_wait_for_matches():
80
+ d, _, _ = _driver(["booting...", "booting...", "$ ready"])
81
+ ok, screen = d.wait_for(r"\$ ready", timeout_sec=5)
82
+ check(ok is True, "wait_for finds the marker once it appears")
83
+ check("ready" in screen, "wait_for returns the matching screen")
84
+
85
+
86
+ def test_wait_for_timeout():
87
+ d, _, _ = _driver(["nope", "nope", "nope"])
88
+ ok, _ = d.wait_for(r"never", timeout_sec=1)
89
+ check(ok is False, "wait_for times out when the marker never appears")
90
+
91
+
92
+ def test_wait_any_index_and_tie():
93
+ d, _, _ = _driver(["...", "ERROR: boom"])
94
+ idx, _ = d.wait_any([r">>> ", r"ERROR"], timeout_sec=5)
95
+ check(idx == 1, "wait_any returns the index of the matching pattern", f"idx={idx}")
96
+ # same-poll tie -> earliest in list wins
97
+ d2, _, _ = _driver(["ERROR: boom"])
98
+ idx2, _ = d2.wait_any([r"ERROR", r"boom"], timeout_sec=5)
99
+ check(idx2 == 0, "wait_any tie: earliest-in-list wins", f"idx={idx2}")
100
+ # empty list short-circuits
101
+ d3, _, _ = _driver(["anything"])
102
+ idx3, _ = d3.wait_any([], timeout_sec=5)
103
+ check(idx3 == -1, "wait_any empty patterns -> -1", f"idx={idx3}")
104
+
105
+
106
+ def test_wait_change():
107
+ d, _, _ = _driver(["same", "same", "DIFFERENT"])
108
+ changed, screen = d.wait_change(timeout_sec=5)
109
+ check(changed is True, "wait_change detects the screen changing from baseline")
110
+ check("DIFFERENT" in screen, "wait_change returns the changed screen")
111
+
112
+ d2, _, _ = _driver(["frozen", "frozen", "frozen"])
113
+ changed2, _ = d2.wait_change(timeout_sec=1)
114
+ check(changed2 is False, "wait_change times out when nothing changes")
115
+
116
+
117
+ def test_wait_stable():
118
+ # changes for two polls, then holds -> becomes stable (min_wait_sec=0 to isolate
119
+ # the dwell logic from the stale-screen floor, which is tested separately below).
120
+ d, _, _ = _driver(["a", "b", "c", "c", "c"])
121
+ ok = d.wait_stable(quiet_polls=2, timeout_sec=5, min_wait_sec=0)
122
+ check(ok is True, "wait_stable settles once the screen stops changing")
123
+
124
+
125
+ def test_wait_stable_never_settles():
126
+ # A screen that alternates FOREVER (cycle=True) must never satisfy the dwell
127
+ # counter -> can only exit via timeout. Uses a truly non-clamping fake so this
128
+ # is a real never-settle, not a clamp-then-freeze artifact.
129
+ clk = FakeClock()
130
+ sess = FakeTmuxSession(["A", "B"], cycle=True)
131
+ d = TmuxSessionDriver(sess, poll_ms=100, clock=clk.now, sleep=clk.sleep)
132
+ ok = d.wait_stable(quiet_polls=2, timeout_sec=1, min_wait_sec=0)
133
+ check(ok is False, "wait_stable never settles on an alternating screen -> timeout")
134
+
135
+
136
+ def test_wait_stable_min_wait_guards_stale_screen():
137
+ # The stale-screen race (finding 1): the screen is ALREADY static from poll 0
138
+ # (the shell echoed the command instantly) but real output is still pending.
139
+ # With min_wait_sec, wait_stable must NOT declare stable before the floor, even
140
+ # though the dwell counter is satisfied immediately.
141
+ clk = FakeClock()
142
+ sess = FakeTmuxSession(["prompt$ cmd"], cycle=True) # static from the very start
143
+ d = TmuxSessionDriver(sess, poll_ms=100, clock=clk.now, sleep=clk.sleep)
144
+ t0 = clk.now()
145
+ ok = d.wait_stable(quiet_polls=2, timeout_sec=5, min_wait_sec=0.5)
146
+ elapsed = clk.now() - t0
147
+ check(ok is True, "wait_stable eventually settles on a static screen")
148
+ check(elapsed >= 0.5, "min_wait_sec floor delays settle past the stale-screen race",
149
+ f"elapsed={elapsed:.2f}s")
150
+ # And without the floor it would settle almost immediately (proves the guard bites):
151
+ clk2 = FakeClock()
152
+ d2 = TmuxSessionDriver(FakeTmuxSession(["prompt$ cmd"], cycle=True),
153
+ poll_ms=100, clock=clk2.now, sleep=clk2.sleep)
154
+ d2.wait_stable(quiet_polls=2, timeout_sec=5, min_wait_sec=0)
155
+ check(clk2.now() < 0.5, "without the floor, settle is immediate (guard is what delays)",
156
+ f"elapsed={clk2.now():.2f}s")
157
+
158
+
159
+ def test_send_helpers():
160
+ d, sess, _ = _driver(["scr"])
161
+ d.send_keys(["Down", "Enter"])
162
+ d.send_line("echo hi")
163
+ check(sess.sent[0] == ["Down", "Enter"], "send_keys forwards key tokens")
164
+ check(sess.sent[1] == ["echo hi", "Enter"], "send_line appends Enter")
165
+
166
+
167
+ def test_loop_runs_to_done():
168
+ # Decider: step 0 types a command, step 1 declares done.
169
+ d, sess, _ = _driver(["prompt$ ", "prompt$ output", "prompt$ output"])
170
+
171
+ def decide(instruction, screen, history):
172
+ if len(history) == 0:
173
+ return AgentStep(line="run the thing", wait_for=None)
174
+ return AgentStep(done=True)
175
+
176
+ res = run_agent_loop("do it", d, decide, max_steps=10)
177
+ check(res.done is True, "loop ends when the decider signals done")
178
+ check(res.steps_taken == 1, "one action taken before done", f"steps={res.steps_taken}")
179
+ check(sess.sent and sess.sent[0] == ["run the thing", "Enter"],
180
+ "loop actually sent the decided line")
181
+
182
+
183
+ def test_loop_respects_step_budget():
184
+ d, _, _ = _driver(["x"])
185
+ # Decider never says done -> must stop at max_steps.
186
+ def decide(instruction, screen, history):
187
+ return AgentStep(keys=["a"])
188
+ res = run_agent_loop("loop forever", d, decide, max_steps=5)
189
+ check(res.done is False, "loop stops at the step budget when never done")
190
+ check(res.steps_taken == 5, "exactly max_steps actions taken", f"steps={res.steps_taken}")
191
+
192
+
193
+ def test_agent_module_imports_without_tb():
194
+ # Importing the agent module must NOT require terminal-bench; SmartCliAgent is
195
+ # None here (TB absent), which is the documented non-TB-host behavior.
196
+ import smartcli_tbench.agent as ag
197
+ check(hasattr(ag, "SmartCliAgent"), "agent module imports without terminal-bench")
198
+ check(ag.SmartCliAgent is None, "SmartCliAgent is None on a non-TB host (expected)")
199
+
200
+
201
+ def main():
202
+ for fn in (test_wait_for_matches, test_wait_for_timeout, test_wait_any_index_and_tie,
203
+ test_wait_change, test_wait_stable, test_wait_stable_never_settles,
204
+ test_wait_stable_min_wait_guards_stale_screen, test_send_helpers,
205
+ test_loop_runs_to_done, test_loop_respects_step_budget,
206
+ test_agent_module_imports_without_tb):
207
+ fn()
208
+ print("-" * 60)
209
+ if failures:
210
+ print(f"test_tbench_adapter FAIL -- {failures} check(s) failed")
211
+ return 1
212
+ print("test_tbench_adapter PASS -- driver + loop logic locked (no Docker/LLM)")
213
+ return 0
214
+
215
+
216
+ if __name__ == "__main__":
217
+ sys.exit(main())
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ """test_wait_any.py — PtySession.wait_any regression (deterministic).
3
+
4
+ wait_any is the pexpect expect([...]) analogue: race several regexes and report
5
+ WHICH matched first. This locks:
6
+ * the pattern that appears wins and its LIST INDEX is returned,
7
+ * a later-listed pattern wins when only it appears,
8
+ * earliest-in-list wins a same-poll tie (documented ordering),
9
+ * timeout with none matched returns (-1, latest snapshot),
10
+ * min_wait_ms ignores a stale prior match until the guard elapses.
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
+ import time
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 # noqa: E402
24
+ from smartcli_core.pty_backend import PtyBackend # noqa: E402
25
+
26
+ failures = 0
27
+
28
+
29
+ def check(cond, label, detail=""):
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
+ """Feeds queued byte chunks one-per-read; optionally delays the first chunk."""
38
+
39
+ def __init__(self):
40
+ self._q = []
41
+ self._alive = True
42
+
43
+ def queue(self, data):
44
+ self._q.append(data)
45
+
46
+ def spawn(self, cmd, cols, rows):
47
+ pass
48
+
49
+ def read_nonblocking(self, timeout=0.0):
50
+ return self._q.pop(0) if self._q else b""
51
+
52
+ def write(self, data):
53
+ pass
54
+
55
+ def resize(self, cols, rows):
56
+ pass
57
+
58
+ def is_alive(self):
59
+ return self._alive
60
+
61
+ def terminate(self):
62
+ self._alive = False
63
+
64
+
65
+ def test_first_pattern_matches():
66
+ be = FakeBackend()
67
+ sess = PtySession(cols=40, rows=10, backend=be)
68
+ be.queue(b">>> ")
69
+ idx, snap = sess.wait_any([r">>> ", r"Traceback"], timeout_ms=2000)
70
+ check(idx == 0, "the prompt pattern matches -> index 0", f"idx={idx}")
71
+ check(">>>" in snap.to_text(), "returned snapshot shows the matched screen")
72
+
73
+
74
+ def test_later_pattern_matches():
75
+ be = FakeBackend()
76
+ sess = PtySession(cols=40, rows=10, backend=be)
77
+ be.queue(b"Traceback (most recent call last)")
78
+ idx, _ = sess.wait_any([r">>> ", r"Traceback"], timeout_ms=2000)
79
+ check(idx == 1, "only the error pattern is present -> index 1", f"idx={idx}")
80
+
81
+
82
+ def test_earliest_in_list_wins_tie():
83
+ # One screen that matches BOTH patterns; the earlier list entry must win.
84
+ be = FakeBackend()
85
+ sess = PtySession(cols=40, rows=10, backend=be)
86
+ be.queue(b"ERROR: boom")
87
+ idx, _ = sess.wait_any([r"ERROR", r"boom"], timeout_ms=2000)
88
+ check(idx == 0, "same-poll tie: earliest-in-list pattern wins", f"idx={idx}")
89
+ # And the reverse order proves it's list-order, not text-position.
90
+ be2 = FakeBackend()
91
+ sess2 = PtySession(cols=40, rows=10, backend=be2)
92
+ be2.queue(b"ERROR: boom")
93
+ idx2, _ = sess2.wait_any([r"boom", r"ERROR"], timeout_ms=2000)
94
+ check(idx2 == 0, "reversed list -> the now-first pattern ('boom') wins", f"idx={idx2}")
95
+
96
+
97
+ def test_empty_patterns_short_circuits():
98
+ # An empty list can never match: return (-1, snapshot) immediately instead of
99
+ # spinning the whole timeout window (adversarial-review polish).
100
+ be = FakeBackend()
101
+ sess = PtySession(cols=40, rows=10, backend=be)
102
+ t0 = time.monotonic()
103
+ idx, snap = sess.wait_any([], timeout_ms=3000)
104
+ elapsed_ms = (time.monotonic() - t0) * 1000
105
+ check(idx == -1, "empty patterns -> index -1", f"idx={idx}")
106
+ check(snap is not None, "empty patterns still returns a snapshot")
107
+ check(elapsed_ms < 500, "empty patterns returns immediately, not after timeout",
108
+ f"elapsed={elapsed_ms:.0f}ms")
109
+
110
+
111
+ def test_timeout_returns_minus_one():
112
+ be = FakeBackend()
113
+ sess = PtySession(cols=40, rows=10, backend=be)
114
+ # nothing queued -> screen stays blank -> no pattern ever matches
115
+ idx, snap = sess.wait_any([r"never", r"appears"], timeout_ms=250)
116
+ check(idx == -1, "no pattern within the window -> index -1 (timeout)", f"idx={idx}")
117
+ check(snap is not None, "timeout still returns the latest snapshot")
118
+
119
+
120
+ def test_min_wait_ignores_stale_match():
121
+ # The target text is already on screen at call time; min_wait_ms must make
122
+ # wait_any ignore it until the guard elapses, then it still returns the match.
123
+ be = FakeBackend()
124
+ sess = PtySession(cols=40, rows=10, backend=be)
125
+ be.queue(b"$ ")
126
+ sess.pump() # paint "$ " BEFORE the wait, so it's a stale prior match
127
+ t0 = time.monotonic()
128
+ idx, _ = sess.wait_any([r"\$ "], timeout_ms=2000, min_wait_ms=150)
129
+ elapsed_ms = (time.monotonic() - t0) * 1000
130
+ check(idx == 0, "stale match is still returned after the guard", f"idx={idx}")
131
+ check(elapsed_ms >= 140, "min_wait_ms delayed the match past the guard",
132
+ f"elapsed={elapsed_ms:.0f}ms")
133
+
134
+
135
+ def main():
136
+ for fn in (test_first_pattern_matches, test_later_pattern_matches,
137
+ test_earliest_in_list_wins_tie, test_empty_patterns_short_circuits,
138
+ test_timeout_returns_minus_one, test_min_wait_ignores_stale_match):
139
+ fn()
140
+ print("-" * 60)
141
+ if failures:
142
+ print(f"test_wait_any FAIL -- {failures} check(s) failed")
143
+ return 1
144
+ print("test_wait_any PASS -- all wait_any paths locked")
145
+ return 0
146
+
147
+
148
+ if __name__ == "__main__":
149
+ sys.exit(main())