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