etch-loop 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- etch_loop-0.1.0/.github/workflows/workflow.yml +34 -0
- etch_loop-0.1.0/PKG-INFO +13 -0
- etch_loop-0.1.0/README.md +1 -0
- etch_loop-0.1.0/pyproject.toml +21 -0
- etch_loop-0.1.0/src/etch/__init__.py +1 -0
- etch_loop-0.1.0/src/etch/agent.py +91 -0
- etch_loop-0.1.0/src/etch/cli.py +90 -0
- etch_loop-0.1.0/src/etch/display.py +389 -0
- etch_loop-0.1.0/src/etch/git.py +101 -0
- etch_loop-0.1.0/src/etch/loop.py +182 -0
- etch_loop-0.1.0/src/etch/prompt.py +75 -0
- etch_loop-0.1.0/src/etch/signals.py +78 -0
- etch_loop-0.1.0/src/etch/templates/BREAK.md +21 -0
- etch_loop-0.1.0/src/etch/templates/ETCH.md +29 -0
- etch_loop-0.1.0/tests/__init__.py +0 -0
- etch_loop-0.1.0/tests/test_git.py +136 -0
- etch_loop-0.1.0/tests/test_loop.py +212 -0
- etch_loop-0.1.0/tests/test_prompt.py +102 -0
- etch_loop-0.1.0/tests/test_signals.py +95 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.11"
|
|
16
|
+
- run: pip install build
|
|
17
|
+
- run: python -m build
|
|
18
|
+
- uses: actions/upload-artifact@v4
|
|
19
|
+
with:
|
|
20
|
+
name: dist
|
|
21
|
+
path: dist/
|
|
22
|
+
|
|
23
|
+
publish:
|
|
24
|
+
needs: build
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
environment: pypi
|
|
27
|
+
permissions:
|
|
28
|
+
id-token: write
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/download-artifact@v4
|
|
31
|
+
with:
|
|
32
|
+
name: dist
|
|
33
|
+
path: dist/
|
|
34
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
etch_loop-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: etch-loop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run Claude Code in a fix-break loop until your codebase is clean
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: rich
|
|
8
|
+
Requires-Dist: typer
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# etch-loop
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# etch-loop
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "etch-loop"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.11"
|
|
5
|
+
description = "Run Claude Code in a fix-break loop until your codebase is clean"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
dependencies = ["typer", "rich"]
|
|
9
|
+
|
|
10
|
+
[project.optional-dependencies]
|
|
11
|
+
dev = ["pytest"]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
etch = "etch.cli:app"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/etch"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Claude agent subprocess runner."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AgentError(Exception):
|
|
9
|
+
"""Raised when the agent subprocess fails or is unavailable."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(
|
|
13
|
+
prompt: str,
|
|
14
|
+
verbose: bool = False,
|
|
15
|
+
tick_callback: Callable[[str], None] | None = None,
|
|
16
|
+
) -> str:
|
|
17
|
+
"""Run the Claude agent with the given prompt piped to stdin.
|
|
18
|
+
|
|
19
|
+
Launches `claude -p --dangerously-skip-permissions` as a subprocess,
|
|
20
|
+
pipes `prompt` to its stdin, and returns the full stdout.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
prompt: The prompt text to send to Claude.
|
|
24
|
+
verbose: If True, streams output to terminal in addition to capturing it.
|
|
25
|
+
tick_callback: Optional callable called with each line of output as it
|
|
26
|
+
arrives. Used by display layer for streaming updates.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The full stdout output from the agent as a string.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
AgentError: If `claude` is not found, or exits with a non-zero code.
|
|
33
|
+
"""
|
|
34
|
+
cmd = ["claude", "-p", "--dangerously-skip-permissions"]
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
process = subprocess.Popen(
|
|
38
|
+
cmd,
|
|
39
|
+
stdin=subprocess.PIPE,
|
|
40
|
+
stdout=subprocess.PIPE,
|
|
41
|
+
stderr=subprocess.PIPE,
|
|
42
|
+
text=True,
|
|
43
|
+
encoding="utf-8",
|
|
44
|
+
errors="replace",
|
|
45
|
+
)
|
|
46
|
+
except FileNotFoundError:
|
|
47
|
+
raise AgentError(
|
|
48
|
+
"claude executable not found. "
|
|
49
|
+
"Is Claude Code installed? Run: npm install -g @anthropic-ai/claude-code"
|
|
50
|
+
)
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
raise AgentError(f"Failed to launch claude: {exc}") from exc
|
|
53
|
+
|
|
54
|
+
# Write prompt to stdin and close it
|
|
55
|
+
try:
|
|
56
|
+
process.stdin.write(prompt)
|
|
57
|
+
process.stdin.close()
|
|
58
|
+
except BrokenPipeError as exc:
|
|
59
|
+
raise AgentError(f"Failed to write prompt to claude stdin: {exc}") from exc
|
|
60
|
+
|
|
61
|
+
output_lines: list[str] = []
|
|
62
|
+
lock = threading.Lock()
|
|
63
|
+
|
|
64
|
+
def read_stdout() -> None:
|
|
65
|
+
assert process.stdout is not None
|
|
66
|
+
for line in process.stdout:
|
|
67
|
+
with lock:
|
|
68
|
+
output_lines.append(line)
|
|
69
|
+
if verbose:
|
|
70
|
+
print(line, end="", flush=True)
|
|
71
|
+
if tick_callback is not None:
|
|
72
|
+
tick_callback(line)
|
|
73
|
+
|
|
74
|
+
reader = threading.Thread(target=read_stdout, daemon=True)
|
|
75
|
+
reader.start()
|
|
76
|
+
reader.join()
|
|
77
|
+
|
|
78
|
+
process.wait()
|
|
79
|
+
|
|
80
|
+
# Capture stderr for error reporting
|
|
81
|
+
stderr_output = ""
|
|
82
|
+
if process.stderr:
|
|
83
|
+
stderr_output = process.stderr.read().strip()
|
|
84
|
+
|
|
85
|
+
if process.returncode != 0:
|
|
86
|
+
detail = stderr_output or "(no stderr)"
|
|
87
|
+
raise AgentError(
|
|
88
|
+
f"claude exited with code {process.returncode}: {detail}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return "".join(output_lines)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""CLI entry points for etch-loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from etch import display, loop
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="etch",
|
|
16
|
+
help="Run Claude Code in a fix-break loop, hunting for edge cases.",
|
|
17
|
+
add_completion=False,
|
|
18
|
+
pretty_exceptions_show_locals=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_TEMPLATES = ["ETCH.md", "BREAK.md"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command()
|
|
25
|
+
def init() -> None:
|
|
26
|
+
"""Copy ETCH.md and BREAK.md templates into the current directory."""
|
|
27
|
+
for filename in _TEMPLATES:
|
|
28
|
+
dest = Path.cwd() / filename
|
|
29
|
+
|
|
30
|
+
if dest.exists():
|
|
31
|
+
display.print_init_skip(filename)
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# Python 3.9+ traversable API
|
|
36
|
+
pkg_templates = importlib.resources.files("etch") / "templates" / filename
|
|
37
|
+
content = pkg_templates.read_text(encoding="utf-8")
|
|
38
|
+
dest.write_text(content, encoding="utf-8")
|
|
39
|
+
display.print_init_ok(filename)
|
|
40
|
+
except (FileNotFoundError, ModuleNotFoundError, TypeError) as exc:
|
|
41
|
+
display.print_error(f"Could not copy {filename}: {exc}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def run(
|
|
46
|
+
prompt: str = typer.Option(
|
|
47
|
+
"./ETCH.md",
|
|
48
|
+
"--prompt",
|
|
49
|
+
help="Path to the fixer prompt file (ETCH.md).",
|
|
50
|
+
show_default=True,
|
|
51
|
+
),
|
|
52
|
+
max_iterations: int = typer.Option(
|
|
53
|
+
20,
|
|
54
|
+
"--max-iterations",
|
|
55
|
+
"-n",
|
|
56
|
+
help="Maximum number of fix-break cycles.",
|
|
57
|
+
show_default=True,
|
|
58
|
+
min=1,
|
|
59
|
+
),
|
|
60
|
+
no_commit: bool = typer.Option(
|
|
61
|
+
False,
|
|
62
|
+
"--no-commit",
|
|
63
|
+
help="Skip git commits after fixer runs.",
|
|
64
|
+
is_flag=True,
|
|
65
|
+
),
|
|
66
|
+
dry_run: bool = typer.Option(
|
|
67
|
+
False,
|
|
68
|
+
"--dry-run",
|
|
69
|
+
help="Print the fixer prompt and exit without running.",
|
|
70
|
+
is_flag=True,
|
|
71
|
+
),
|
|
72
|
+
verbose: bool = typer.Option(
|
|
73
|
+
False,
|
|
74
|
+
"--verbose",
|
|
75
|
+
help="Stream agent output to the terminal.",
|
|
76
|
+
is_flag=True,
|
|
77
|
+
),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Run the fix-break loop against the current repository."""
|
|
80
|
+
try:
|
|
81
|
+
loop.run(
|
|
82
|
+
prompt_path=prompt,
|
|
83
|
+
max_iterations=max_iterations,
|
|
84
|
+
no_commit=no_commit,
|
|
85
|
+
dry_run=dry_run,
|
|
86
|
+
verbose=verbose,
|
|
87
|
+
)
|
|
88
|
+
except KeyboardInterrupt:
|
|
89
|
+
display.print_interrupted()
|
|
90
|
+
raise typer.Exit(code=130)
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Rich-based terminal UI for etch-loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rich.columns import Columns
|
|
12
|
+
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
|
13
|
+
from rich.live import Live
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.segment import Segment
|
|
16
|
+
from rich.style import Style
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from etch import __version__
|
|
21
|
+
|
|
22
|
+
# ── Palette ──────────────────────────────────────────────────────────────────
|
|
23
|
+
BG = "#0a0a0a"
|
|
24
|
+
FG = "#e8e8e8"
|
|
25
|
+
DIM = "#555555"
|
|
26
|
+
AMBER = "#d4a547"
|
|
27
|
+
GREEN = "#7aba78"
|
|
28
|
+
RED = "#c96a6a"
|
|
29
|
+
BORDER = "#1e1e1e"
|
|
30
|
+
|
|
31
|
+
# ── Symbols (ASCII only) ──────────────────────────────────────────────────────
|
|
32
|
+
SYM_RUN = ">"
|
|
33
|
+
SYM_OK = "+"
|
|
34
|
+
SYM_FAIL = "x"
|
|
35
|
+
SYM_NEUTRAL = "-"
|
|
36
|
+
|
|
37
|
+
SCAN_WIDTH = 40
|
|
38
|
+
SCAN_BLOCK = "▓▒ " # 3-char block
|
|
39
|
+
SCAN_FILL = "░"
|
|
40
|
+
TICK_MS = 80
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── ScanBar renderable ────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ScanBar:
|
|
47
|
+
"""A Rich renderable that renders one frame of the scan animation.
|
|
48
|
+
|
|
49
|
+
The bar is SCAN_WIDTH chars wide, filled with SCAN_FILL (░).
|
|
50
|
+
A 3-char SCAN_BLOCK (▓▒ ) slides left-to-right, wrapping at the end.
|
|
51
|
+
Rendered in amber.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, tick: int) -> None:
|
|
55
|
+
self.tick = tick
|
|
56
|
+
|
|
57
|
+
def __rich_console__(
|
|
58
|
+
self, console: Console, options: ConsoleOptions
|
|
59
|
+
) -> RenderResult:
|
|
60
|
+
width = SCAN_WIDTH
|
|
61
|
+
block = SCAN_BLOCK
|
|
62
|
+
block_len = len(block)
|
|
63
|
+
pos = self.tick % width
|
|
64
|
+
|
|
65
|
+
bar = list(SCAN_FILL * width)
|
|
66
|
+
for i, ch in enumerate(block):
|
|
67
|
+
idx = (pos + i) % width
|
|
68
|
+
bar[idx] = ch
|
|
69
|
+
|
|
70
|
+
yield Segment("".join(bar), Style(color=AMBER))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Log entry dataclass ───────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class _LogEntry:
|
|
78
|
+
"""One line in the scrolling log."""
|
|
79
|
+
|
|
80
|
+
symbol: str
|
|
81
|
+
phase: str
|
|
82
|
+
status: str
|
|
83
|
+
detail: str | RenderableType
|
|
84
|
+
color: str
|
|
85
|
+
running: bool = False
|
|
86
|
+
tick: int = 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── EtchDisplay ──────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class EtchDisplay:
|
|
93
|
+
"""Manages the Rich Live layout for the etch-loop run."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, target: str = "") -> None:
|
|
96
|
+
self._console = Console(style=f"on {BG}")
|
|
97
|
+
self._target = target
|
|
98
|
+
self._entries: list[_LogEntry] = []
|
|
99
|
+
self._stats: dict[str, Any] = {
|
|
100
|
+
"iterations": 0,
|
|
101
|
+
"fixes": 0,
|
|
102
|
+
"issues": 0,
|
|
103
|
+
"start": time.monotonic(),
|
|
104
|
+
}
|
|
105
|
+
self._live: Live | None = None
|
|
106
|
+
self._tick = 0
|
|
107
|
+
self._ticker_stop = threading.Event()
|
|
108
|
+
self._ticker_thread: threading.Thread | None = None
|
|
109
|
+
self._lock = threading.Lock()
|
|
110
|
+
|
|
111
|
+
# ── Public lifecycle ──────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def __enter__(self) -> "EtchDisplay":
|
|
114
|
+
self._live = Live(
|
|
115
|
+
self._render(),
|
|
116
|
+
console=self._console,
|
|
117
|
+
refresh_per_second=15,
|
|
118
|
+
transient=False,
|
|
119
|
+
)
|
|
120
|
+
self._live.__enter__()
|
|
121
|
+
self._start_ticker()
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def __exit__(self, *args: Any) -> None:
|
|
125
|
+
self._stop_ticker()
|
|
126
|
+
if self._live is not None:
|
|
127
|
+
self._live.__exit__(*args)
|
|
128
|
+
|
|
129
|
+
# ── Iteration / phase API ─────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def start_iteration(self, n: int) -> None:
|
|
132
|
+
"""Add an iteration header line to the log."""
|
|
133
|
+
with self._lock:
|
|
134
|
+
self._stats["iterations"] = n
|
|
135
|
+
self._entries.append(
|
|
136
|
+
_LogEntry(
|
|
137
|
+
symbol=SYM_NEUTRAL,
|
|
138
|
+
phase=f"iteration {n}",
|
|
139
|
+
status="",
|
|
140
|
+
detail="",
|
|
141
|
+
color=DIM,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
self._refresh()
|
|
145
|
+
|
|
146
|
+
def start_phase(self, phase: str) -> None:
|
|
147
|
+
"""Add a 'running' line with scan animation for the given phase."""
|
|
148
|
+
with self._lock:
|
|
149
|
+
self._entries.append(
|
|
150
|
+
_LogEntry(
|
|
151
|
+
symbol=SYM_RUN,
|
|
152
|
+
phase=phase,
|
|
153
|
+
status="running",
|
|
154
|
+
detail=ScanBar(self._tick),
|
|
155
|
+
color=AMBER,
|
|
156
|
+
running=True,
|
|
157
|
+
tick=self._tick,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
self._refresh()
|
|
161
|
+
|
|
162
|
+
def finish_phase(
|
|
163
|
+
self,
|
|
164
|
+
phase: str,
|
|
165
|
+
status: str,
|
|
166
|
+
detail: str,
|
|
167
|
+
duration: float,
|
|
168
|
+
success: bool = True,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Replace the last running line for `phase` with a finished result."""
|
|
171
|
+
color = GREEN if success else RED
|
|
172
|
+
symbol = SYM_OK if success else SYM_FAIL
|
|
173
|
+
dur_str = f"{duration:.1f}s"
|
|
174
|
+
|
|
175
|
+
with self._lock:
|
|
176
|
+
# Find the last running entry for this phase and replace it
|
|
177
|
+
for i in range(len(self._entries) - 1, -1, -1):
|
|
178
|
+
entry = self._entries[i]
|
|
179
|
+
if entry.phase == phase and entry.running:
|
|
180
|
+
self._entries[i] = _LogEntry(
|
|
181
|
+
symbol=symbol,
|
|
182
|
+
phase=phase,
|
|
183
|
+
status=status,
|
|
184
|
+
detail=detail,
|
|
185
|
+
color=color,
|
|
186
|
+
running=False,
|
|
187
|
+
)
|
|
188
|
+
break
|
|
189
|
+
self._refresh()
|
|
190
|
+
|
|
191
|
+
def record_fix(self) -> None:
|
|
192
|
+
"""Increment the fix counter."""
|
|
193
|
+
with self._lock:
|
|
194
|
+
self._stats["fixes"] += 1
|
|
195
|
+
|
|
196
|
+
def record_issue(self) -> None:
|
|
197
|
+
"""Increment the breaker-issues counter."""
|
|
198
|
+
with self._lock:
|
|
199
|
+
self._stats["issues"] += 1
|
|
200
|
+
|
|
201
|
+
def print_summary(self, stats: dict[str, Any]) -> None:
|
|
202
|
+
"""Print the final done/stopped/interrupted panel."""
|
|
203
|
+
self._stop_ticker()
|
|
204
|
+
if self._live is not None:
|
|
205
|
+
self._live.stop()
|
|
206
|
+
|
|
207
|
+
reason = stats.get("reason", "done")
|
|
208
|
+
elapsed = stats.get("elapsed", 0.0)
|
|
209
|
+
iterations = stats.get("iterations", 0)
|
|
210
|
+
fixes = stats.get("fixes", 0)
|
|
211
|
+
issues = stats.get("issues", 0)
|
|
212
|
+
|
|
213
|
+
elapsed_str = _format_elapsed(elapsed)
|
|
214
|
+
|
|
215
|
+
if reason == "clear":
|
|
216
|
+
title_color = GREEN
|
|
217
|
+
title = f"[{GREEN}]+ all clear[/{GREEN}]"
|
|
218
|
+
elif reason == "interrupted":
|
|
219
|
+
title_color = AMBER
|
|
220
|
+
title = f"[{AMBER}]- interrupted[/{AMBER}]"
|
|
221
|
+
elif reason == "max_iterations":
|
|
222
|
+
title_color = AMBER
|
|
223
|
+
title = f"[{AMBER}]- stopped (max iterations)[/{AMBER}]"
|
|
224
|
+
elif reason == "no_changes":
|
|
225
|
+
title_color = GREEN
|
|
226
|
+
title = f"[{GREEN}]+ clean — fixer found nothing[/{GREEN}]"
|
|
227
|
+
else:
|
|
228
|
+
title_color = FG
|
|
229
|
+
title = f"[{FG}]done[/{FG}]"
|
|
230
|
+
|
|
231
|
+
body = (
|
|
232
|
+
f"[{DIM}]iterations[/{DIM}] [{FG}]{iterations}[/{FG}] "
|
|
233
|
+
f"[{DIM}]fixes[/{DIM}] [{FG}]{fixes}[/{FG}] "
|
|
234
|
+
f"[{DIM}]breaker issues[/{DIM}] [{FG}]{issues}[/{FG}] "
|
|
235
|
+
f"[{DIM}]{elapsed_str} elapsed[/{DIM}]"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
panel = Panel(
|
|
239
|
+
body,
|
|
240
|
+
title=title,
|
|
241
|
+
border_style=Style(color=BORDER),
|
|
242
|
+
style=Style(bgcolor=BG),
|
|
243
|
+
)
|
|
244
|
+
self._console.print(panel)
|
|
245
|
+
|
|
246
|
+
# ── Rendering ─────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
def _render(self) -> RenderableType:
|
|
249
|
+
"""Build the full Live renderable."""
|
|
250
|
+
with self._lock:
|
|
251
|
+
entries = list(self._entries)
|
|
252
|
+
tick = self._tick
|
|
253
|
+
stats = dict(self._stats)
|
|
254
|
+
|
|
255
|
+
elapsed = time.monotonic() - stats["start"]
|
|
256
|
+
|
|
257
|
+
# Build log table
|
|
258
|
+
log_table = Table.grid(padding=(0, 1))
|
|
259
|
+
log_table.add_column(width=2) # symbol
|
|
260
|
+
log_table.add_column(width=10) # phase
|
|
261
|
+
log_table.add_column(width=12) # status
|
|
262
|
+
log_table.add_column() # detail
|
|
263
|
+
|
|
264
|
+
for entry in entries:
|
|
265
|
+
if entry.running:
|
|
266
|
+
detail_render: RenderableType = ScanBar(tick)
|
|
267
|
+
else:
|
|
268
|
+
detail_render = Text(str(entry.detail), style=Style(color=DIM))
|
|
269
|
+
|
|
270
|
+
sym_text = Text(entry.symbol, style=Style(color=entry.color))
|
|
271
|
+
phase_text = Text(entry.phase, style=Style(color=entry.color))
|
|
272
|
+
status_text = Text(entry.status, style=Style(color=DIM))
|
|
273
|
+
|
|
274
|
+
log_table.add_row(sym_text, phase_text, status_text, detail_render)
|
|
275
|
+
|
|
276
|
+
# Header
|
|
277
|
+
title_str = f"etch loop v{__version__}"
|
|
278
|
+
if self._target:
|
|
279
|
+
title_str += f" {self._target}"
|
|
280
|
+
|
|
281
|
+
# Stats footer
|
|
282
|
+
elapsed_str = _format_elapsed(elapsed)
|
|
283
|
+
footer = (
|
|
284
|
+
f"[{DIM}]iterations[/{DIM}] [{FG}]{stats['iterations']}[/{FG}] "
|
|
285
|
+
f"[{DIM}]fixes[/{DIM}] [{FG}]{stats['fixes']}[/{FG}] "
|
|
286
|
+
f"[{DIM}]breaker issues[/{DIM}] [{FG}]{stats['issues']}[/{FG}] "
|
|
287
|
+
f"[{DIM}]{elapsed_str} elapsed[/{DIM}]"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
panel = Panel(
|
|
291
|
+
log_table,
|
|
292
|
+
title=f"[{AMBER}]{title_str}[/{AMBER}]",
|
|
293
|
+
subtitle=footer,
|
|
294
|
+
border_style=Style(color=BORDER),
|
|
295
|
+
style=Style(bgcolor=BG),
|
|
296
|
+
)
|
|
297
|
+
return panel
|
|
298
|
+
|
|
299
|
+
def _refresh(self) -> None:
|
|
300
|
+
if self._live is not None:
|
|
301
|
+
self._live.update(self._render())
|
|
302
|
+
|
|
303
|
+
# ── Ticker thread ─────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
def _start_ticker(self) -> None:
|
|
306
|
+
self._ticker_stop.clear()
|
|
307
|
+
self._ticker_thread = threading.Thread(
|
|
308
|
+
target=self._ticker_loop, daemon=True
|
|
309
|
+
)
|
|
310
|
+
self._ticker_thread.start()
|
|
311
|
+
|
|
312
|
+
def _stop_ticker(self) -> None:
|
|
313
|
+
self._ticker_stop.set()
|
|
314
|
+
if self._ticker_thread is not None:
|
|
315
|
+
self._ticker_thread.join(timeout=1.0)
|
|
316
|
+
self._ticker_thread = None
|
|
317
|
+
|
|
318
|
+
def _ticker_loop(self) -> None:
|
|
319
|
+
while not self._ticker_stop.is_set():
|
|
320
|
+
with self._lock:
|
|
321
|
+
self._tick += 1
|
|
322
|
+
self._refresh()
|
|
323
|
+
time.sleep(TICK_MS / 1000.0)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ── Standalone print helpers (used outside Live context) ──────────────────────
|
|
327
|
+
|
|
328
|
+
_console = Console(style=f"on {BG}")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def print_dry_run(prompt_text: str) -> None:
|
|
332
|
+
"""Print the prompt in a panel for --dry-run mode."""
|
|
333
|
+
_console.print(
|
|
334
|
+
Panel(
|
|
335
|
+
Text(prompt_text, style=Style(color=FG)),
|
|
336
|
+
title=f"[{AMBER}]dry run — prompt preview[/{AMBER}]",
|
|
337
|
+
border_style=Style(color=BORDER),
|
|
338
|
+
style=Style(bgcolor=BG),
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def print_interrupted() -> None:
|
|
344
|
+
"""Print interrupted notice."""
|
|
345
|
+
_console.print(
|
|
346
|
+
Panel(
|
|
347
|
+
Text("Run interrupted by user.", style=Style(color=AMBER)),
|
|
348
|
+
title=f"[{AMBER}]- interrupted[/{AMBER}]",
|
|
349
|
+
border_style=Style(color=BORDER),
|
|
350
|
+
style=Style(bgcolor=BG),
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def print_error(message: str) -> None:
|
|
356
|
+
"""Print an error panel."""
|
|
357
|
+
_console.print(
|
|
358
|
+
Panel(
|
|
359
|
+
Text(message, style=Style(color=RED)),
|
|
360
|
+
title=f"[{RED}]x error[/{RED}]",
|
|
361
|
+
border_style=Style(color=BORDER),
|
|
362
|
+
style=Style(bgcolor=BG),
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def print_init_ok(filename: str) -> None:
|
|
368
|
+
"""Print a success line for etch init."""
|
|
369
|
+
_console.print(f"[{GREEN}]{SYM_OK}[/{GREEN}] [{FG}]{filename}[/{FG}]")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def print_init_skip(filename: str) -> None:
|
|
373
|
+
"""Print a skip line for etch init (file already exists)."""
|
|
374
|
+
_console.print(
|
|
375
|
+
f"[{AMBER}]{SYM_NEUTRAL}[/{AMBER}] [{DIM}]{filename} already exists, skipping[/{DIM}]"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ── Utilities ─────────────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _format_elapsed(seconds: float) -> str:
|
|
383
|
+
"""Format elapsed seconds as '2m 14s' or '45s'."""
|
|
384
|
+
seconds = max(0.0, seconds)
|
|
385
|
+
mins = int(seconds // 60)
|
|
386
|
+
secs = int(seconds % 60)
|
|
387
|
+
if mins > 0:
|
|
388
|
+
return f"{mins}m {secs}s"
|
|
389
|
+
return f"{secs}s"
|