craftkit 0.3.0__py3-none-any.whl

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.
craft_kit/study.py ADDED
@@ -0,0 +1,170 @@
1
+ """Study: gather evidence about the target into capture/, README.md first.
2
+
3
+ Clean room (default): only what a visitor or user sees: screenshots and visible facts of a website,
4
+ the person's pictures in reference/, and (with --allow-run) what a program prints when run.
5
+ Deep: also look inside: a site's HTML, a folder's files and manifests, a program's strings."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import shutil
12
+ import re
13
+ import urllib.request
14
+ from collections import Counter
15
+ from pathlib import Path
16
+
17
+ from . import browser, proc, tables
18
+ from .ui import UI
19
+ from .workspace import IGNORED, Project, write_atomic
20
+
21
+ RUN_INPUTS: list[list[str]] = [[], ["--help"], ["--version"], ["-h"]]
22
+ MANIFESTS = ["package.json", "Cargo.toml", "pyproject.toml", "go.mod", "requirements.txt", "setup.py", "Makefile", "CMakeLists.txt", "README.md", "README", "README.txt"]
23
+ PICTURES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
24
+
25
+
26
+ def study(p: Project, ui: UI) -> None:
27
+ t = p.state["target"]
28
+ cap = p.capture
29
+ if cap.exists():
30
+ shutil.rmtree(cap, ignore_errors=True)
31
+ cap.mkdir(parents=True, exist_ok=True)
32
+ notes = [
33
+ "# Evidence",
34
+ "",
35
+ f"Request: “{p.state['request']}”",
36
+ f"Target: {t['value'] or 'none (build from the request)'}",
37
+ f"Mode: {p.mode()}" + ("" if p.deep else " — nothing inside the original was read; don't read it either."),
38
+ "",
39
+ ]
40
+ if t["type"] == "website":
41
+ notes.append(_website(p, t["value"], ui))
42
+ elif t["type"] == "local":
43
+ path = Path(t["value"])
44
+ if p.deep:
45
+ notes.append(_inside(path, cap, ui))
46
+ else:
47
+ notes.append("## Clean room\n\nThe program, its files and its folder weren't opened. Build from the request, the pictures in reference/ and what it prints below (if anything). Ask the person about what can't be seen.\n")
48
+ if p.state.get("allow_run") and _runnable(path):
49
+ notes.append(_runs(path, cap, ui))
50
+ elif _runnable(path):
51
+ notes.append("It wasn't run (start the project with --allow-run to record what it prints).\n")
52
+ else:
53
+ notes.append("No target: build from the request alone.\n")
54
+ pics = sorted(x.name for x in p.reference.glob("*") if x.suffix.lower() in PICTURES) if p.reference.exists() else []
55
+ if pics:
56
+ notes.append("## Pictures from the person (reference/)\n\n" + "\n".join(f"- {n}" for n in pics) + "\n\nOpen each one and look closely.\n")
57
+ write_atomic(cap / "README.md", "\n".join(notes))
58
+ p.log(f"study done: {len(list(cap.rglob('*')))} files in capture/")
59
+
60
+
61
+ def _website(p: Project, url: str, ui: UI) -> str:
62
+ out = []
63
+ with ui.live(f"photographing {url}", "study") as live:
64
+ shots, facts, problems = browser.capture(url, p.capture, tables.WIDTHS, facts=True, live=live)
65
+ if facts:
66
+ write_atomic(p.capture / "capture.json", json.dumps({"version": 1, "url": url, "widths": tables.WIDTHS, "facts": facts}, indent=1, ensure_ascii=False))
67
+ out.append("## Screenshots\n")
68
+ out += [f"- {s.name}" for s in shots] or ["None could be taken."]
69
+ out.append("\nOpen every screenshot and match it width by width: layout, spacing, sizes, colours, fonts, what changes on a phone.\n")
70
+ out.append(browser.facts_markdown(facts))
71
+ for prob in problems:
72
+ ui.note(prob)
73
+ out.append(f"> {prob}\n")
74
+ if p.deep:
75
+ try:
76
+ req = urllib.request.Request(url, headers={"User-Agent": "craftkit"})
77
+ with urllib.request.urlopen(req, timeout=30) as r:
78
+ html = r.read(2_000_000).decode("utf-8", "replace")
79
+ write_atomic(p.capture / "page.html", html)
80
+ out.append("Deep: the page's HTML as served is in page.html (rebuild what you learn; never paste it into app/).\n")
81
+ except (OSError, ValueError) as e:
82
+ out.append(f"Deep: the page's HTML couldn't be fetched ({e}).\n")
83
+ ui.step("done" if shots else "fail", "study", f"{len(shots)} screenshot(s)" + (", visible facts" if facts else ""))
84
+ return "\n".join(out)
85
+
86
+
87
+ def _runnable(path: Path) -> bool:
88
+ return path.is_file() and os.access(path, os.X_OK)
89
+
90
+
91
+ def _runs(program: Path, cap: Path, ui: UI) -> str:
92
+ runs = []
93
+ with ui.live(f"running {program.name} with {len(RUN_INPUTS)} inputs", "study") as live:
94
+ for args in RUN_INPUTS:
95
+ live.event("run", f"{program.name} {' '.join(args)}".strip())
96
+ r = proc.run([str(program), *args], cwd=str(cap), timeout=10, cap=16_000)
97
+ runs.append({"args": args, "code": r.code, "output": r.output[-16_000:], "timed_out": r.timed_out, "error": r.error})
98
+ write_atomic(cap / "runs.json", json.dumps({"version": 1, "program": program.name, "runs": runs}, indent=1, ensure_ascii=False))
99
+ lines = ["## What it prints (runs.json)", ""]
100
+ for run in runs:
101
+ first = (run["output"].strip().splitlines() or [run["error"] or "(nothing)"])[0][:100]
102
+ lines.append(f"- `{program.name} {' '.join(run['args'])}`: exit {run['code']} — {first}")
103
+ lines.append("\nThe rebuild must print the same for each input and exit the same way; the check reruns these inputs.\n")
104
+ ui.step("done", "runs", f"{len(runs)} runs recorded")
105
+ return "\n".join(lines)
106
+
107
+
108
+ def _inside(path: Path, cap: Path, ui: UI) -> str:
109
+ out = ["## Deep: what's inside", ""]
110
+ if path.is_dir():
111
+ exts: Counter = Counter()
112
+ files = size = 0
113
+ top = sorted(x.name + ("/" if x.is_dir() else "") for x in path.iterdir())[:80]
114
+ for dirpath, dirnames, filenames in os.walk(path):
115
+ dirnames[:] = [d for d in dirnames if d not in IGNORED]
116
+ for n in filenames:
117
+ files += 1
118
+ exts[Path(n).suffix.lower() or "(none)"] += 1
119
+ try:
120
+ size += (Path(dirpath) / n).stat().st_size
121
+ except OSError:
122
+ pass
123
+ if files > 50_000:
124
+ break
125
+ out.append(f"{files} files, {size / 1e6:.1f} MB. Top level: " + ", ".join(top))
126
+ out.append("\nBy type: " + ", ".join(f"{e} {c}" for e, c in exts.most_common(15)))
127
+ man = cap / "manifests"
128
+ copied = []
129
+ for name in MANIFESTS:
130
+ src = path / name
131
+ if src.is_file() and src.stat().st_size < 200_000:
132
+ man.mkdir(exist_ok=True)
133
+ shutil.copyfile(src, man / name)
134
+ copied.append(name)
135
+ if copied:
136
+ out.append("\nCopied to capture/manifests/: " + ", ".join(copied) + " (read them first).")
137
+ stacks = [tc["file"] for tc in tables.TOOLCHAINS if (path / tc["file"]).exists()]
138
+ if stacks:
139
+ out.append(f"Built with: {', '.join(stacks)}.")
140
+ out.append(f"\nThe original folder is {path}: read what you need there, never change it.\n")
141
+ elif path.is_file():
142
+ st = path.stat()
143
+ out.append(f"{path.name}: {st.st_size / 1e6:.2f} MB.")
144
+ if proc.which("file"):
145
+ r = proc.run(["file", "-b", str(path)], timeout=20)
146
+ if r.ok:
147
+ out.append(f"Type: {r.output.strip()}")
148
+ found = _strings(path)
149
+ write_atomic(cap / "strings.txt", "\n".join(found))
150
+ out.append(f"\n{len(found)} readable strings saved to strings.txt (options, messages, names: clues to its features).\n")
151
+ ui.step("done", "inside", "inventory written")
152
+ return "\n".join(out)
153
+
154
+
155
+ def _strings(path: Path, minimum: int = 6, limit: int = 3000, scan: int = 32_000_000) -> list[str]:
156
+ try:
157
+ with open(path, "rb") as f:
158
+ data = f.read(scan)
159
+ except OSError:
160
+ return []
161
+ found: list[str] = []
162
+ seen: set = set()
163
+ for m in re.finditer(rb"[\x20-\x7e\t]{%d,}" % minimum, data):
164
+ s = m.group().decode("ascii").strip()
165
+ if s and s not in seen:
166
+ seen.add(s)
167
+ found.append(s[:200])
168
+ if len(found) >= limit:
169
+ break
170
+ return found
craft_kit/tables.py ADDED
@@ -0,0 +1,154 @@
1
+ """Catalogues as data: kinds of thing made, stacks, toolchains and coding agents.
2
+ Override or extend agents with ~/.config/craft/agents.json (same shape as AGENTS)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ WIDTHS = [1440, 768, 390] # desktop, tablet, phone: Study and Check photograph the same widths
12
+ MAX_ROUNDS = 6 # refinement rounds per request (kept across runs; a new request resets it)
13
+ GOOD_ENOUGH = 90.0 # stop refining once every scored check is at least this alike (percent)
14
+ UNCHANGED_LIMIT = 2 # stop when the builder changes nothing this many turns in a row
15
+ PLAYWRIGHT = "playwright@1.48.2"
16
+
17
+ # A kind: how it's recognised (whole words, English and French), what the goal is, which checks judge it.
18
+ KINDS: dict[str, dict] = {
19
+ "website": {
20
+ "words": ["website", "site", "webpage", "landing", "homepage", "page", "web"],
21
+ "goal": "a website that looks and behaves like the original at every screen width",
22
+ "checks": ["build", "web", "tests"],
23
+ },
24
+ "game": {
25
+ "words": ["game", "jeu", "gameplay", "arcade", "platformer", "puzzle"],
26
+ "goal": "a playable game with the same rules, controls, numbers and feel",
27
+ "checks": ["build", "selftest", "screenshot"],
28
+ },
29
+ "cli": {
30
+ "words": ["cli", "command", "commande", "console", "terminal", "shell", "script"],
31
+ "goal": "a command-line program that prints the same output and exits with the same codes",
32
+ "checks": ["build", "runs", "tests"],
33
+ },
34
+ "app": {
35
+ "words": ["app", "application", "desktop", "gui", "window", "fenêtre", "mobile"],
36
+ "goal": "an application with the same core features and screens",
37
+ "checks": ["build", "screenshot", "tests"],
38
+ },
39
+ "tool": {
40
+ "words": ["library", "librairie", "bibliothèque", "api", "server", "serveur", "service", "sdk", "package", "plugin", "outil", "tool"],
41
+ "goal": "software that does what the original does, verified by its own tests",
42
+ "checks": ["build", "tests"],
43
+ },
44
+ }
45
+ KIND_ORDER = ["game", "website", "cli", "app", "tool"]
46
+
47
+ # A stack: phrases that ask for it (matched on " lower-case words "), and what the builder is told.
48
+ STACKS: list[dict] = [
49
+ {"name": "Next.js (static export)", "asked_by": [" next js ", " nextjs "],
50
+ "steps": "Next.js App Router + TypeScript with output: 'export', trailingSlash: true and unoptimized images; build with `npm install && npm run build`; the site is app/out."},
51
+ {"name": "React + TypeScript (Vite)", "asked_by": [" react ", " vite "],
52
+ "steps": "Vite + React + TypeScript with base './'; build with `npm install && npm run build`; the site is app/dist."},
53
+ {"name": "Plain HTML and CSS", "asked_by": [" html ", " plain html ", " no framework ", " sans framework ", " static "],
54
+ "steps": "plain HTML and CSS (JavaScript only where needed), no build step; index.html at the top of app/, one file per page."},
55
+ {"name": "Electron", "asked_by": [" electron "],
56
+ "steps": "Electron + TypeScript or JavaScript, contextIsolation on, nodeIntegration off, IPC through a preload script; a `build` script that exits without opening a window."},
57
+ {"name": "Python", "asked_by": [" python ", " py "],
58
+ "steps": "Python 3.9+ with a pyproject.toml, standard library first; tests with pytest under app/tests."},
59
+ {"name": "Rust", "asked_by": [" rust ", " cargo "],
60
+ "steps": "a cargo project at app/ itself (app/Cargo.toml, app/src/); `cargo build` must succeed and `cargo test` must pass."},
61
+ {"name": "Go", "asked_by": [" go ", " golang "],
62
+ "steps": "a Go module at app/ itself; `go build ./...` must succeed and `go test ./...` must pass."},
63
+ {"name": "Node.js + TypeScript", "asked_by": [" node ", " nodejs ", " typescript ", " javascript ", " js ", " ts "],
64
+ "steps": "Node.js 18+ with TypeScript, a package.json `build` script and tests runnable with `npm test`."},
65
+ ]
66
+ BUILDERS_CHOICE = {
67
+ "name": "Builder's choice",
68
+ "steps": "whatever their request names; otherwise choose the stack that best fits the request and what the original visibly uses, and prefer the simplest one that does the job. A website must build to static files (index.html at the top of a folder) that work when served as they are.",
69
+ }
70
+
71
+ # How a project builds and tests itself, found by the file that names the tool (first match wins).
72
+ PY = os.path.basename(sys.executable) if sys.executable else "python3"
73
+ TOOLCHAINS: list[dict] = [
74
+ {"file": "package.json", "install": ["npm", "install"], "build": ["npm", "run", "build", "--if-present"], "test": ["npm", "test", "--if-present"]},
75
+ {"file": "Cargo.toml", "build": ["cargo", "build"], "test": ["cargo", "test"]},
76
+ {"file": "go.mod", "build": ["go", "build", "./..."], "test": ["go", "test", "./..."]},
77
+ {"file": "pyproject.toml", "build": [PY, "-m", "compileall", "-q", "."], "test": [PY, "-m", "pytest", "-q"]},
78
+ {"file": "requirements.txt", "build": [PY, "-m", "compileall", "-q", "."], "test": [PY, "-m", "pytest", "-q"]},
79
+ {"file": "CMakeLists.txt", "build": ["cmake", "-S", ".", "-B", "build"], "test": ["ctest", "--test-dir", "build"]},
80
+ {"file": "Makefile", "build": ["make"], "test": ["make", "test"]},
81
+ ]
82
+ NO_TESTS = ["no tests ran", "no tests found", "0 tests", "running 0 tests", "no test files", "missing script: \"test\"", "no tests to run", "collected 0 items"]
83
+ SITE_DIRS = ["dist", "out", "build", "public", "."]
84
+
85
+ # Coding agents, driven headless. The brief goes in on stdin (never argv: it can be long).
86
+ _CLAUDE_TOOLS = ",".join(
87
+ ["Read", "Write", "Edit", "Glob", "Grep", "TodoWrite"]
88
+ + [f"Bash({p}:*)" for p in ["npm", "npx", "node", "pnpm", "yarn", "cargo", "rustc", "go", "python", "python3", "pip", "pytest", "make", "cmake", "craft", "ls", "mkdir", "cat"]]
89
+ )
90
+ AGENTS: dict[str, dict] = {
91
+ "claude": {
92
+ "argv": ["claude", "-p", "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits", "--allowedTools", _CLAUDE_TOOLS],
93
+ "stream": "claude-json",
94
+ "resume": ["--resume", "{session}"],
95
+ "lost": "No conversation found",
96
+ },
97
+ "codex": {"argv": ["codex", "exec", "--full-auto", "-"], "stream": "text"},
98
+ "gemini": {"argv": ["gemini", "--yolo"], "stream": "text"},
99
+ }
100
+ AGENT_ORDER = ["claude", "codex", "gemini"]
101
+
102
+
103
+ def config_dir() -> Path:
104
+ if os.environ.get("CRAFT_CONFIG_DIR"):
105
+ return Path(os.environ["CRAFT_CONFIG_DIR"])
106
+ if os.environ.get("XDG_CONFIG_HOME"):
107
+ return Path(os.environ["XDG_CONFIG_HOME"]) / "craft"
108
+ return Path.home() / ".config" / "craft"
109
+
110
+
111
+ def agents() -> dict[str, dict]:
112
+ table = {k: dict(v) for k, v in AGENTS.items()}
113
+ path = config_dir() / "agents.json"
114
+ try:
115
+ extra = json.loads(path.read_text("utf-8"))
116
+ except (OSError, ValueError):
117
+ return table
118
+ if isinstance(extra, dict):
119
+ for name, spec in extra.items():
120
+ if isinstance(spec, dict) and isinstance(spec.get("argv"), list) and all(isinstance(a, str) for a in spec["argv"]):
121
+ table[name] = {**table.get(name, {}), **spec}
122
+ if name not in AGENT_ORDER:
123
+ AGENT_ORDER.append(name)
124
+ return table
125
+
126
+
127
+ def words(text: str) -> str:
128
+ """Lower-case words padded with spaces, punctuation as spaces: "Next.js," -> " next js "."""
129
+ cleaned = "".join(ch if ch.isalnum() else " " for ch in text.lower())
130
+ return f" {' '.join(cleaned.split())} "
131
+
132
+
133
+ def detect_kind(request: str, target_type: str) -> str:
134
+ w = words(request)
135
+ for kind in KIND_ORDER:
136
+ if any(f" {word} " in w for word in KINDS[kind]["words"]):
137
+ # "site" alone in a request about a game or CLI shouldn't win; KIND_ORDER puts game first.
138
+ return kind
139
+ return "website" if target_type == "website" else "tool"
140
+
141
+
142
+ def detect_stack(request: str) -> dict:
143
+ w = words(request)
144
+ for s in STACKS:
145
+ if any(p in w for p in s["asked_by"]):
146
+ return s
147
+ return BUILDERS_CHOICE
148
+
149
+
150
+ def stack_by_name(name: str) -> dict:
151
+ for s in STACKS:
152
+ if s["name"].lower() == name.lower() or name.lower() in words(s["name"]):
153
+ return s
154
+ return BUILDERS_CHOICE if name.lower() in ("", "builder's choice", "auto", "choice") else {"name": name, "steps": f"{name}, as the request asks."}
craft_kit/ui.py ADDED
@@ -0,0 +1,284 @@
1
+ """A minimal terminal interface: one line per step, a live feed of what's happening, one live line
2
+ while something runs. Each stage has its colour (study sky, build blue, refine violet, check amber,
3
+ ready green); NO_COLOR, a pipe or TERM=dumb give plain text, CRAFT_FEED=0 keeps only the live line."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import shutil
9
+ import sys
10
+ import threading
11
+ import time
12
+ from typing import Optional
13
+
14
+ from . import banner
15
+
16
+ ICONS = {"done": ("✓", "32"), "run": ("●", "36"), "todo": ("○", "2"), "fail": ("✗", "31"), "wait": ("?", "33"), "skip": ("–", "2")}
17
+ FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
18
+
19
+ RGB = tuple # (r, g, b)
20
+
21
+ # The stages of the loop, each with its colour.
22
+ STAGES: dict = {
23
+ "study": (70, 210, 255),
24
+ "build": (60, 125, 255),
25
+ "refine": (165, 95, 255),
26
+ "check": (255, 185, 60),
27
+ "ready": (80, 220, 130),
28
+ }
29
+ # Step lines whose name isn't a stage say which stage they belong to.
30
+ STEP_STAGE = {"runs": "study", "inside": "study", "new": "ready", "answer": "build", "open": "ready", "skill": "ready"}
31
+
32
+ # What the builder does, by kind: icon, verb, colour. None = dim.
33
+ KINDS: dict = {
34
+ "read": ("◇", "read", (120, 200, 255)),
35
+ "edit": ("✎", "edit", (90, 225, 140)),
36
+ "write": ("✚", "write", (60, 235, 170)),
37
+ "run": ("❯", "run", (255, 200, 80)),
38
+ "search": ("⌕", "search", (205, 140, 255)),
39
+ "web": ("◎", "web", (80, 165, 255)),
40
+ "plan": ("☰", "plan", (245, 115, 200)),
41
+ "agent": ("◆", "agent", (150, 160, 255)),
42
+ "tool": ("▸", "tool", (180, 180, 200)),
43
+ "fail": ("✗", "failed", (240, 85, 95)),
44
+ "say": ("“", "", None),
45
+ "out": ("·", "", None),
46
+ }
47
+
48
+
49
+ def _tty(stream) -> bool:
50
+ try:
51
+ return stream.isatty() and not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb"
52
+ except (AttributeError, ValueError):
53
+ return False
54
+
55
+
56
+ def elapsed(seconds: float) -> str:
57
+ s = int(seconds)
58
+ return f"{s // 60}m{s % 60:02d}s" if s >= 60 else f"{s}s"
59
+
60
+
61
+ def clock(seconds: float) -> str:
62
+ s = int(seconds)
63
+ return f"{s // 60}:{s % 60:02d}"
64
+
65
+
66
+ def fit(text: str, room: int) -> str:
67
+ text = " ".join(text.split())
68
+ return text if len(text) <= room else text[: max(0, room - 1)] + "…"
69
+
70
+
71
+ def stage_of(name: str) -> Optional[str]:
72
+ return name if name in STAGES else STEP_STAGE.get(name)
73
+
74
+
75
+ def _mix(a: RGB, b: RGB, k: float) -> RGB:
76
+ return tuple(round(x + (y - x) * k) for x, y in zip(a, b))
77
+
78
+
79
+ class UI:
80
+ def __init__(self, stream=None):
81
+ self.out = stream or sys.stderr
82
+ self.fancy = _tty(self.out)
83
+ self.feed = os.environ.get("CRAFT_FEED", "1").lower() not in ("0", "off", "no", "false")
84
+ self._lock = threading.Lock()
85
+ self._live: Live | None = None
86
+
87
+ # colours ---------------------------------------------------------------
88
+ def c(self, code: str, text: str) -> str:
89
+ return f"\033[{code}m{text}\033[0m" if self.fancy else text
90
+
91
+ def rgb(self, colour: Optional[RGB], text: str, bold: bool = False, italic: bool = False, dim: bool = False) -> str:
92
+ if not self.fancy or not text:
93
+ return text
94
+ style = ("\033[1m" if bold else "") + ("\033[3m" if italic else "") + ("\033[2m" if dim else "")
95
+ return f"{style}{banner.fg(colour) if colour else ''}{text}\033[0m"
96
+
97
+ def dim(self, t: str) -> str:
98
+ return self.c("2", t)
99
+
100
+ def bold(self, t: str) -> str:
101
+ return self.c("1", t)
102
+
103
+ def red(self, t: str) -> str:
104
+ return self.c("31", t)
105
+
106
+ def green(self, t: str) -> str:
107
+ return self.c("32", t)
108
+
109
+ def yellow(self, t: str) -> str:
110
+ return self.c("33", t)
111
+
112
+ def width(self) -> int:
113
+ return max(40, min(shutil.get_terminal_size((80, 20)).columns, 100))
114
+
115
+ # output ------------------------------------------------------------------
116
+ def say(self, text: str = "") -> None:
117
+ with self._lock:
118
+ if self._live is not None:
119
+ self._live.clear()
120
+ print(text, file=self.out, flush=True)
121
+
122
+ def header(self, name: str, right: str) -> None:
123
+ w = self.width()
124
+ left = f"craft · {name}"
125
+ gap = max(1, w - len(left) - len(right) - 2)
126
+ self.say(f" {self.bold(left)}{' ' * gap}{self.dim(right)}")
127
+
128
+ def stage(self, name: str, detail: str = "") -> None:
129
+ """A coloured rule that opens a stage: ` ▍build claude · turn 3 ─────────`."""
130
+ colour = STAGES.get(name)
131
+ detail = fit(detail, self.width() - len(name) - 12)
132
+ rule = "─" * max(3, self.width() - len(name) - len(detail) - 8)
133
+ if not self.fancy:
134
+ self.say(f"\n -- {name} {detail}".rstrip())
135
+ return
136
+ self.say("")
137
+ self.say(f" {self.rgb(colour, '▍' + name, bold=True)} {detail}{' ' if detail else ''}{self.rgb(colour, rule, dim=True)}")
138
+
139
+ def step(self, state: str, name: str, detail: str = "", stage: Optional[str] = None) -> None:
140
+ icon, code = ICONS.get(state, ICONS["todo"])
141
+ quiet = state in ("todo", "skip")
142
+ detail = self.dim(detail) if quiet else detail
143
+ colour = STAGES.get(stage or stage_of(name) or "")
144
+ label = f"{name:<7}"
145
+ label = self.dim(label) if quiet else (self.rgb(colour, label, bold=True) if colour else label)
146
+ self.say(f" {self.c(code, icon)} {label} {detail}".rstrip())
147
+
148
+ def note(self, text: str) -> None:
149
+ for line in text.splitlines() or [""]:
150
+ self.say(f" {self.dim('│')} {line}")
151
+
152
+ def error(self, text: str) -> None:
153
+ self.say(f" {self.red('✗')} {text}")
154
+
155
+ def live(self, label: str, stage: Optional[str] = None) -> "Live":
156
+ return Live(self, label, stage)
157
+
158
+ # input -------------------------------------------------------------------
159
+ def interactive(self) -> bool:
160
+ return sys.stdin.isatty() and self.fancy
161
+
162
+ def ask(self, prompt: str) -> str:
163
+ with self._lock:
164
+ if self._live is not None:
165
+ self._live.clear()
166
+ try:
167
+ return input(f" {self.c('33', '?')} {prompt} ").strip()
168
+ except EOFError:
169
+ return ""
170
+
171
+ def choose(self, question: str, choices: list[str]) -> str:
172
+ self.say(f" {self.c('33', '?')} {question}")
173
+ for i, ch in enumerate(choices, 1):
174
+ self.say(f" {self.bold(str(i))} {ch}")
175
+ raw = self.ask("number, or your own answer:")
176
+ if raw.isdigit() and 1 <= int(raw) <= len(choices):
177
+ return choices[int(raw) - 1]
178
+ return raw
179
+
180
+
181
+ class Live:
182
+ """While something runs: a feed of what it does (one line per action, kept on screen) above a
183
+ single line that redraws in place (spinner, shimmering label, elapsed time, latest activity)."""
184
+
185
+ def __init__(self, ui: UI, label: str, stage: Optional[str] = None):
186
+ self.ui = ui
187
+ self.label = label
188
+ self.colour: RGB = STAGES.get(stage or "", (150, 160, 255))
189
+ self.detail = ""
190
+ self.start = time.monotonic()
191
+ self.counts: dict = {}
192
+ self._last_event = ""
193
+ self._stop = threading.Event()
194
+ self._thread: threading.Thread | None = None
195
+
196
+ def __enter__(self) -> "Live":
197
+ if self.ui.fancy:
198
+ self.ui._live = self
199
+ self._thread = threading.Thread(target=self._spin, daemon=True)
200
+ self._thread.start()
201
+ else:
202
+ print(f" … {self.label}", file=self.ui.out, flush=True)
203
+ return self
204
+
205
+ def __exit__(self, *exc) -> None:
206
+ self._stop.set()
207
+ if self._thread is not None:
208
+ self._thread.join(timeout=1)
209
+ with self.ui._lock:
210
+ self.clear()
211
+ self.ui._live = None
212
+
213
+ def update(self, detail: str) -> None:
214
+ self.detail = " ".join(detail.split())
215
+
216
+ def event(self, kind: str, text: str, count: bool = True) -> None:
217
+ """One action, kept in the feed: ` 1:42 ✎ edit src/main.rs`."""
218
+ text = " ".join(text.split())
219
+ if not text:
220
+ return
221
+ self.update(text)
222
+ key = f"{kind}\0{text}"
223
+ if key == self._last_event: # the same line twice in a row says nothing new
224
+ return
225
+ self._last_event = key
226
+ if count and kind not in ("say", "out"):
227
+ self.counts[kind] = self.counts.get(kind, 0) + 1
228
+ if not self.ui.feed:
229
+ return
230
+ icon, verb, colour = KINDS.get(kind, KINDS["tool"])
231
+ stamp = clock(time.monotonic() - self.start)
232
+ head = f"{icon} {verb:<7}" if verb else f"{icon} "
233
+ room = self.ui.width() - len(stamp) - len(head) - 6
234
+ body = fit(text, room)
235
+ u = self.ui
236
+ if kind in ("say", "out"):
237
+ line = f" {u.dim(stamp)} {u.dim(head)}{u.rgb(None, body, italic=kind == 'say', dim=True)}"
238
+ else:
239
+ line = f" {u.dim(stamp)} {u.rgb(colour, head, bold=True)}{u.rgb(colour, body) if kind == 'fail' else body}"
240
+ u.say(line)
241
+
242
+ def tally(self) -> str:
243
+ """`2m14s · 18 actions: 6 edit, 5 read, 4 run`."""
244
+ counts = {k: n for k, n in self.counts.items() if k != "fail"}
245
+ total = sum(counts.values())
246
+ out = elapsed(time.monotonic() - self.start)
247
+ if total:
248
+ parts = sorted(counts.items(), key=lambda kv: -kv[1])
249
+ out += f" · {total} action{'s' if total > 1 else ''}: " + ", ".join(f"{n} {KINDS.get(k, KINDS['tool'])[1] or k}" for k, n in parts)
250
+ if self.counts.get("fail"):
251
+ out += f" · {self.counts['fail']} failed"
252
+ return out
253
+
254
+ def clear(self) -> None:
255
+ if self.ui.fancy:
256
+ self.ui.out.write("\r\033[2K")
257
+ self.ui.out.flush()
258
+
259
+ def _shimmer(self, text: str, i: int) -> str:
260
+ """The label in its stage colour, a bright band sweeping across it."""
261
+ base = _mix(self.colour, (40, 40, 60), 0.25)
262
+ n = len(text) + 8
263
+ pos = (i * 0.6) % n - 4
264
+ cells = []
265
+ for x, ch in enumerate(text):
266
+ k = max(0.0, 1.0 - abs(x - pos) / 4.0)
267
+ cells.append(f"{banner.fg(_mix(base, (255, 255, 255), 0.7 * k))}{ch}")
268
+ return "\033[1m" + "".join(cells) + "\033[0m"
269
+
270
+ def _spin(self) -> None:
271
+ i = 0
272
+ while not self._stop.wait(0.1):
273
+ i += 1
274
+ u = self.ui
275
+ frame = FRAMES[i % len(FRAMES)]
276
+ time_s = elapsed(time.monotonic() - self.start)
277
+ done = sum(n for k, n in self.counts.items() if k != "fail")
278
+ meta = f" · {time_s}" + (f" · {done}" if done else "")
279
+ room = u.width() - 1 - 5 - len(self.label) - len(meta)
280
+ detail = f" · {fit(self.detail, room - 3)}" if self.detail and room > 8 else ""
281
+ text = f" {u.rgb(self.colour, frame, bold=True)} {self._shimmer(self.label, i)}{u.dim(meta)}{u.dim(detail)}"
282
+ with u._lock:
283
+ u.out.write("\r\033[2K" + text)
284
+ u.out.flush()