Skip to content

Commit 13268ea

Browse files
Kasper Jungeclaude
authored andcommitted
feat: generalize peek_dev harness as scripts/tui_dev for any TUI iteration
The snapshot/live harness was already capable of rendering any ConsoleEmitter state — only the fixtures and naming pinned it to the peek panel. Rename the directory and add an EVENT_SCENARIOS dict with non-peek fixtures so the harness can drive iteration result lines, run summaries, error logs, and markdown rendering through the same PNG-snapshot loop. Renames: - scripts/peek_dev/ → scripts/tui_dev/ - docs/contributing/peek-dev-harness.md → tui-dev-harness.md snapshot.py grows a `_snapshot_event_sequence` driver that takes a list of (EventType, dict) tuples and emits them through the recording console — used for any TUI state that doesn't need direct access to the panel internals. Five new fixtures land in fixtures.py: - 10_iteration_success (happy path + markdown result) - 11_iteration_failed (red failure + log file path) - 12_iteration_timeout (yellow timeout branch) - 13_run_summary_mixed (multi-iteration summary) - 14_log_error (error log + traceback) CLAUDE.md gets a new "Iterating on the TUI" section pointing at the harness as the primary visual-design loop. The docs page is rewritten to describe both fixture flavours and explain when to add each. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent eef52c2 commit 13268ea

11 files changed

Lines changed: 1385 additions & 1 deletion

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ mess/
1414
.agents/
1515
.DS_Store
1616
.coverage
17-
.ralphify/
17+
.ralphify/
18+
scripts/tui_dev/output/
19+

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ A **ralph** is a directory containing a `RALPH.md` file. That's it. No project-l
5959
- `README.md` — keep short and high-level. Update only when the change affects the quickstart, install, or core concepts.
6060
- `CHANGELOG.md` — add an entry for every release.
6161

62+
## Iterating on the TUI
63+
64+
Editing `_console_emitter.py` and need to see what the terminal output looks like? Run `./scripts/tui_dev/run.sh` to regenerate PNG snapshots in `scripts/tui_dev/output/`, then `Read` them in your editor. The harness covers both the live peek panel *and* general terminal states (iteration result lines, run summaries, error logs, markdown results) — add a fixture to `scripts/tui_dev/fixtures.py` for any new state worth iterating on. See `docs/contributing/tui-dev-harness.md` for details.
65+
6266
## Boundaries
6367

6468
### Always Do
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# TUI dev harness
2+
3+
The `ralph` CLI's terminal output — the iteration headers, peek panel,
4+
result lines, run summary, and error logs — is rendered by
5+
`ConsoleEmitter` in `src/ralphify/_console_emitter.py`. Iterating on
6+
that visual design needs visual feedback, but coding agents running in
7+
a non-interactive environment can't watch a live terminal. The harness
8+
at `scripts/tui_dev/` solves this — it generates PNG snapshots of any
9+
`ConsoleEmitter` state that any agent (or human) can view.
10+
11+
## Usage
12+
13+
```bash
14+
./scripts/tui_dev/run.sh # regenerate everything (~15s)
15+
./scripts/tui_dev/run.sh snapshot # fixture-driven mode only
16+
./scripts/tui_dev/run.sh live # real ralph run via pty only
17+
```
18+
19+
Outputs land in `scripts/tui_dev/output/*.png` (gitignored). The
20+
iteration cycle is: edit `_console_emitter.py` → rerun the harness →
21+
view the PNGs → repeat.
22+
23+
## Modes
24+
25+
### Snapshot mode (`snapshot.py`)
26+
27+
Drives the real `ConsoleEmitter` in-process through canned fixtures
28+
defined in `fixtures.py`. Two flavours of fixture are supported:
29+
30+
#### Peek-panel scenarios (`ALL_SCENARIOS`)
31+
32+
Each scenario is a list of parsed Claude stream-json dicts that get
33+
fed through a real `_IterationPanel`. Used for iterating on the live
34+
activity feed (the bordered panel shown when peek is on).
35+
36+
| Scenario | What it shows |
37+
|---|---|
38+
| `01_empty` | Peek toggled on, zero scroll lines yet |
39+
| `02_single_tool` | First tool call after peek turns on |
40+
| `03_mixed_activity` | Thinking, tool calls, text preview — typical mid-iteration |
41+
| `04_scroll_buffer_full` | 17 tool calls — exceeds the visible scroll cap |
42+
| `05_heavy_tokens` | 1M+ input tokens, exercising the `M` unit in the token formatter |
43+
| `06_rate_limit` | Rate-limit event mixed with normal activity |
44+
| `07_tool_error` | Red `tool_result` error branch |
45+
| `08_raw_spinner` | `_IterationSpinner` path for non-Claude agents |
46+
| `09_peek_off` | Peek toggled off mid-iteration — verifies the buffer persists |
47+
48+
#### Event-sequence scenarios (`EVENT_SCENARIOS`)
49+
50+
Each scenario is a list of `(EventType, dict)` tuples that get fed
51+
through the real emitter. Whatever the emitter prints to the recording
52+
console is what ends up in the snapshot. Used for iterating on
53+
**anything that isn't the peek panel** — iteration result lines, run
54+
summaries, error logs, markdown result rendering.
55+
56+
| Scenario | What it shows |
57+
|---|---|
58+
| `10_iteration_success` | Happy path: green checkmark + markdown result |
59+
| `11_iteration_failed` | Red failure with exit code and log file path |
60+
| `12_iteration_timeout` | Yellow timeout branch |
61+
| `13_run_summary_mixed` | Multi-iteration run with success / failure / timeout |
62+
| `14_log_error` | Error log message with traceback |
63+
64+
Snapshot mode subclasses `ConsoleEmitter` to disable `Live.start`, so
65+
each scenario produces exactly one frozen render. Rendering goes
66+
through Rich's own `save_svg` → headless Chrome → PNG — no lossy
67+
intermediaries, and colors/fonts match what Rich emits to the terminal.
68+
69+
Fast (~15s total), deterministic, no subprocess — this is the mode to
70+
use for most design work.
71+
72+
### Live mode (`live.py`)
73+
74+
Maximum fidelity. Spawns the real `ralph` binary in a pseudo-terminal
75+
via `pty.openpty()`, with `fake_bin/claude` (a Python script named
76+
`claude` so `_is_claude_command` treats it as a structured agent)
77+
emitting realistic stream-json on a 1.8s cadence. The capture
78+
deliberately freezes at t=9s so the `Live` panel is still
79+
mid-iteration, then feeds the raw ANSI byte stream through
80+
[`pyte`](https://github.com/selectel/pyte) — a pure-Python terminal
81+
emulator — to reduce cursor moves and clears to a stable screen grid.
82+
That grid is then rendered through Rich for the final SVG → PNG.
83+
84+
pyte is installed on-demand via `uv run --with pyte` (not a project
85+
dep).
86+
87+
## Adding a new scenario
88+
89+
### Peek-panel scenario
90+
91+
Append a builder function to `scripts/tui_dev/fixtures.py` and
92+
register it in `ALL_SCENARIOS`:
93+
94+
```python
95+
def scenario_my_new_case() -> list[dict[str, Any]]:
96+
return [
97+
system_init(),
98+
assistant_tool_use("Bash", {"command": "pytest -x"}, input_tokens=1200),
99+
# ...
100+
]
101+
102+
103+
ALL_SCENARIOS = {
104+
# ...
105+
"15_my_new_case": scenario_my_new_case(),
106+
}
107+
```
108+
109+
### Event-sequence scenario
110+
111+
Append an events builder and register it in `EVENT_SCENARIOS`:
112+
113+
```python
114+
def events_my_new_case() -> list[tuple[EventType, dict[str, Any]]]:
115+
return [
116+
_run_started("demo / 16_my_new_case"),
117+
(EventType.ITERATION_STARTED, {"iteration": 1}),
118+
(EventType.ITERATION_COMPLETED, {
119+
"iteration": 1,
120+
"detail": "completed (5s)",
121+
"log_file": None,
122+
"result_text": "all good",
123+
}),
124+
]
125+
126+
127+
EVENT_SCENARIOS = {
128+
# ...
129+
"16_my_new_case": events_my_new_case(),
130+
}
131+
```
132+
133+
The next `./scripts/tui_dev/run.sh snapshot` will produce
134+
`scripts/tui_dev/output/16_my_new_case.png`.
135+
136+
## Files
137+
138+
```
139+
scripts/tui_dev/
140+
├── run.sh # one-command launcher
141+
├── snapshot.py # mode A: in-process ConsoleEmitter + fixtures
142+
├── live.py # mode B: real ralph run via pty + pyte
143+
├── fixtures.py # canned event sequences (peek + general)
144+
├── render.py # shared SVG → PNG via headless Chrome
145+
├── fake_bin/claude # stub Claude agent used by live mode
146+
├── demo_ralph/RALPH.md # minimal ralph dir used by live mode
147+
└── output/ # PNG + SVG snapshots (gitignored)
148+
```

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,4 @@ nav:
139139
- Contributing:
140140
- contributing/index.md
141141
- Codebase Map: contributing/codebase-map.md
142+
- TUI Dev Harness: contributing/tui-dev-harness.md
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
agent: claude
3+
---
4+
refine the iteration panel layout in _console_emitter.py and keep tests green

scripts/tui_dev/fake_bin/claude

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
#!/usr/bin/env python3
2+
"""Stub Claude agent for peek dev harness.
3+
4+
Named ``claude`` (no extension) on purpose — ralphify's ``_is_claude_command``
5+
matches on ``Path(parts[0]).stem == "claude"``, so placing this file on PATH
6+
with that name makes ralphify treat it as a structured stream-json agent.
7+
8+
It ignores the ``--output-format stream-json --verbose`` flags that
9+
ralphify appends, drains the prompt from stdin (prompts can be large, so
10+
we must actually read them to avoid blocking ralphify's writer thread),
11+
and emits a realistic sequence of Claude stream-json events on stdout
12+
with small delays so peek has time to render intermediate states.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import json
18+
import sys
19+
import threading
20+
import time
21+
22+
EVENTS = [
23+
{"type": "system", "subtype": "init", "model": "claude-opus-4-6"},
24+
# Thinking
25+
{
26+
"type": "assistant",
27+
"message": {"content": [{"type": "thinking", "thinking": "..."}]},
28+
},
29+
# Read file
30+
{
31+
"type": "assistant",
32+
"message": {
33+
"content": [
34+
{
35+
"type": "tool_use",
36+
"name": "Read",
37+
"input": {
38+
"file_path": "/Users/kasper/Code/ralphify/src/ralphify/_console_emitter.py"
39+
},
40+
}
41+
],
42+
"usage": {
43+
"input_tokens": 2_800,
44+
"output_tokens": 120,
45+
"cache_read_input_tokens": 16_500,
46+
},
47+
},
48+
},
49+
# Grep
50+
{
51+
"type": "assistant",
52+
"message": {
53+
"content": [
54+
{
55+
"type": "tool_use",
56+
"name": "Grep",
57+
"input": {"pattern": "_IterationPanel"},
58+
}
59+
],
60+
"usage": {
61+
"input_tokens": 3_100,
62+
"output_tokens": 210,
63+
"cache_read_input_tokens": 16_500,
64+
},
65+
},
66+
},
67+
# Text preview
68+
{
69+
"type": "assistant",
70+
"message": {
71+
"content": [
72+
{
73+
"type": "text",
74+
"text": "I can see the iteration panel structure — let me refine the spacing.",
75+
}
76+
],
77+
"usage": {
78+
"input_tokens": 3_250,
79+
"output_tokens": 340,
80+
"cache_read_input_tokens": 16_500,
81+
},
82+
},
83+
},
84+
# Edit
85+
{
86+
"type": "assistant",
87+
"message": {
88+
"content": [
89+
{
90+
"type": "tool_use",
91+
"name": "Edit",
92+
"input": {
93+
"file_path": "/Users/kasper/Code/ralphify/src/ralphify/_console_emitter.py"
94+
},
95+
}
96+
],
97+
"usage": {
98+
"input_tokens": 3_500,
99+
"output_tokens": 480,
100+
"cache_read_input_tokens": 16_500,
101+
},
102+
},
103+
},
104+
# Bash
105+
{
106+
"type": "assistant",
107+
"message": {
108+
"content": [
109+
{
110+
"type": "tool_use",
111+
"name": "Bash",
112+
"input": {
113+
"command": "uv run pytest tests/test_console_emitter.py -x"
114+
},
115+
}
116+
],
117+
"usage": {
118+
"input_tokens": 3_750,
119+
"output_tokens": 560,
120+
"cache_read_input_tokens": 16_500,
121+
},
122+
},
123+
},
124+
# Final result
125+
{
126+
"type": "result",
127+
"result": "Refined the iteration panel spacing; all tests pass.",
128+
},
129+
]
130+
131+
132+
def _drain_stdin() -> None:
133+
"""Read and discard the prompt from stdin.
134+
135+
Ralphify writes the prompt on a background thread and closes stdin;
136+
if we never read it, the writer thread blocks on a full pipe buffer
137+
for large prompts. Daemon thread so the process can exit once all
138+
events are flushed to stdout.
139+
"""
140+
try:
141+
sys.stdin.read()
142+
except Exception:
143+
pass
144+
145+
146+
def main() -> int:
147+
drainer = threading.Thread(target=_drain_stdin, daemon=True)
148+
drainer.start()
149+
150+
# Slow pacing so the live-mode capture harness can freeze the pty
151+
# state mid-iteration and observe the active peek panel. Total
152+
# runtime is ~18s; capture typically freezes around t=8-10s after
153+
# 5-6 events have been emitted.
154+
delay = 1.8
155+
156+
for event in EVENTS:
157+
sys.stdout.write(json.dumps(event) + "\n")
158+
sys.stdout.flush()
159+
time.sleep(delay)
160+
161+
return 0
162+
163+
164+
if __name__ == "__main__":
165+
raise SystemExit(main())

0 commit comments

Comments
 (0)