Add .gitignore and initial project files

This commit is contained in:
Developer
2026-09-10 16:15:17 +00:00
commit 78a2043968
17 changed files with 2261 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
# ============================================================
# .gitignore for tmux-shot (Python project via uv/hatchling)
# Generated: 2026-09-10
# ============================================================
# ---- Python / Build Artifacts ----
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg-info/
*.egg
dist/
build/
_build/
*.whl
# Hatch / setuptools build artifacts
.hatch/
.hatch-temp/
# ---- uv (package manager) ----
uv.lock
.venv/
.venv.*
!.venv/.gitkeep
# ---- IDEs ----
# VS Code
.vscode/
.vscode-*
*.code-workspace
# PyCharm / JetBrains
.idea/
*.iml
*.iws
*.ipr
.idea_modules/
# Eclipse
.project
.classpath
.settings/
# Sublime Text
*.sublime-project
*.sublime-workspace
# Vim
*.swp
*.swo
*~
.vimrc.local
# ---- OS-generated files ----
# macOS
.DS_Store
.AppleDouble
.LSOverride
Icon?
._*
Thumbs.db
Desktop.ini
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.lnk
*.dll
*.exe
*.sys
*.bak
*.tmp
*.temp
# Linux / GNOME
.Trash-*
.gvfs
# ---- Environment & Secrets ----
.env
.env.*
!.env.example
secrets/
credentials/
*.key
*.pem
*.p12
*.jks
# ---- Logs & Debug ----
*.log
logs/
dpc.log
*.out
# ---- Test Artifacts ----
.pytest_cache/
.coverage
htmlcov/
coverage.json
*.cover
.tox/
noxfile.py/.tox/
# ---- Jupyter Notebooks ----
.ipynb_checkpoints/
*.ipynb
# ---- Data / Output directories (project-specific) ----
out/
data/
outputs/
# ---- Temporary files ----
tmp/
temp/
*.tmp
*.temp
# ---- Docker (if added later) ----
.dockerenv
docker-compose.override.yml
*.pid
+193
View File
@@ -0,0 +1,193 @@
# tmux-shot
Drive a TUI application inside tmux from Python, and capture pixel-accurate
PNG screenshots of it — no X server, no headless browser, no external
binaries. Pure `tmux` + `pyte` (terminal emulator) + `Pillow`.
Built for AI-driven workflows: an agent can create a session, type into it,
capture a PNG, look at it, decide what to do next, and repeat.
## How it works
1. `tmux_shot.TmuxApp` creates/attaches a tmux session running your TUI and
sends keystrokes to it (`libtmux` under the hood).
2. `tmux capture-pane -e -p` dumps the pane's exact character grid,
including ANSI color/style escape codes.
3. `pyte.Screen` replays that byte stream into an in-memory terminal buffer
(one cell per character, with fg/bg/bold/etc.).
4. `render.py` draws that buffer cell-by-cell with Pillow into a PNG using a
monospace font.
If the `freeze` CLI (https://github.com/charmbracelet/freeze) is installed,
you can use `render_with_freeze()` instead for nicer-looking output (window
chrome, better font shaping) — it's optional, not a hard dependency.
## Install
```bash
uv sync # or: pip install -e .
```
## Quick start
```python
from tmux_shot import TmuxApp
app = TmuxApp("demo", command="python3 examples/demo_tui.py", width=100, height=30)
app.screenshot("out/1.png")
app.send_keys("j") # drive the app
app.screenshot("out/2.png")
app.send_keys("q")
app.kill()
```
Or from the CLI:
```bash
tmux-shot new demo --command "top" --width 120 --height 40
tmux-shot shot demo out.png
tmux-shot send demo "q"
tmux-shot kill demo
```
## Demo TUI
`examples/demo_tui.py` is a self-contained curses app (arrow keys to move a
cursor, `q` to quit) used to exercise the pipeline without depending on any
other installed program.
```bash
uv run python examples/run_demo.py
```
produces `out/demo_*.png` frames showing the cursor moving and colors
rendering correctly.
## AI-driven "photo session" mode
`tmux-agent` is its own binary (also reachable as `tmux-shot agent`): give it
a plan in plain English and it drives the app itself (arrow keys, waits,
screenshots) via OpenAI-style function calling, looking at each screenshot as
it goes.
It talks to [devplace.net](https://devplace.net)'s OpenAI-compatible gateway
(`https://devplace.net/openai/v1`, model `molodetz`) by default. Set your key
first:
```bash
export DEVPLACE_API_KEY=<your key from your devplace.net profile page>
```
The prompt is the only required argument — everything else has a sensible
default:
```bash
uv run tmux-agent "open the app, find the settings panel, screenshot it"
```
Omit the prompt and it asks interactively instead. Every knob from
`tmux-shot`'s other commands is still available when you need it:
```bash
uv run tmux-agent \
--session myapp --command "myapp" --width 120 --height 40 \
--delay 1.5 --max-steps 40 --out-dir out/session1 --gif out/session1.gif \
"Open the settings menu, navigate to Network, screenshot it as \
'network-panel', then go back to the main menu and screenshot that \
as 'home', then finish."
```
Tools exposed to the model: `send_keys` (tmux key names like `Up`/`Enter`/
`C-c`, or literal text), `wait`, `read_text` (cheap text-only state check),
`screenshot` (saves + shows the model the PNG), and `finish`. `--delay`
controls the automatic pause after every `send_keys` so the TUI has time to
redraw before the next look; the model can also request extra waits itself.
Swap `--model`/`--base-url`/`--app-reference` to point at a different
OpenAI-compatible backend.
## "N minutes of X" — timed sessions with a guaranteed GIF/video
Asking for "a gif of 3 minutes of you doing X" doesn't map onto a normal
step-count loop, and a model can misread "video" as "produce a video file"
and just refuse. Two things fix this:
1. The system prompt is explicit that "video"/"gif"/"movie"/"N minutes of X"
requests are fully in scope — the model's job is to keep taking real
actions (`send_keys`) and periodic `screenshot`s, not to produce a file
itself.
2. `--duration SECONDS` makes the guarantee mechanical instead of relying on
the model cooperating: a background timer (stdlib `threading`, no extra
dependency) takes a screenshot every `--interval` seconds for the entire
requested duration, regardless of what the model does. If the model
stalls or refuses partway through, the run keeps waiting out the clock —
letting the ticker keep capturing — instead of ending early, so a 3-minute
request always produces roughly 3 minutes of real footage.
```bash
uv run tmux-agent \
--duration 180 --interval 5 --max-steps 80 \
--command "grok --cwd /tmp/scratch --always-approve" \
--gif out/vibecode.gif \
"actively vibecode something small and fun with grok the whole session"
```
`--always-approve` (a `grok` flag, not ours) is worth knowing about here: if
the target app itself asks for permission before editing files or running
commands, an unattended agent has no one to click "yes" for it and will
stall. Only use it against a disposable scratch directory (`--cwd
/tmp/scratch` above) — it removes the safety net that normally stops a
misbehaving app from doing something destructive.
## Testing
```bash
uv run pytest
```
- `tests/test_render.py`, `tests/test_tmux_control.py`, `tests/test_cli.py` — core
pipeline (colors, reverse video, `send_keys` correctness, screenshots, CLI
round-trips). No network, no API key needed.
- `tests/test_agent.py` — exercises the full LLM tool-calling loop
(`send_keys`/`wait`/`read_text`/`screenshot`/`finish`, image follow-up
messages, the max-steps safety cap) against a scripted fake OpenAI client —
validates the agent harness itself without spending real API calls.
- `tests/test_grok_target.py` — a real-world capability probe against the
`grok` CLI (a third-party TUI), auto-skipped if `grok` isn't installed.
Read-only by design: only ever sends arrow keys, never Enter, so no prompt
is ever submitted to grok's own model. Produces an actual animated GIF of
its welcome screen as a working example of the "movie" pipeline below.
`TmuxApp.wait_for(text, timeout=10)` polls the pane until a marker string
appears — use it instead of a fixed `sleep()` before screenshotting a TUI
whose startup/redraw time varies (this was discovered as real flakiness
while probing `grok`: its splash screen can take anywhere from ~2s to ~7s
depending on an update check).
## Making a "movie" (animated GIF) from a sequence of screenshots
```python
from tmux_shot import frames_to_gif
frames_to_gif(["01_a.png", "02_b.png", "03_c.png"], "session.gif", duration_ms=500)
```
or from the CLI:
```bash
tmux-shot gif out/01_a.png out/02_b.png out/03_c.png out/session.gif --duration-ms 500
```
`tmux-agent --gif out/session.gif "..."` stitches every screenshot the agent
took during that run into one GIF automatically.
## Notes for automating this with an AI agent
- `TmuxApp.screenshot()` returns the path it wrote — feed that straight to a
vision-capable model.
- `TmuxApp.capture_text()` gives you the plain-text grid (no image) when the
agent just needs to read state cheaply instead of "looking" at a picture.
- Sessions are named, so multiple agents/tasks can run against independent
tmux sessions concurrently without colliding.
- Everything is idempotent: `TmuxApp(name, command=...)` attaches to an
existing session with that name instead of erroring if it's already
running.
+39
View File
@@ -0,0 +1,39 @@
"""Self-contained curses TUI used to exercise the tmux-shot pipeline.
Arrow keys move the '@' cursor, 'q' quits. No external dependencies beyond
the standard library, so the demo works without any other TUI installed.
"""
import curses
def main(stdscr: "curses._CursesWindow") -> None:
curses.curs_set(0)
curses.start_color()
curses.use_default_colors()
for i in range(1, 8):
curses.init_pair(i, i, -1)
y, x = 5, 10
while True:
stdscr.erase()
stdscr.addstr(0, 0, "tmux-shot demo TUI -- arrows to move, q to quit", curses.A_BOLD)
for i in range(1, 8):
stdscr.addstr(2, i * 4, f" {i} ", curses.color_pair(i) | curses.A_REVERSE)
stdscr.addstr(y, x, "@", curses.color_pair(2) | curses.A_BOLD)
stdscr.refresh()
ch = stdscr.getch()
if ch in (ord("q"), ord("Q")):
break
elif ch == curses.KEY_UP:
y = max(3, y - 1)
elif ch == curses.KEY_DOWN:
y += 1
elif ch == curses.KEY_LEFT:
x = max(0, x - 1)
elif ch == curses.KEY_RIGHT:
x += 1
if __name__ == "__main__":
curses.wrapper(main)
+28
View File
@@ -0,0 +1,28 @@
"""Drives examples/demo_tui.py inside tmux and captures a couple of PNG frames.
Run with: uv run python examples/run_demo.py
"""
import time
from pathlib import Path
from tmux_shot import TmuxApp
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "out"
OUT.mkdir(exist_ok=True)
DEMO_SCRIPT = Path(__file__).resolve().parent / "demo_tui.py"
app = TmuxApp("tmux_shot_demo", command=f"python3 {DEMO_SCRIPT}", width=80, height=24)
time.sleep(0.3) # let curses draw the first frame
app.screenshot(OUT / "demo_1.png")
for key in ("Down", "Down", "Right", "Right", "Right"):
app.send_keys(key, enter=False)
time.sleep(0.2)
app.screenshot(OUT / "demo_2.png")
app.send_keys("q")
app.kill()
print(f"wrote {OUT / 'demo_1.png'}")
print(f"wrote {OUT / 'demo_2.png'}")
+35
View File
@@ -0,0 +1,35 @@
[project]
name = "tmux-shot"
version = "0.1.0"
description = "Drive a TUI inside tmux from Python and capture pixel-accurate PNG screenshots of it."
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"libtmux>=0.37",
"pyte>=0.8.2",
"pillow>=10.0",
"click>=8.1",
"openai>=2.48.0",
]
[project.scripts]
tmux-shot = "tmux_shot.cli:main"
tmux-agent = "tmux_shot.agent_cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/tmux_shot"]
[tool.uv]
package = true
[dependency-groups]
dev = [
"pytest>=8.4.2",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
+4
View File
@@ -0,0 +1,4 @@
from .render import frames_to_gif, render_ansi_to_png
from .tmux_control import TmuxApp
__all__ = ["TmuxApp", "render_ansi_to_png", "frames_to_gif"]
+623
View File
@@ -0,0 +1,623 @@
from __future__ import annotations
import base64
import json
import os
import re
import threading
import time
from pathlib import Path
from typing import Any, Optional
import click
from openai import APIConnectionError, APITimeoutError, InternalServerError, OpenAI, RateLimitError
from .tmux_control import TmuxApp
_TRANSIENT_API_ERRORS = (APIConnectionError, APITimeoutError, InternalServerError, RateLimitError)
DEFAULT_BASE_URL = "https://devplace.net/openai/v1"
DEFAULT_MODEL = "molodetz"
DEFAULT_APP_REFERENCE = "tmux-shot"
TOOLS: list[dict] = [
{
"type": "function",
"function": {
"name": "send_keys",
"description": (
"Send keystrokes to the TUI running in the tmux pane. Use tmux key "
"names for special keys (Up, Down, Left, Right, Enter, Tab, Escape, "
"C-c, etc.) or literal=true to type plain text verbatim."
),
"parameters": {
"type": "object",
"properties": {
"keys": {"type": "string", "description": "Keys or text to send."},
"literal": {
"type": "boolean",
"description": "Send exactly as typed text instead of interpreting tmux key names.",
},
"enter": {
"type": "boolean",
"description": "Send Enter afterwards.",
},
},
"required": ["keys"],
},
},
},
{
"type": "function",
"function": {
"name": "wait",
"description": "Pause for the TUI to redraw or animate before doing anything else.",
"parameters": {
"type": "object",
"properties": {
"seconds": {"type": "number", "description": "How long to wait, in seconds."},
},
"required": ["seconds"],
},
},
},
{
"type": "function",
"function": {
"name": "read_text",
"description": "Read the pane's current plain-text contents (cheap, no image) to check exact state.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "screenshot",
"description": (
"Capture a PNG screenshot of the pane's current state and view it. "
"Use this to look at the TUI before deciding the next action, and to "
"save the specific shots the user asked for."
),
"parameters": {
"type": "object",
"properties": {
"label": {
"type": "string",
"description": "Short filename-safe label for this screenshot, e.g. 'settings-panel'.",
},
},
"required": ["label"],
},
},
},
{
"type": "function",
"function": {
"name": "finish",
"description": "Call this once every step of the requested photo session has been completed.",
"parameters": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "What was done and which screenshots were taken.",
},
},
"required": ["summary"],
},
},
},
]
PLAN_TOOL: dict = {
"type": "function",
"function": {
"name": "propose_plan",
"description": (
"Break a long task into an ordered list of short, self-contained chunks. Each "
"chunk is later executed by a separate fresh turn that only sees that chunk's "
"description plus a one-line note about what happened before it -- not the full "
"history -- so make every chunk understandable on its own."
),
"parameters": {
"type": "object",
"properties": {
"chunks": {
"type": "array",
"items": {"type": "string"},
"description": "Ordered, concrete, independently-actionable chunk descriptions.",
}
},
"required": ["chunks"],
},
},
}
_REQUIRED_ARGS: dict[str, tuple[str, ...]] = {
"send_keys": ("keys",),
"wait": ("seconds",),
"screenshot": ("label",),
"read_text": (),
"finish": (),
}
SYSTEM_PROMPT = """You are driving a text-based terminal application (a TUI) running \
inside a tmux pane, to carry out a "photo session" the user describes: a sequence of \
navigation/typing actions, optionally ending in one or more screenshots of specific \
screens/panels.
You can only interact with the app through the provided tools:
- send_keys: send keystrokes (tmux key names like Up/Down/Left/Right/Enter/Tab/Escape/C-c, \
or literal text when literal=true) -- this is how you type, navigate menus, run commands, \
or write code inside the app. This is your only way to actually DO anything.
- wait: pause for redraws/animations
- read_text: cheaply read the pane's plain text to check exact state without spending on vision
- screenshot: capture and view a PNG of the current pane, saved under the given label
- finish: call once you've completed everything the user asked for
IMPORTANT -- if the user asks for a "video", "gif", "movie", "recording", or "N minutes of you
doing X": you cannot produce a video file yourself, and you must NEVER refuse or say you can't
help with it. That request is fully achievable and is exactly what you're for -- it just doesn't
map onto a single tool call. It means: actually perform the requested activity for the requested
duration using send_keys/wait (type real commands, write real code, navigate the real app -- do
not just sit idle or take one screenshot and quit), while calling screenshot periodically along
the way to capture frames. The calling program automatically stitches every screenshot you take
into the final GIF/video -- your only job is to keep taking real actions and periodic screenshots
until the time is up, then call finish. Treat a duration request as a minimum amount of real,
varied activity to produce, not something to complete in one or two tool calls.
Always take at least one screenshot before finishing. Keep replies brief, then call a tool. \
Follow the user's instructions literally and in order — do not skip or shortcut later steps of \
a multi-step plan just because an earlier one produced a screenshot. If the app doesn't respond \
as expected, use read_text or screenshot to check the actual state before trying again. You MUST \
call finish as your last tool call once (and only once) every step the user asked for is done; \
never stop by simply returning a message with no tool call, and never stop just because you \
personally can't produce a literal video/audio/binary file -- the tools already handle that."""
def _b64_png(path: Path) -> str:
return base64.b64encode(path.read_bytes()).decode("ascii")
def _safe_label(label: str) -> str:
cleaned = "".join(c if c.isalnum() or c in "-_" else "-" for c in label).strip("-")
return cleaned or "shot"
_NUMBERED_ITEM_RE = re.compile(r"(?:^|\s)(\d+)[.)]\s+")
def _split_run_on_chunk(text: str) -> list[str]:
"""Defensive fallback for when the model bundles several numbered steps into one plan
array element ("1. do X. 2. do Y. 3. do Z.") instead of separate elements as instructed --
a known, observed failure mode of structured output compliance. Splits on the model's own
numbering; returns [text] unchanged if it doesn't look like a bundled list."""
matches = list(_NUMBERED_ITEM_RE.finditer(text))
if len(matches) < 2:
return [text]
parts = []
for idx, m in enumerate(matches):
start = m.end()
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text)
part = text[start:end].strip()
if part:
parts.append(part)
return parts if len(parts) >= 2 else [text]
class AgentRunner:
def __init__(
self,
app: TmuxApp,
client: OpenAI,
model: str,
out_dir: Path,
delay: float,
max_steps: int,
duration: Optional[float] = None,
screenshot_interval: float = 5.0,
chunk_seconds: float = 45.0,
) -> None:
self.app = app
self.client = client
self.model = model
self.out_dir = out_dir
self.delay = delay
self.max_steps = max_steps
self.duration = duration
self.screenshot_interval = screenshot_interval
self.chunk_seconds = chunk_seconds
self.shots: list[str] = []
self._step_no = 0
self._lock = threading.Lock()
self._deadline: Optional[float] = None
self._ticker_stop: Optional[threading.Event] = None
self._ticker_thread: Optional[threading.Thread] = None
def _next_shot_path(self, label: str) -> Path:
with self._lock:
self._step_no += 1
n = self._step_no
return self.out_dir / f"{n:03d}_{_safe_label(label)}.png"
def _take_auto_shot(self) -> None:
path = self._next_shot_path("auto")
try:
self.app.screenshot(path)
except Exception as exc: # pane may be transiently busy/gone; a missed tick isn't fatal
click.echo(f"[agent] auto-screenshot skipped: {exc}")
return
with self._lock:
self.shots.append(str(path))
def _ticker_loop(self, stop: threading.Event) -> None:
while not stop.wait(self.screenshot_interval):
self._take_auto_shot()
def _start_ticker(self) -> None:
if not self.duration:
return
self._ticker_stop = threading.Event()
self._ticker_thread = threading.Thread(
target=self._ticker_loop, args=(self._ticker_stop,), daemon=True
)
self._ticker_thread.start()
def _stop_ticker(self) -> None:
if self._ticker_stop:
self._ticker_stop.set()
if self._ticker_thread:
self._ticker_thread.join(timeout=self.screenshot_interval + 1)
def _time_remaining(self) -> Optional[float]:
if self._deadline is None:
return None
return self._deadline - time.monotonic()
def _dispatch(self, name: str, args: dict[str, Any]) -> tuple[str, Optional[dict]]:
"""Runs one tool call. Returns (tool-message text, optional follow-up image message)."""
if name == "send_keys":
self.app.send_keys(
args["keys"], enter=bool(args.get("enter", False)), literal=bool(args.get("literal", False))
)
time.sleep(self.delay)
return f"sent keys: {args['keys']!r}", None
if name == "wait":
time.sleep(float(args["seconds"]))
return f"waited {args['seconds']}s", None
if name == "read_text":
return self.app.capture_text(), None
if name == "screenshot":
label = str(args.get("label", "shot"))
path = self._next_shot_path(label)
self.app.screenshot(path)
with self._lock:
self.shots.append(str(path))
image_message = {
"role": "user",
"content": [
{"type": "text", "text": f"Screenshot '{label}' (saved to {path}):"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{_b64_png(path)}"}},
],
}
return f"screenshot saved to {path}", image_message
if name == "finish":
return str(args.get("summary", "done")), None
return f"unknown tool: {name}", None
def _safe_dispatch(self, name: str, args: dict[str, Any]) -> tuple[str, Optional[dict]]:
"""Validate-then-dispatch: a malformed or incomplete tool call (missing required
argument, wrong type, transient failure) must never crash the whole run -- it should
come back as an error the model can read and self-correct from, same as any other
tool result. This is the standard fix for the #1 agent production failure mode
(tool misuse: wrong/missing arguments, malformed JSON)."""
missing = [a for a in _REQUIRED_ARGS.get(name, ()) if a not in args]
if missing:
return (
f"error: {name} call is missing required argument(s) {missing}; "
"retry the call with all required arguments included.",
None,
)
try:
return self._dispatch(name, args)
except Exception as exc:
return f"error: {name} failed ({type(exc).__name__}: {exc}); adjust arguments and retry.", None
def _call_model(self, messages: list[dict], tools: list[dict], tool_choice: Any, max_retries: int = 4):
"""chat.completions.create with retry-and-backoff on transient failures (connection
drops, timeouts, rate limits, 5xx) -- a long multi-minute session makes many dozens of
API round-trips, so hitting at least one transient hiccup is the expected case, not the
exception. Non-transient errors (bad request, auth, content filter, ...) raise
immediately since retrying them would never succeed."""
delay = 1.0
last_exc: Exception | None = None
for attempt in range(1, max_retries + 1):
try:
return self.client.chat.completions.create(
model=self.model,
messages=messages,
tools=tools,
tool_choice=tool_choice,
)
except _TRANSIENT_API_ERRORS as exc:
last_exc = exc
if attempt < max_retries:
click.echo(
f"[agent] API call failed transiently ({exc}); "
f"retrying in {delay:.0f}s ({attempt}/{max_retries})..."
)
time.sleep(delay)
delay = min(delay * 2, 30.0)
assert last_exc is not None
raise last_exc
def _step(self, messages: list[dict], step_label: str) -> tuple[bool, bool, str]:
"""One model turn: call the API, dispatch any tool calls, mutate `messages` in place.
Returns (had_tool_calls, finished, text) -- `text` is this turn's own content, or the
finish summary if it called finish, used as a short carry-forward note between chunks
rather than replaying the full conversation.
"""
response = self._call_model(messages, TOOLS, "auto")
message = response.choices[0].message
messages.append(message.model_dump(exclude_none=True))
if message.content:
click.echo(f"[agent] {message.content}")
if not message.tool_calls:
return False, False, message.content or ""
followups: list[dict] = []
finished = False
last_text = message.content or ""
for tool_call in message.tool_calls:
# Some backends leak raw chat-template control tokens (e.g. a "harmony"-style
# model's "<|channel|>commentary") straight into the function name -- observed live
# as "screenshot<|channel|>commentary" instead of "screenshot". Strip anything from
# the first such token onward before matching, so it dispatches on the first try
# instead of falling through to "unknown tool" and wasting a full retry round-trip.
name = tool_call.function.name.split("<|")[0].strip()
raw_args = tool_call.function.arguments or "{}"
try:
args = json.loads(raw_args)
if not isinstance(args, dict):
raise ValueError(f"arguments must be a JSON object, got {type(args).__name__}")
except (json.JSONDecodeError, ValueError) as exc:
click.echo(f"[{step_label}] {name}({raw_args!r}) -- malformed arguments")
text, followup = f"error: could not parse arguments for {name}: {exc}", None
else:
click.echo(f"[{step_label}] {name}({args})")
text, followup = self._safe_dispatch(name, args)
messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": text})
if followup:
followups.append(followup)
if name == "finish":
finished = True
last_text = text
messages.extend(followups)
return True, finished, last_text
def _plan_chunks(self, instruction: str, target_count: int) -> list[str]:
"""One lightweight planning call: ask for an ordered list of short chunks, the same
way a book gets outlined into chapters before each chapter is drafted separately.
Falls back to no plan (caller then runs unchunked) on any failure -- planning is a
nice-to-have, never a hard requirement."""
plan_messages = [
{
"role": "system",
"content": (
"You break a long TUI automation task into an ordered list of short, "
"concrete, self-contained chunks. Each chunk will later be executed by a "
"separate fresh turn that only sees that chunk's own description plus a "
"one-line note about the previous chunk -- not the full history -- so make "
"every chunk understandable and actionable entirely on its own.\n\n"
"Every chunk MUST be a concrete, hands-on-keyboard action performed live in "
"the actual terminal via keystrokes (e.g. 'create a macro with qa, use it to "
"duplicate a line 5 times with 5@a', 'open a vertical split with :vsp and "
"navigate between panes with Ctrl-w'). Never propose meta/production-style "
"chunks like writing a script/outline, recording, editing footage, adding "
"captions, or exporting a file -- there is no such tool, and no video/script "
"is ever produced separately from the real actions taken. The screenshot "
"capture and final GIF assembly are handled automatically outside these "
"chunks; every chunk here is purely about doing something real on screen.\n\n"
"Each array element must be exactly ONE short chunk (one short sentence, a "
"single focused action or tightly-related handful of keystrokes) -- never "
"bundle several distinct steps or numbered sub-items into one array element. "
"If you find yourself writing '1. ... 2. ... 3. ...' inside a single string, "
"that means it should have been 3 separate array elements instead."
),
},
{
"role": "user",
"content": (
f"Task: {instruction}\n\nBreak this into about {target_count} ordered chunks, "
"each a distinct concrete on-screen action or short sequence of keystrokes."
),
},
]
try:
response = self._call_model(
plan_messages, [PLAN_TOOL], {"type": "function", "function": {"name": "propose_plan"}}
)
call = response.choices[0].message.tool_calls[0]
args = json.loads(call.function.arguments)
chunks = [str(c).strip() for c in args.get("chunks", []) if str(c).strip()]
if len(chunks) == 1:
split = _split_run_on_chunk(chunks[0])
if len(split) > 1:
click.echo(f"[agent] plan came back as one bundled item; split into {len(split)} chunks.")
chunks = split
return chunks
except Exception as exc:
click.echo(f"[agent] planning failed ({exc}); running as a single unchunked session.")
return []
def _run_flat(self, instruction: str) -> None:
content = instruction
if self.duration:
content += (
f"\n\n(This is a {self.duration:.0f}-second timed session. Keep taking real "
"actions the whole time -- don't stop early.)"
)
messages: list[dict] = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": content},
]
stall_nudges = 0
max_stall_nudges = 5
step = 0
while step < self.max_steps:
step += 1
remaining = self._time_remaining()
if remaining is not None and remaining <= 0:
click.echo("[agent] duration elapsed; stopping.")
return
had_tools, finished, _ = self._step(messages, f"step {step}")
if finished:
click.echo("[agent] finished.")
return
if not had_tools:
remaining = self._time_remaining()
if remaining and remaining > 0 and stall_nudges < max_stall_nudges:
stall_nudges += 1
click.echo(f"[agent] stopped early with {remaining:.0f}s left; nudging to continue.")
messages.append(
{
"role": "user",
"content": (
f"You still have about {remaining:.0f} seconds left. Keep taking real "
"actions (send_keys/wait) and periodic screenshots -- do not stop until "
"the time is used or call finish only when truly done."
),
}
)
step -= 1 # this turn produced no progress; don't count it against max_steps
continue
if remaining and remaining > 0:
# The model won't cooperate further, but a --duration request is a promise
# about wall-clock footage, not about the model's willingness. The
# background ticker is still running: wait out the clock so the
# requested-length GIF/video gets produced regardless.
click.echo(
f"[agent] model stopped cooperating with {remaining:.0f}s left; "
"letting the automatic screenshot timer fill out the rest."
)
while True:
remaining = self._time_remaining()
if remaining is None or remaining <= 0:
break
time.sleep(min(remaining, 1.0))
return
click.echo("[agent] no further tool calls; stopping.")
return
click.echo(f"[agent] hit max-steps ({self.max_steps}) without finishing.")
def _run_chunked(self, overall_instruction: str, chunks: list[str]) -> None:
"""Execute the plan one chunk at a time, each with its OWN fresh, small context
(system prompt + this chunk's description + a one-line note from the previous chunk)
instead of one ever-growing conversation. This is the same fix long-form LLM writing
uses for book-length output: outline once, then generate each part with bounded,
mostly-independent context, rather than one giant continuously-growing prompt where
tool-call reliability degrades turn after turn."""
per_chunk_steps = max(4, self.max_steps // len(chunks))
carry = ""
for i, chunk_instruction in enumerate(chunks, start=1):
remaining = self._time_remaining()
if remaining is not None and remaining <= 0:
click.echo(f"[agent] duration elapsed before chunk {i}/{len(chunks)}; stopping.")
break
click.echo(f"[agent] --- chunk {i}/{len(chunks)}: {chunk_instruction} ---")
seed = f"Overall task: {overall_instruction}\n\nYou are now on step {i} of {len(chunks)}: {chunk_instruction}\n"
if carry:
seed += f"\nWhat just happened (for continuity -- don't repeat it): {carry}\n"
seed += (
"\nFocus only on this step. When it's done, call finish with a one-sentence "
"summary of what you did -- the next step picks up from there."
)
messages: list[dict] = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": seed},
]
summary = chunk_instruction
for step in range(1, per_chunk_steps + 1):
remaining = self._time_remaining()
if remaining is not None and remaining <= 0:
summary = "cut short: overall duration elapsed"
break
had_tools, finished, text = self._step(messages, f"chunk {i}/{len(chunks)} step {step}")
if text:
summary = text
if finished or not had_tools:
break # a plain non-tool reply also means this chunk considers itself done
carry = summary
click.echo(f"[agent] chunk {i}/{len(chunks)} done: {summary}")
def run(self, instruction: str) -> None:
# Plan (if applicable) BEFORE starting the clock/ticker: a --duration request is a
# promise about actual execution time, not wall-clock-including-setup, and planning is
# itself a network round-trip that can take several seconds on its own.
chunks: list[str] = []
if self.duration and self.duration > self.chunk_seconds * 1.5:
target = max(2, min(8, round(self.duration / self.chunk_seconds)))
click.echo(f"[agent] planning ~{target} chunks for this {self.duration:.0f}s session...")
chunks = self._plan_chunks(instruction, target)
if self.duration:
self._deadline = time.monotonic() + self.duration
self._start_ticker()
try:
if chunks:
click.echo(f"[agent] plan ({len(chunks)} chunks):")
for i, c in enumerate(chunks, start=1):
click.echo(f" {i}. {c}")
self._run_chunked(instruction, chunks)
else:
self._run_flat(instruction)
except _TRANSIENT_API_ERRORS as exc:
# Retries inside _call_model already absorbed every transient blip; reaching here
# means the backend is down for longer than the retry budget covers -- a real
# outage, not something more retrying fixes. Degrade gracefully: keep whatever the
# background ticker already captured and stop cleanly instead of a raw traceback
# that would also discard those screenshots.
click.echo(
f"[agent] stopping: the AI backend is still unreachable after retries ({exc}). "
f"Session '{self.app.session_name}' is untouched -- rerun the same command "
"later to pick up where this left off."
)
finally:
self._stop_ticker()
self.shots.sort()
if self.shots:
click.echo(f"Screenshots taken ({len(self.shots)}):")
for shot in self.shots:
click.echo(f" - {shot}")
def build_client(base_url: str, app_reference: str, timeout: float = 45.0) -> OpenAI:
api_key = os.environ.get("DEVPLACE_API_KEY")
if not api_key:
raise click.ClickException(
"DEVPLACE_API_KEY is not set. Export it first: "
"export DEVPLACE_API_KEY=<your key from https://devplace.net profile page>"
)
# Without a client-side timeout, one slow/hung call (a complex prompt on a "high reasoning
# effort" model, or a stalled connection) blocks indefinitely -- observed live: a single
# bundled multi-part chunk took well over 150s with no way to interrupt it. A bounded
# timeout turns that into an APITimeoutError, which _call_model already retries/backs off.
return OpenAI(
base_url=base_url, api_key=api_key, default_headers={"X-App-Reference": app_reference}, timeout=timeout
)
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from pathlib import Path
import click
from .render import frames_to_gif
from .tmux_control import TmuxApp
@click.command()
@click.argument("prompt", required=False)
@click.option("--session", default="tmux_shot_agent", show_default=True, help="tmux session to create/attach.")
@click.option("--command", default=None, help="Command to launch if the session doesn't exist yet.")
@click.option("--width", default=120, show_default=True)
@click.option("--height", default=40, show_default=True)
@click.option(
"--delay",
default=1.0,
show_default=True,
type=float,
help="Seconds to pause after each send_keys before the model looks again.",
)
@click.option(
"--max-steps",
default=60,
show_default=True,
help="Safety cap on agent tool-call round-trips. Raise this for long --duration runs.",
)
@click.option(
"--duration",
default=None,
type=float,
help=(
"Keep the agent actively working for this many seconds (e.g. 180 for 'three minutes "
"of X') instead of stopping as soon as it thinks it's done. A screenshot is captured "
"automatically every --interval seconds throughout, regardless of what the model does, "
"so a long GIF/video request always produces real footage."
),
)
@click.option(
"--interval",
default=5.0,
show_default=True,
type=float,
help="Seconds between automatic screenshots when --duration is set.",
)
@click.option(
"--chunk-seconds",
default=45.0,
show_default=True,
type=float,
help=(
"For --duration sessions much longer than this, the task is first split into an "
"ordered plan of roughly duration/chunk-seconds chunks, each run with its own fresh, "
"small context instead of one giant growing conversation -- same fix long-form LLM "
"writing uses (outline once, draft each part separately). Set very high (or use a "
"short --duration) to disable chunking."
),
)
@click.option("--out-dir", default="out/agent", show_default=True, type=click.Path())
@click.option("--model", default="molodetz", show_default=True)
@click.option("--base-url", default="https://devplace.net/openai/v1", show_default=True)
@click.option(
"--app-reference",
default="tmux-shot",
show_default=True,
help="Sent as X-App-Reference for cost attribution on the gateway.",
)
@click.option(
"--api-timeout",
default=45.0,
show_default=True,
type=float,
help="Per-request timeout in seconds. A slow/stuck call fails (and retries) instead of blocking forever.",
)
@click.option(
"--gif",
"gif_path",
default=None,
type=click.Path(),
help="Also stitch every screenshot taken during the run into an animated GIF at this path.",
)
@click.option("--gif-duration-ms", default=600, show_default=True, help="Milliseconds per frame in --gif output.")
def agent(
prompt: str | None,
session: str,
command: str | None,
width: int,
height: int,
delay: float,
max_steps: int,
duration: float | None,
interval: float,
chunk_seconds: float,
out_dir: str,
model: str,
base_url: str,
app_reference: str,
api_timeout: float,
gif_path: str | None,
gif_duration_ms: int,
) -> None:
"""Give a natural-language PROMPT; an LLM drives a TUI in tmux via tool calls
(send_keys, wait, read_text, screenshot, finish) and takes screenshots.
Everything is optional except the prompt itself:
tmux-agent "open the app, find the settings panel, screenshot it"
For a timed "N minutes of X" style session, add --duration and --gif:
tmux-agent --duration 180 --interval 5 --gif out/session.gif \\
--command "grok --cwd /tmp/scratch --always-approve" \\
"actively vibecode something small and fun with grok the whole time"
Every knob (--session, --command, --width/--height, --delay, --model, ...)
is still here when you need finer control over a specific run.
"""
if not prompt:
prompt = click.prompt("What should the agent do? (describe the whole photo session)")
from .agent import AgentRunner, build_client
tui = TmuxApp(session, command=command, width=width, height=height)
client = build_client(base_url, app_reference, timeout=api_timeout)
runner = AgentRunner(
tui,
client,
model=model,
out_dir=Path(out_dir),
delay=delay,
max_steps=max_steps,
duration=duration,
screenshot_interval=interval,
chunk_seconds=chunk_seconds,
)
runner.run(prompt)
if gif_path and runner.shots:
path = frames_to_gif(runner.shots, gif_path, duration_ms=gif_duration_ms)
click.echo(f"gif: {path}")
elif gif_path:
click.echo("gif: skipped, no screenshots were taken")
main = agent
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import click
from .agent_cli import agent
from .render import frames_to_gif
from .tmux_control import TmuxApp
@click.group()
def main() -> None:
"""tmux-shot: drive a TUI inside tmux from the shell and screenshot it."""
@main.command()
@click.argument("name")
@click.option("--command", default=None, help="Command to launch in the new session.")
@click.option("--width", default=120, show_default=True)
@click.option("--height", default=40, show_default=True)
def new(name: str, command: str | None, width: int, height: int) -> None:
"""Create (or attach to) a named tmux session."""
TmuxApp(name, command=command, width=width, height=height)
click.echo(f"session '{name}' ready")
@main.command()
@click.argument("name")
@click.argument("output", type=click.Path())
@click.option("--font-size", default=14, show_default=True)
@click.option("--freeze", "use_freeze", is_flag=True, help="Render via the external `freeze` CLI instead.")
def shot(name: str, output: str, font_size: int, use_freeze: bool) -> None:
"""Screenshot a session's current pane state to a PNG."""
app = TmuxApp(name)
path = app.screenshot_with_freeze(output) if use_freeze else app.screenshot(output, font_size=font_size)
click.echo(path)
@main.command()
@click.argument("name")
def text(name: str) -> None:
"""Print a session's current pane state as plain text."""
app = TmuxApp(name)
click.echo(app.capture_text())
@main.command()
@click.argument("name")
@click.argument("keys")
@click.option("--literal/--no-literal", default=False, help="Send keys literally instead of as tmux key names.")
@click.option("--enter/--no-enter", default=True, help="Send Enter after the keys.")
def send(name: str, keys: str, literal: bool, enter: bool) -> None:
"""Send keystrokes to a session."""
app = TmuxApp(name)
app.send_keys(keys, enter=enter, literal=literal)
@main.command()
@click.argument("name")
def kill(name: str) -> None:
"""Kill a named session."""
app = TmuxApp(name)
app.kill()
click.echo(f"session '{name}' killed")
@main.command()
@click.argument("frames", nargs=-1, required=True, type=click.Path(exists=True))
@click.argument("output", type=click.Path())
@click.option("--duration-ms", default=400, show_default=True, help="Milliseconds each frame is shown.")
@click.option("--loop/--no-loop", default=True, help="Loop the animation forever vs play once.")
def gif(frames: tuple[str, ...], output: str, duration_ms: int, loop: bool) -> None:
"""Stitch PNG screenshots (in the given order) into an animated GIF "movie"."""
path = frames_to_gif(list(frames), output, duration_ms=duration_ms, loop=0 if loop else 1)
click.echo(path)
main.add_command(agent, name="agent")
if __name__ == "__main__":
main()
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
from pathlib import Path
from typing import Optional
import pyte
from PIL import Image, ImageDraw, ImageFont
_FONT_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeMono.ttf",
"/System/Library/Fonts/Menlo.ttc",
"/Library/Fonts/Menlo.ttc",
]
_BOLD_FONT_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeMonoBold.ttf",
]
# xterm's default 16-color palette, keyed by the names pyte uses.
_ANSI_COLORS = {
"black": (0, 0, 0),
"red": (205, 0, 0),
"green": (0, 205, 0),
"brown": (205, 205, 0),
"yellow": (205, 205, 0),
"blue": (0, 0, 238),
"magenta": (205, 0, 205),
"cyan": (0, 205, 205),
"white": (229, 229, 229),
"brightblack": (127, 127, 127),
"brightred": (255, 0, 0),
"brightgreen": (0, 255, 0),
"brightbrown": (255, 255, 0),
"brightyellow": (255, 255, 0),
"brightblue": (92, 92, 255),
"brightmagenta": (255, 0, 255),
"brightcyan": (0, 255, 255),
"brightwhite": (255, 255, 255),
}
DEFAULT_FG = (229, 229, 229)
DEFAULT_BG = (0, 0, 0)
def _find_font(candidates: list[str], size: int) -> ImageFont.FreeTypeFont:
for path in candidates:
if Path(path).exists():
return ImageFont.truetype(path, size)
return ImageFont.load_default()
def _resolve_color(value: Optional[str], default: tuple[int, int, int]) -> tuple[int, int, int]:
if not value or value == "default":
return default
if len(value) == 6 and all(c in "0123456789abcdefABCDEF" for c in value):
return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16))
return _ANSI_COLORS.get(value, default)
def render_ansi_to_png(
ansi_text: str,
output_path: str | Path,
cols: int,
rows: int,
font_path: Optional[str] = None,
font_size: int = 14,
) -> str:
"""Replay an ANSI byte stream (e.g. from `tmux capture-pane -e`) into a PNG.
Uses pyte as a headless VT100 terminal emulator to resolve the escape
codes into a (char, fg, bg, bold, ...) grid, then draws that grid with
Pillow. No X server, browser, or external binary required.
"""
screen = pyte.Screen(cols, rows)
stream = pyte.Stream(screen)
# pyte's Stream treats "\n" as line-feed only (real VT100 semantics: no
# implicit carriage return), so a bare "\n" between tmux's captured rows
# leaves the cursor wherever the previous row's content ended instead of
# returning it to column 0 -- corrupting every row after the first one
# that doesn't happen to end exactly at the pane's edge.
stream.feed(ansi_text.replace("\n", "\r\n"))
if font_path:
font = ImageFont.truetype(font_path, font_size)
bold_font = font
else:
font = _find_font(_FONT_CANDIDATES, font_size)
bold_font = _find_font(_BOLD_FONT_CANDIDATES, font_size)
char_w = max(1, round(font.getlength("M")))
char_h = max(1, round(font_size * 1.4))
img = Image.new("RGB", (cols * char_w, rows * char_h), DEFAULT_BG)
draw = ImageDraw.Draw(img)
for y in range(rows):
row = screen.buffer[y]
for x in range(cols):
char = row[x]
fg = _resolve_color(char.fg, DEFAULT_FG)
bg = _resolve_color(char.bg, DEFAULT_BG)
if char.reverse:
fg, bg = bg, fg
px, py = x * char_w, y * char_h
if bg != DEFAULT_BG:
draw.rectangle([px, py, px + char_w, py + char_h], fill=bg)
if char.data and char.data != " ":
draw.text(
(px, py),
char.data,
font=bold_font if char.bold else font,
fill=fg,
)
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
img.save(output_path)
return str(output_path)
def frames_to_gif(
frame_paths: list[str | Path],
output_path: str | Path,
duration_ms: int = 400,
loop: int = 0,
) -> str:
"""Stitch a sequence of PNG screenshots into one animated GIF ("movie" mode).
Frames are resized to the first frame's canvas so a run against a
resizable/varying-content pane still produces a valid animation.
"""
if not frame_paths:
raise ValueError("frames_to_gif needs at least one frame")
frames = [Image.open(p).convert("RGB") for p in frame_paths]
size = frames[0].size
frames = [f if f.size == size else f.resize(size) for f in frames]
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
frames[0].save(
output_path,
save_all=True,
append_images=frames[1:],
duration=duration_ms,
loop=loop,
)
return str(output_path)
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import subprocess
import time
from pathlib import Path
from typing import Optional
import libtmux
from libtmux.pane import Pane
from libtmux.session import Session
from .render import render_ansi_to_png
class TmuxApp:
"""Drive a TUI running inside a named tmux session and screenshot it.
Attaching by name is idempotent: if a session with `session_name` is
already running, it's reused as-is (its own size, command, and state)
instead of being recreated.
"""
def __init__(
self,
session_name: str,
command: Optional[str] = None,
width: int = 120,
height: int = 40,
server: Optional[libtmux.Server] = None,
) -> None:
self.session_name = session_name
self.width = width
self.height = height
self.server = server or libtmux.Server()
self.session: Session = self._get_or_create_session(command)
def _get_or_create_session(self, command: Optional[str]) -> Session:
existing = self.server.sessions.get(session_name=self.session_name, default=None)
if existing is not None:
return existing
# Known tmux bug (tmux/tmux#4268): a detached `new-session -x -y` is
# silently resized to match whichever client was last active on the
# server, ignoring the requested size, whenever another client is
# attached elsewhere -- and this can happen before the target app has
# even started, so the app itself starts up with (and may cache) the
# wrong terminal size, corrupting anything it later draws with
# absolute cursor positioning (cut-off text, missing borders) even
# after we forcibly resize the pane back afterwards.
#
# Workaround: always start a plain shell first, pin the size (and
# wait for it to actually stick), and only *then* type the real
# command in -- so the target app never observes the wrong size.
session = self.server.new_session(
session_name=self.session_name,
attach=False,
x=self.width,
y=self.height,
)
session.cmd("set-option", "window-size", "manual")
session.cmd("resize-window", "-x", str(self.width), "-y", str(self.height))
self._wait_for_pinned_size(session.active_pane)
if command:
session.active_pane.send_keys(command, literal=True, enter=True)
return session
def _wait_for_pinned_size(self, pane: Pane, timeout: float = 2.0, interval: float = 0.05) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
pane.refresh()
if int(pane.width) == self.width and int(pane.height) == self.height:
return
time.sleep(interval)
@property
def pane(self) -> Pane:
return self.session.active_pane
def send_keys(self, keys: str, enter: bool = True, literal: bool = False) -> None:
"""Send one or more keys to the pane.
Non-literal mode splits on whitespace and sends each token as its own
tmux key-name argument (matching `tmux send-keys Down Down Right`
CLI semantics) — a single call can drive a multi-step move like
"Down Down Right Right Right". Use literal=True to type text verbatim,
spaces included.
"""
if literal:
self.pane.send_keys(keys, enter=enter, literal=True)
return
tokens = keys.split()
if not tokens:
return
self.pane.cmd("send-keys", *tokens)
if enter:
self.pane.enter()
def capture_ansi(self) -> str:
"""Pane content including ANSI color/style escape codes."""
lines = self.pane.capture_pane(escape_sequences=True) or []
return "\n".join(lines)
def capture_text(self) -> str:
"""Plain-text pane content, no styling — cheap to feed to a text-only agent."""
lines = self.pane.capture_pane() or []
return "\n".join(lines)
def wait_for(self, text: str, timeout: float = 10.0, interval: float = 0.2) -> str:
"""Poll the pane until `text` shows up in its plain-text content.
TUI startup/redraw latency varies (network calls, cold caches, etc.), so a
fixed sleep before a screenshot is inherently flaky. Returns the matching
capture; raises TimeoutError if `text` never appears within `timeout`.
"""
deadline = time.monotonic() + timeout
last = ""
while time.monotonic() < deadline:
last = self.capture_text()
if text in last:
return last
time.sleep(interval)
raise TimeoutError(f"timed out after {timeout}s waiting for {text!r} in pane output:\n{last}")
def live_size(self) -> tuple[int, int]:
"""The pane's actual current (cols, rows), refreshed from tmux.
Can diverge from the width/height passed to the constructor if the pane
was resized after creation (a client attaching, the app itself resizing
it, tmux's smallest-attached-client behavior, etc). Rendering with a
stale size mismatches the cursor-position escape codes the app actually
emitted, producing corrupted-looking screenshots — text cut off
mid-word, missing borders — so screenshot() always re-queries this.
"""
self.pane.refresh()
return int(self.pane.width), int(self.pane.height)
def screenshot(
self,
path: str | Path,
font_path: Optional[str] = None,
font_size: int = 14,
) -> str:
"""Render the current pane state to a PNG using the built-in pyte+Pillow renderer."""
ansi = self.capture_ansi()
cols, rows = self.live_size()
return render_ansi_to_png(
ansi,
path,
cols=cols,
rows=rows,
font_path=font_path,
font_size=font_size,
)
def screenshot_with_freeze(self, path: str | Path) -> str:
"""Render via the external `freeze` CLI (charmbracelet/freeze) if installed.
Optional alternative renderer with nicer typography/window chrome.
Falls back is not automatic — call screenshot() if freeze isn't installed.
"""
ansi = self.capture_ansi()
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
["freeze", "-o", str(path)],
input=ansi.encode(),
check=True,
)
return str(path)
def kill(self) -> None:
self.session.kill()
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import uuid
from pathlib import Path
import pytest
from tmux_shot import TmuxApp
REPO_ROOT = Path(__file__).resolve().parent.parent
DEMO_TUI = REPO_ROOT / "examples" / "demo_tui.py"
@pytest.fixture
def session_name() -> str:
return f"tmux_shot_test_{uuid.uuid4().hex[:8]}"
@pytest.fixture
def demo_app(session_name: str, tmp_path: Path):
app = TmuxApp(session_name, command=f"python3 {DEMO_TUI}", width=80, height=24)
try:
yield app
finally:
try:
app.kill()
except Exception:
pass # already dead (e.g. the test quit it itself)
@pytest.fixture
def out_dir(tmp_path: Path) -> Path:
d = tmp_path / "out"
d.mkdir()
return d
+370
View File
@@ -0,0 +1,370 @@
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any
from tmux_shot import TmuxApp
from tmux_shot.agent import AgentRunner, _split_run_on_chunk
class FakeFunction:
def __init__(self, name: str, arguments: str) -> None:
self.name = name
self.arguments = arguments
class FakeToolCall:
def __init__(self, call_id: str, name: str, arguments: str) -> None:
self.id = call_id
self.function = FakeFunction(name, arguments)
class FakeMessage:
def __init__(self, content: str | None = None, tool_calls: list[FakeToolCall] | None = None) -> None:
self.content = content
self.tool_calls = tool_calls or []
def model_dump(self, exclude_none: bool = True) -> dict[str, Any]:
d: dict[str, Any] = {"role": "assistant", "content": self.content}
if self.tool_calls:
d["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in self.tool_calls
]
if exclude_none:
d = {k: v for k, v in d.items() if v is not None}
return d
class FakeCompletions:
def __init__(self, script: list[FakeMessage]) -> None:
self._script = list(script)
self.requests: list[dict[str, Any]] = []
def create(self, **kwargs: Any):
# snapshot messages: it's the same mutable list AgentRunner keeps appending
# to for the rest of the run, so store a copy or every recorded request
# would end up reflecting the final state instead of the state at call time.
self.requests.append({**kwargs, "messages": list(kwargs["messages"])})
item = self._script.pop(0)
if isinstance(item, Exception):
raise item
return type("R", (), {"choices": [type("C", (), {"message": item})()]})()
class FakeChat:
def __init__(self, script: list[FakeMessage]) -> None:
self.completions = FakeCompletions(script)
class FakeClient:
def __init__(self, script: list[FakeMessage]) -> None:
self.chat = FakeChat(script)
def test_agent_runner_drives_app_via_scripted_tool_calls(demo_app: TmuxApp, out_dir: Path) -> None:
time.sleep(0.3)
script = [
FakeMessage(
content="Moving down.",
tool_calls=[FakeToolCall("call_1", "send_keys", '{"keys": "Down Down", "enter": false}')],
),
FakeMessage(
tool_calls=[FakeToolCall("call_2", "screenshot", '{"label": "moved"}')],
),
FakeMessage(
tool_calls=[FakeToolCall("call_3", "finish", '{"summary": "moved and captured"}')],
),
]
client = FakeClient(script)
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.05, max_steps=5)
runner.run("move down twice and screenshot it")
assert len(runner.shots) == 1
assert Path(runner.shots[0]).exists()
# 3 request round-trips: send_keys turn, screenshot turn, finish turn
assert len(client.chat.completions.requests) == 3
# the request after the screenshot tool call must include the image as a follow-up
# user message, immediately after that turn's tool-response message
third_request_messages = client.chat.completions.requests[2]["messages"]
roles = [m["role"] for m in third_request_messages]
assert roles.count("tool") == 2 # one per completed tool call so far
image_messages = [
m for m in third_request_messages if m["role"] == "user" and isinstance(m.get("content"), list)
]
assert len(image_messages) == 1
content_types = [part["type"] for part in image_messages[0]["content"]]
assert "image_url" in content_types
def test_agent_runner_stops_when_model_returns_no_tool_calls(demo_app: TmuxApp, out_dir: Path) -> None:
script = [FakeMessage(content="Nothing to do here.")]
client = FakeClient(script)
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.05, max_steps=5)
runner.run("do nothing")
assert runner.shots == []
assert len(client.chat.completions.requests) == 1
def test_agent_runner_keeps_capturing_via_ticker_even_if_model_refuses_outright(
demo_app: TmuxApp, out_dir: Path
) -> None:
"""Reproduces a real failure: the model flatly refuses a 'gif of N minutes of X' request
("I'm sorry, but I can't help with that.") instead of calling any tool. With --duration
set, the background screenshot ticker must still cover the full requested wall-clock time
regardless of the model's cooperation, so a timed GIF/video request is a mechanical
guarantee, not something that depends on the model understanding the ask."""
refusal = FakeMessage(content="I'm sorry, but I can't help with that.")
client = FakeClient([refusal] * 10) # more than enough for every nudge attempt
runner = AgentRunner(
demo_app,
client,
model="fake-model",
out_dir=out_dir,
delay=0.0,
max_steps=50,
duration=1.2,
screenshot_interval=0.2,
)
start = time.monotonic()
runner.run("I want a gif of you actively using this app")
elapsed = time.monotonic() - start
assert elapsed >= 1.0 # actually waited out roughly the full duration, not just bailed
assert len(runner.shots) >= 3 # the ticker produced real frames despite total refusal
for shot in runner.shots:
assert Path(shot).exists()
def test_agent_runner_survives_malformed_tool_call_instead_of_crashing(
demo_app: TmuxApp, out_dir: Path
) -> None:
"""Reproduces a real crash: the model called send_keys({}) -- no 'keys' argument at all --
which used to raise an unhandled KeyError and kill the whole process. It must instead come
back as a tool-result error the model can read and recover from."""
script = [
FakeMessage(tool_calls=[FakeToolCall("call_1", "send_keys", "{}")]), # missing "keys"
FakeMessage(tool_calls=[FakeToolCall("call_2", "send_keys", "not json")]), # malformed JSON
FakeMessage(tool_calls=[FakeToolCall("call_3", "finish", '{"summary": "recovered"}')]),
]
client = FakeClient(script)
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.0, max_steps=5)
runner.run("do something") # must not raise
third_request_messages = client.chat.completions.requests[2]["messages"]
tool_messages = [m for m in third_request_messages if m["role"] == "tool"]
assert len(tool_messages) == 2
assert "error" in tool_messages[0]["content"]
assert "missing required argument" in tool_messages[0]["content"]
assert "error" in tool_messages[1]["content"]
def test_split_run_on_chunk_splits_bundled_numbered_items() -> None:
bundled = (
"1. Open vim in a new terminal and create a file. "
"2. Use fzf to search project files and jump to a definition. "
"3. Duplicate a heading with a macro and save."
)
parts = _split_run_on_chunk(bundled)
assert len(parts) == 3
assert parts[0].startswith("Open vim")
assert parts[1].startswith("Use fzf")
assert parts[2].startswith("Duplicate a heading")
def test_split_run_on_chunk_leaves_ordinary_text_alone() -> None:
assert _split_run_on_chunk("create a macro with qa and replay it with 5@a") == [
"create a macro with qa and replay it with 5@a"
]
def test_agent_runner_normalizes_a_bundled_plan_into_separate_chunks(
demo_app: TmuxApp, out_dir: Path, capsys
) -> None:
"""Reproduces a real failure: the model returned a plan with all 3 steps bundled into a
single array element ('1. ... 2. ... 3. ...') instead of 3 separate elements as instructed.
The runner must recover this into real separate chunks rather than executing one giant,
unbounded mega-chunk."""
bundled_plan_call = FakeToolCall(
"plan_1",
"propose_plan",
json.dumps(
{
"chunks": [
"1. Open vim and create a file. 2. Record a macro and replay it 5 times. "
"3. Open a vertical split and copy a line across."
]
}
),
)
script = [
FakeMessage(tool_calls=[bundled_plan_call]),
FakeMessage(tool_calls=[FakeToolCall("c1", "finish", '{"summary": "opened file"}')]),
FakeMessage(tool_calls=[FakeToolCall("c2", "finish", '{"summary": "did macro"}')]),
FakeMessage(tool_calls=[FakeToolCall("c3", "finish", '{"summary": "did split"}')]),
]
client = FakeClient(script)
runner = AgentRunner(
demo_app,
client,
model="fake-model",
out_dir=out_dir,
delay=0.0,
max_steps=12,
duration=6.0,
screenshot_interval=100.0,
chunk_seconds=2.0,
)
runner.run("give a demo of advanced vim features")
out = capsys.readouterr().out
assert "split into 3 chunks" in out
assert "chunk 3/3 done: did split" in out
def test_agent_runner_chunks_a_long_duration_session_with_status_updates(
demo_app: TmuxApp, out_dir: Path, capsys
) -> None:
"""A long --duration task should be planned into chunks (like an outline before drafting
chapters) and each chunk run with its own fresh context, printing a status line per chunk
as it completes."""
plan_call = FakeToolCall(
"plan_1", "propose_plan", '{"chunks": ["show text objects", "show macros", "show marks"]}'
)
script = [
FakeMessage(tool_calls=[plan_call]),
FakeMessage(tool_calls=[FakeToolCall("c1", "finish", '{"summary": "did text objects"}')]),
FakeMessage(tool_calls=[FakeToolCall("c2", "finish", '{"summary": "did macros"}')]),
FakeMessage(tool_calls=[FakeToolCall("c3", "finish", '{"summary": "did marks"}')]),
]
client = FakeClient(script)
runner = AgentRunner(
demo_app,
client,
model="fake-model",
out_dir=out_dir,
delay=0.0,
max_steps=12,
duration=6.0,
screenshot_interval=100.0, # long enough the ticker won't fire during this fast fake run
chunk_seconds=2.0, # 6s / 2s -> plans for ~3 chunks
)
runner.run("give a demo of advanced vim features")
assert len(client.chat.completions.requests) == 4 # 1 planning call + 3 chunk turns
out = capsys.readouterr().out
assert "plan (3 chunks)" in out
assert "chunk 1/3 done: did text objects" in out
assert "chunk 2/3 done: did macros" in out
assert "chunk 3/3 done: did marks" in out
def test_agent_runner_retries_transient_api_errors_instead_of_crashing(
demo_app: TmuxApp, out_dir: Path
) -> None:
"""Reproduces a real crash: the upstream gateway returned a transient 502
(openai.InternalServerError) mid-session, which used to propagate straight up and kill the
whole run. It must be retried with backoff instead."""
import httpx2
from openai import InternalServerError
request = httpx2.Request("POST", "https://example.invalid/v1/chat/completions")
response = httpx2.Response(502, request=request, json={"detail": "Upstream connection failed"})
transient_error = InternalServerError("Bad Gateway", response=response, body=None)
script = [
transient_error,
transient_error,
FakeMessage(tool_calls=[FakeToolCall("call_1", "finish", '{"summary": "done after retry"}')]),
]
client = FakeClient(script)
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.0, max_steps=5)
runner.run("do something") # must not raise
assert len(client.chat.completions.requests) == 3
def test_agent_runner_degrades_gracefully_on_persistent_backend_outage(
demo_app: TmuxApp, out_dir: Path, monkeypatch
) -> None:
"""Reproduces a real outage: the backend stayed down for longer than the retry budget
(every call 502s). run() must not crash with a raw traceback -- it should stop cleanly and
keep reporting whatever screenshots were already captured."""
import httpx2
from openai import InternalServerError
import tmux_shot.agent as agent_module
monkeypatch.setattr(agent_module.time, "sleep", lambda _seconds: None) # skip real backoff waits
request = httpx2.Request("POST", "https://example.invalid/v1/chat/completions")
response = httpx2.Response(502, request=request, json={"detail": "Upstream connection failed"})
always_down = InternalServerError("Bad Gateway", response=response, body=None)
client = FakeClient(
[
FakeMessage(tool_calls=[FakeToolCall("call_1", "screenshot", '{"label": "before-outage"}')]),
always_down,
always_down,
always_down,
always_down,
]
)
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.0, max_steps=5)
runner.run("do something") # must not raise
assert len(runner.shots) == 1 # the screenshot taken before the outage is not lost
assert Path(runner.shots[0]).exists()
def test_agent_step_strips_leaked_chat_template_tokens_from_tool_name(
demo_app: TmuxApp, out_dir: Path
) -> None:
"""Reproduces a real artifact: a backend leaked raw harmony-style channel tokens into the
function name itself ("screenshot<|channel|>commentary" instead of "screenshot"). It must
still dispatch as the real tool on the first try, not fall through to 'unknown tool'."""
script = [
FakeMessage(tool_calls=[FakeToolCall("call_1", "read_text<|channel|>commentary", "{}")]),
]
client = FakeClient(script)
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.0, max_steps=5)
messages: list[dict] = []
had_tools, finished, _ = runner._step(messages, "step 1")
tool_message = next(m for m in messages if m["role"] == "tool")
assert "unknown tool" not in tool_message["content"]
assert "error" not in tool_message["content"]
def test_agent_runner_respects_max_steps_safety_cap(demo_app: TmuxApp, out_dir: Path) -> None:
# every turn asks to wait, never calling finish -> must stop at max_steps, not hang forever
script = [
FakeMessage(tool_calls=[FakeToolCall(f"call_{i}", "wait", '{"seconds": 0.01}')]) for i in range(10)
]
client = FakeClient(script)
runner = AgentRunner(demo_app, client, model="fake-model", out_dir=out_dir, delay=0.0, max_steps=3)
runner.run("wait forever")
assert len(client.chat.completions.requests) == 3
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import subprocess
import sys
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DEMO_TUI = REPO_ROOT / "examples" / "demo_tui.py"
def run_cli(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-m", "tmux_shot.cli", *args],
capture_output=True,
text=True,
timeout=15,
)
def test_cli_full_lifecycle(session_name: str, out_dir: Path) -> None:
try:
result = run_cli("new", session_name, "--command", f"python3 {DEMO_TUI}", "--width", "80", "--height", "24")
assert result.returncode == 0, result.stderr
assert "ready" in result.stdout
time.sleep(0.3)
text_result = run_cli("text", session_name)
assert text_result.returncode == 0, text_result.stderr
assert "tmux-shot demo TUI" in text_result.stdout
shot_path = out_dir / "cli_shot.png"
shot_result = run_cli("shot", session_name, str(shot_path))
assert shot_result.returncode == 0, shot_result.stderr
assert shot_path.exists()
assert shot_path.stat().st_size > 0
send_result = run_cli("send", session_name, "Down", "--no-enter")
assert send_result.returncode == 0, send_result.stderr
finally:
run_cli("kill", session_name)
def test_cli_gif_command_stitches_frames(out_dir: Path) -> None:
from tmux_shot.render import render_ansi_to_png
frames = [
render_ansi_to_png("frame a", out_dir / "a.png", cols=8, rows=1),
render_ansi_to_png("frame b", out_dir / "b.png", cols=8, rows=1),
]
gif_path = out_dir / "movie.gif"
result = run_cli("gif", *frames, str(gif_path), "--duration-ms", "150")
assert result.returncode == 0, result.stderr
assert gif_path.exists()
+73
View File
@@ -0,0 +1,73 @@
"""Capability probe against a real, complex third-party TUI: the `grok` CLI.
Everything here is strictly read-only from grok's point of view: we only ever
send Down/Up (menu highlight movement) and never press Enter on its prompt,
so no prompt is ever submitted to a model and no tool/agent action can run.
Skipped entirely when `grok` isn't installed, since it's a local dev tool,
not a project dependency.
"""
from __future__ import annotations
import shutil
import time
import uuid
from pathlib import Path
import pytest
from PIL import Image
from tmux_shot import TmuxApp, frames_to_gif
pytestmark = pytest.mark.skipif(shutil.which("grok") is None, reason="grok CLI not installed on this machine")
def test_grok_doctor_screen_renders_correctly(out_dir: Path) -> None:
session = f"tmux_shot_grok_doctor_{uuid.uuid4().hex[:8]}"
app = TmuxApp(session, command="grok doctor", width=100, height=35)
try:
text = app.wait_for("issue", timeout=15)
assert "Checks not completed" in text
path = app.screenshot(out_dir / "doctor.png")
img = Image.open(path)
assert img.width == 100 * round(img.width / 100) # sane multiple-of-cols size
assert img.height > 0
finally:
try:
app.kill()
except Exception:
pass
def test_grok_welcome_screen_and_menu_navigation_produce_a_movie(out_dir: Path, tmp_path: Path) -> None:
scratch_cwd = tmp_path / "grok_scratch"
scratch_cwd.mkdir()
session = f"tmux_shot_grok_welcome_{uuid.uuid4().hex[:8]}"
app = TmuxApp(session, command=f"grok --cwd {scratch_cwd}", width=100, height=35)
frames: list[str] = []
try:
app.wait_for("Grok Build", timeout=15)
time.sleep(0.3) # let the box-drawing/logo finish painting after the text appears
frames.append(app.screenshot(out_dir / "01_welcome.png"))
# Move the menu highlight down twice, screenshotting between moves. Arrow
# keys only -- never Enter, so nothing gets submitted to grok's model.
for i, _ in enumerate(range(2), start=2):
app.send_keys("Down", enter=False)
time.sleep(0.3)
frames.append(app.screenshot(out_dir / f"{i:02d}_menu.png"))
assert len(frames) == 3
for f in frames:
assert Path(f).stat().st_size > 0
movie_path = frames_to_gif(frames, out_dir / "grok_welcome.gif", duration_ms=700)
with Image.open(movie_path) as gif:
assert gif.is_animated
assert gif.n_frames == 3
finally:
try:
app.kill()
except Exception:
pass
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from pathlib import Path
from PIL import Image
from tmux_shot.render import frames_to_gif, render_ansi_to_png
def test_render_plain_text_produces_correctly_sized_image(out_dir: Path) -> None:
path = render_ansi_to_png("hello world", out_dir / "plain.png", cols=20, rows=3, font_size=14)
img = Image.open(path)
assert img.size[0] > 0 and img.size[1] > 0
# 20 cols x 3 rows of cells, image should be a whole multiple of cell size in each axis
assert img.width % 20 == 0
assert img.height % 3 == 0
def test_render_resolves_truecolor_background_and_foreground(out_dir: Path) -> None:
# SGR 38;2 = truecolor fg, 48;2 = truecolor bg. Pure red bg, pure green text.
ansi = "\x1b[48;2;255;0;0m\x1b[38;2;0;255;0mX\x1b[0m"
path = render_ansi_to_png(ansi, out_dir / "color.png", cols=1, rows=1, font_size=20)
img = Image.open(path)
# corner pixel is background-only (no glyph ink there)
corner = img.getpixel((0, 0))
assert corner == (255, 0, 0)
def test_render_reverse_video_swaps_fg_and_bg(out_dir: Path) -> None:
ansi = "\x1b[38;2;255;0;0m\x1b[48;2;0;0;255m\x1b[7mX\x1b[0m" # red fg, blue bg, reversed
path = render_ansi_to_png(ansi, out_dir / "reverse.png", cols=1, rows=1, font_size=20)
img = Image.open(path)
# reversed: background should now be the (originally-foreground) red
assert img.getpixel((0, 0)) == (255, 0, 0)
def test_render_handles_ansi_16_color_names(out_dir: Path) -> None:
ansi = "\x1b[42mX\x1b[0m" # classic SGR green background
path = render_ansi_to_png(ansi, out_dir / "named.png", cols=1, rows=1, font_size=20)
img = Image.open(path)
assert img.getpixel((0, 0)) == (0, 205, 0)
def test_frames_to_gif_stitches_multiple_pngs_into_one_animation(out_dir: Path) -> None:
frame_paths = []
for i, label in enumerate(["one", "two", "three"]):
p = render_ansi_to_png(f"frame {label}", out_dir / f"f{i}.png", cols=10, rows=1, font_size=14)
frame_paths.append(p)
gif_path = frames_to_gif(frame_paths, out_dir / "movie.gif", duration_ms=100)
assert Path(gif_path).exists()
with Image.open(gif_path) as gif:
assert gif.is_animated
assert gif.n_frames == 3
def test_frames_to_gif_rejects_empty_input(out_dir: Path) -> None:
import pytest
with pytest.raises(ValueError):
frames_to_gif([], out_dir / "empty.gif")
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import time
from pathlib import Path
from PIL import Image
from tmux_shot import TmuxApp
def test_session_is_created_and_idempotent_on_reattach(session_name: str, demo_app: TmuxApp) -> None:
again = TmuxApp(session_name) # should attach, not recreate
assert again.session.session_name == demo_app.session.session_name
def test_capture_text_shows_initial_frame(demo_app: TmuxApp) -> None:
time.sleep(0.3)
text = demo_app.capture_text()
assert "tmux-shot demo TUI" in text
assert "@" in text
def test_send_keys_multi_token_string_moves_cursor(demo_app: TmuxApp) -> None:
"""Regression test: a single send_keys("Down Down Right") call must be split into
separate tmux key-name arguments, not sent as one literal string (which tmux would
silently mis-send as garbage keystrokes that don't move anything)."""
time.sleep(0.3)
before = demo_app.capture_text()
demo_app.send_keys("Down Down Right Right Right", enter=False)
time.sleep(0.3)
after = demo_app.capture_text()
assert before != after
after_lines = [l for l in after.splitlines() if "@" in l]
assert after_lines, "cursor marker should still be present somewhere"
def test_send_keys_literal_types_text_verbatim(demo_app: TmuxApp) -> None:
# 'q' quits the demo curses app back to the underlying shell.
time.sleep(0.3)
assert "tmux-shot demo TUI" in demo_app.capture_text()
demo_app.send_keys("q", literal=True, enter=False)
time.sleep(0.5)
assert "tmux-shot demo TUI" not in demo_app.capture_text()
def test_screenshot_produces_a_valid_sized_png(demo_app: TmuxApp, out_dir: Path) -> None:
time.sleep(0.3)
path = demo_app.screenshot(out_dir / "shot.png")
img = Image.open(path)
assert img.width > 0 and img.height > 0
# 80 cols x 24 rows pane
assert img.width % 80 == 0
assert img.height % 24 == 0
def test_capture_ansi_includes_escape_codes(demo_app: TmuxApp) -> None:
time.sleep(0.3)
ansi = demo_app.capture_ansi()
assert "\x1b[" in ansi