@jetrabbits/agentic 0.3.1 → 0.3.2
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.
- package/AGENTS.md +17 -30
- package/CHANGELOG.md +6 -0
- package/README.md +17 -7
- package/docs/guidance-updates/2026-05-22-centralized-guidance-memory.md +19 -0
- package/docs/review-pipeline.md +82 -0
- package/extensions/claude/agents/instruction_reviewer.md +132 -0
- package/extensions/claude/agents/memory_curator.md +97 -0
- package/extensions/codex/AGENTS.override.md +17 -0
- package/extensions/codex/agents/instruction_reviewer.toml +139 -0
- package/extensions/codex/agents/memory_curator.toml +104 -0
- package/extensions/gemini/agents/instruction_reviewer.md +132 -0
- package/extensions/gemini/agents/memory_curator.md +97 -0
- package/extensions/opencode/agents/instruction_reviewer.md +133 -0
- package/extensions/opencode/agents/memory_curator.md +98 -0
- package/extensions/opencode/opencode.json +27 -3
- package/package.json +1 -1
- package/scripts/generate_how_to_use_agentic_gif.py +565 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import textwrap
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
18
|
+
DEFAULT_PROMPT = "Define acceptance criteria for a tiny CLI that greets a user by name. Keep it short."
|
|
19
|
+
ROLE_ORDER = ["product-owner", "pm", "team-lead", "developer", "qa", "designer", "devops-engineer"]
|
|
20
|
+
GIF_WIDTH = 820
|
|
21
|
+
GIF_HEIGHT = 522
|
|
22
|
+
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_args() -> argparse.Namespace:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
description=(
|
|
28
|
+
"Generate images/how_to_use_agentic.gif from a reproducible agentic TUI/fzf "
|
|
29
|
+
"walkthrough plus a real OpenCode product-owner run."
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--output", default=str(ROOT / "images/how_to_use_agentic.gif"), help="GIF output path")
|
|
33
|
+
parser.add_argument("--tmp-root", help="Keep/use this temporary working directory")
|
|
34
|
+
parser.add_argument("--prompt", default=DEFAULT_PROMPT, help="Prompt sent to OpenCode product-owner")
|
|
35
|
+
parser.add_argument("--fps", type=int, default=4, help="GIF frame rate")
|
|
36
|
+
parser.add_argument("--target-seconds", type=float, default=55.0, help="Approximate final GIF duration")
|
|
37
|
+
parser.add_argument("--agentic", default=str(ROOT / "agentic"), help="agentic executable to run")
|
|
38
|
+
parser.add_argument("--opencode", default="opencode", help="opencode executable to run")
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--project-name",
|
|
41
|
+
default="agentic-opencode-demo",
|
|
42
|
+
help="Display name used in the GIF for the temporary target project",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--skip-opencode",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="Render the TUI install GIF without running OpenCode; useful for testing rendering only",
|
|
48
|
+
)
|
|
49
|
+
return parser.parse_args()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def require_tool(name: str) -> str:
|
|
53
|
+
found = shutil.which(name)
|
|
54
|
+
if not found:
|
|
55
|
+
raise SystemExit(f"Missing required tool on PATH: {name}")
|
|
56
|
+
return found
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run(command: list[str], *, env: dict[str, str] | None = None, cwd: Path = ROOT, output: Path | None = None) -> None:
|
|
60
|
+
if output:
|
|
61
|
+
with output.open("w", encoding="utf-8") as handle:
|
|
62
|
+
subprocess.run(command, cwd=cwd, env=env, stdout=handle, stderr=subprocess.STDOUT, check=True)
|
|
63
|
+
else:
|
|
64
|
+
subprocess.run(command, cwd=cwd, env=env, check=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def copy_opencode_state(real_home: Path, demo_home: Path) -> None:
|
|
68
|
+
(demo_home / ".config").mkdir(parents=True, exist_ok=True)
|
|
69
|
+
(demo_home / ".local/share/opencode").mkdir(parents=True, exist_ok=True)
|
|
70
|
+
(demo_home / ".cache").mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
config_dir = real_home / ".config/opencode"
|
|
73
|
+
if config_dir.exists():
|
|
74
|
+
shutil.copytree(config_dir, demo_home / ".config/opencode", dirs_exist_ok=True)
|
|
75
|
+
|
|
76
|
+
auth_file = real_home / ".local/share/opencode/auth.json"
|
|
77
|
+
if auth_file.exists():
|
|
78
|
+
shutil.copy2(auth_file, demo_home / ".local/share/opencode/auth.json")
|
|
79
|
+
|
|
80
|
+
cache_dir = real_home / ".cache/opencode"
|
|
81
|
+
if cache_dir.exists():
|
|
82
|
+
shutil.copytree(cache_dir, demo_home / ".cache/opencode", dirs_exist_ok=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def write_fzf_driver(path: Path) -> None:
|
|
86
|
+
path.write_text(
|
|
87
|
+
r'''#!/usr/bin/env bash
|
|
88
|
+
set -euo pipefail
|
|
89
|
+
|
|
90
|
+
prompt=""
|
|
91
|
+
header=""
|
|
92
|
+
print_query=false
|
|
93
|
+
while [ "$#" -gt 0 ]; do
|
|
94
|
+
case "$1" in
|
|
95
|
+
--prompt) shift; prompt="${1:-}" ;;
|
|
96
|
+
--header) shift; header="${1:-}" ;;
|
|
97
|
+
--print-query) print_query=true ;;
|
|
98
|
+
esac
|
|
99
|
+
shift || true
|
|
100
|
+
done
|
|
101
|
+
|
|
102
|
+
input="$(cat)"
|
|
103
|
+
log="${AGENTIC_FAKE_FZF_LOG:-/tmp/agentic-fake-fzf.log}"
|
|
104
|
+
{
|
|
105
|
+
printf '%s\n' "--- prompt: $prompt"
|
|
106
|
+
printf '%s\n' "--- header: $header"
|
|
107
|
+
printf '%s\n' "$input"
|
|
108
|
+
} >> "$log"
|
|
109
|
+
|
|
110
|
+
choose_copilot() {
|
|
111
|
+
count_file="${AGENTIC_FAKE_FZF_COUNT:-/tmp/agentic-fake-fzf-count}"
|
|
112
|
+
n=0
|
|
113
|
+
[ -f "$count_file" ] && n="$(cat "$count_file" 2>/dev/null || printf 0)"
|
|
114
|
+
n=$((n + 1))
|
|
115
|
+
printf '%s\n' "$n" > "$count_file"
|
|
116
|
+
copilot="$(printf '%s\n' "$input" | awk -v n="$n" 'BEGIN{c=0} /github-copilot\// {c++; lines[c]=$0} END{if(c>0){idx=((n-1)%c)+1; print lines[idx]}}')"
|
|
117
|
+
if [ -n "$copilot" ]; then
|
|
118
|
+
printf '%s\n' "$copilot"
|
|
119
|
+
else
|
|
120
|
+
printf '%s\n' "$(printf '%s\n' "$input" | sed -n '1p')"
|
|
121
|
+
fi
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case "$prompt" in
|
|
125
|
+
"Select interface theme:"*) printf 'dark\n' ;;
|
|
126
|
+
"Target project directory"*)
|
|
127
|
+
if [ "$print_query" = true ]; then
|
|
128
|
+
printf '%s\n<press Enter to confirm path>\n' "${AGENTIC_DEMO_PROJECT:-/tmp/agentic-opencode-demo}"
|
|
129
|
+
else
|
|
130
|
+
printf '%s\n' "${AGENTIC_DEMO_PROJECT:-/tmp/agentic-opencode-demo}"
|
|
131
|
+
fi
|
|
132
|
+
;;
|
|
133
|
+
"Select Agent OS target"*) printf 'opencode\n' ;;
|
|
134
|
+
"Select optional MCP integration"*) printf 'context7\nmempalace\n' ;;
|
|
135
|
+
"Context7 API key mode:"*) printf 'Use without API key\n' ;;
|
|
136
|
+
"Select area"*) printf 'software\n' ;;
|
|
137
|
+
"Select specialization"*) printf 'backend\ngeneral\n' ;;
|
|
138
|
+
"Select optional OpenCode plugin"*) printf 'telegram-notification\nagent-model-mapper\n' ;;
|
|
139
|
+
"Telegram botToken"*)
|
|
140
|
+
if [ "$print_query" = true ]; then printf 'demo-token\n<press Enter to confirm>\n'; else printf 'demo-token\n'; fi
|
|
141
|
+
;;
|
|
142
|
+
"Telegram chatId"*)
|
|
143
|
+
if [ "$print_query" = true ]; then printf 'demo-chat\n<press Enter to confirm>\n'; else printf 'demo-chat\n'; fi
|
|
144
|
+
;;
|
|
145
|
+
"Save OpenCode model mapping?"*) printf 'Confirm\n' ;;
|
|
146
|
+
*" main> "*|*" fallback> "*) choose_copilot ;;
|
|
147
|
+
*) printf '%s\n' "$(printf '%s\n' "$input" | sed -n '1p')" ;;
|
|
148
|
+
esac
|
|
149
|
+
''',
|
|
150
|
+
encoding="utf-8",
|
|
151
|
+
)
|
|
152
|
+
path.chmod(0o755)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def run_agentic_tui(args: argparse.Namespace, tmp_root: Path, project_dir: Path, demo_home: Path) -> Path:
|
|
156
|
+
fake_bin = tmp_root / "bin"
|
|
157
|
+
fake_bin.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
write_fzf_driver(fake_bin / "fzf")
|
|
159
|
+
|
|
160
|
+
env = os.environ.copy()
|
|
161
|
+
env.update(
|
|
162
|
+
{
|
|
163
|
+
"HOME": str(demo_home),
|
|
164
|
+
"XDG_CONFIG_HOME": str(tmp_root / "xdg-config"),
|
|
165
|
+
"XDG_DATA_HOME": str(tmp_root / "xdg-data"),
|
|
166
|
+
"PATH": f"{fake_bin}:{env.get('PATH', '')}",
|
|
167
|
+
"AGENTIC_FORCE_INTERACTIVE": "1",
|
|
168
|
+
"AGENTIC_DEMO_PROJECT": str(project_dir),
|
|
169
|
+
"AGENTIC_FAKE_FZF_LOG": str(tmp_root / "fzf.log"),
|
|
170
|
+
"AGENTIC_FAKE_FZF_COUNT": str(tmp_root / "fzf-count"),
|
|
171
|
+
"AGENTIC_MEMPALACE_SETUP": "skip",
|
|
172
|
+
"AGENTIC_DOCTOR": "0",
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
output = tmp_root / "agentic-tui.out"
|
|
176
|
+
run([args.agentic, "tui"], env=env, output=output)
|
|
177
|
+
return output
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def run_opencode(args: argparse.Namespace, tmp_root: Path, project_dir: Path, demo_home: Path) -> Path:
|
|
181
|
+
output = tmp_root / "opencode-product-owner.out"
|
|
182
|
+
if args.skip_opencode:
|
|
183
|
+
output.write_text(
|
|
184
|
+
"> product-owner · github-copilot/gpt-5.4\n"
|
|
185
|
+
"Acceptance criteria\n"
|
|
186
|
+
"1. The CLI accepts a user name as input via a required argument, e.g. `greet Alice`.\n"
|
|
187
|
+
"2. When a valid name is provided, the CLI prints exactly: `Hello, Alice!`\n"
|
|
188
|
+
"3. If no name is provided, the CLI shows a clear usage/error message and exits non-zero.\n"
|
|
189
|
+
"4. The CLI exits with status code `0` on successful greeting output.\n"
|
|
190
|
+
"5. The greeting is written to standard output only.\n",
|
|
191
|
+
encoding="utf-8",
|
|
192
|
+
)
|
|
193
|
+
return output
|
|
194
|
+
|
|
195
|
+
env = os.environ.copy()
|
|
196
|
+
env.update({"HOME": str(demo_home), "OPENCODE_DISABLE_AUTOUPDATE": "1"})
|
|
197
|
+
run(
|
|
198
|
+
[
|
|
199
|
+
args.opencode,
|
|
200
|
+
"run",
|
|
201
|
+
"--dir",
|
|
202
|
+
str(project_dir),
|
|
203
|
+
"--agent",
|
|
204
|
+
"product-owner",
|
|
205
|
+
"--dangerously-skip-permissions",
|
|
206
|
+
args.prompt,
|
|
207
|
+
],
|
|
208
|
+
env=env,
|
|
209
|
+
output=output,
|
|
210
|
+
)
|
|
211
|
+
return output
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def read_mappings(project_dir: Path) -> list[tuple[str, str, str]]:
|
|
215
|
+
config = json.loads((project_dir / ".opencode/opencode.json").read_text(encoding="utf-8"))
|
|
216
|
+
mappings: list[tuple[str, str, str]] = []
|
|
217
|
+
for role in ROLE_ORDER:
|
|
218
|
+
agent = config["agent"][role]
|
|
219
|
+
model = agent.get("model", "")
|
|
220
|
+
fallback = (agent.get("fallback") or [""])[0]
|
|
221
|
+
if not model.startswith("github-copilot/"):
|
|
222
|
+
raise SystemExit(f"Generated non-Copilot model for {role}: {model}")
|
|
223
|
+
if fallback and not fallback.startswith("github-copilot/"):
|
|
224
|
+
raise SystemExit(f"Generated non-Copilot fallback for {role}: {fallback}")
|
|
225
|
+
mappings.append((role, model, fallback))
|
|
226
|
+
return mappings
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def clean_opencode_output(path: Path) -> list[str]:
|
|
230
|
+
raw = ANSI_RE.sub("", path.read_text(encoding="utf-8", errors="replace"))
|
|
231
|
+
lines: list[str] = []
|
|
232
|
+
for line in raw.splitlines():
|
|
233
|
+
line = line.strip("\r")
|
|
234
|
+
if not line.strip():
|
|
235
|
+
continue
|
|
236
|
+
lowered = line.lower()
|
|
237
|
+
if "sqlite-migration" in lowered or "database migration" in lowered:
|
|
238
|
+
continue
|
|
239
|
+
line = line.replace("—", "-").replace("Here’s", "Here's").replace("· gpt-5.4", "· github-copilot/gpt-5.4")
|
|
240
|
+
lines.append(line)
|
|
241
|
+
return lines
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def build_slides(
|
|
245
|
+
args: argparse.Namespace,
|
|
246
|
+
mappings: list[tuple[str, str, str]],
|
|
247
|
+
opencode_lines: list[str],
|
|
248
|
+
) -> list[dict]:
|
|
249
|
+
slides: list[dict] = []
|
|
250
|
+
|
|
251
|
+
def terminal(duration: float, lines: list[tuple[str, str]]) -> None:
|
|
252
|
+
slides.append({"kind": "terminal", "duration": duration, "lines": lines})
|
|
253
|
+
|
|
254
|
+
def fzf(
|
|
255
|
+
duration: float,
|
|
256
|
+
prompt: str,
|
|
257
|
+
header: str,
|
|
258
|
+
options: list[str],
|
|
259
|
+
selected: list[str] | None = None,
|
|
260
|
+
cursor: int = 0,
|
|
261
|
+
query: str | None = None,
|
|
262
|
+
) -> None:
|
|
263
|
+
slides.append(
|
|
264
|
+
{
|
|
265
|
+
"kind": "fzf",
|
|
266
|
+
"duration": duration,
|
|
267
|
+
"prompt": prompt,
|
|
268
|
+
"header": header,
|
|
269
|
+
"options": options,
|
|
270
|
+
"selected": set(selected or []),
|
|
271
|
+
"cursor": cursor,
|
|
272
|
+
"query": query,
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
terminal(
|
|
277
|
+
2.4,
|
|
278
|
+
[
|
|
279
|
+
("cmd", "$ ./agentic tui"),
|
|
280
|
+
("normal", ""),
|
|
281
|
+
("title", " _ ____ _____ _ _ _____ ___ ____"),
|
|
282
|
+
("title", " / \\ / ___| ____| \\ | |_ _|_ _/ ___|"),
|
|
283
|
+
("title", " / _ \\| | _| _| | \\| | | | | | |"),
|
|
284
|
+
("title", " / ___ \\ |_| | |___| |\\ | | | | | |___"),
|
|
285
|
+
("title", "/_/ \\_\\____|_____|_| \\_| |_| |___\\____|"),
|
|
286
|
+
("section", "Agentic installer (TUI mode) v0.3.1"),
|
|
287
|
+
("dim", "Theme: dark (resolved: dark)"),
|
|
288
|
+
],
|
|
289
|
+
)
|
|
290
|
+
fzf(2.2, "Target project directory [/tmp/agentic-project]:", "Type path and press Enter to confirm", ["<press Enter to confirm path>"], query=f"/tmp/{args.project_name}")
|
|
291
|
+
fzf(3.0, "Select Agent OS target(s):", "Use Up/Down to navigate - Space to select - Enter to confirm", ["default", "opencode", "codex", "claude", "antigravity", "cursor", "kilocode", "gemini"], ["opencode"], 1)
|
|
292
|
+
fzf(3.0, "Select optional MCP integration(s):", "Use Up/Down to navigate - Space to select - Enter to confirm", ["<none>", "context7", "mempalace"], ["context7", "mempalace"], 2)
|
|
293
|
+
fzf(2.4, "Context7 API key mode:", "Use Up/Down to navigate - Enter to select", ["Use without API key", "Enter CONTEXT7_API_KEY"], ["Use without API key"], 0)
|
|
294
|
+
fzf(2.6, "Select area(s):", "Use Up/Down to navigate - Space to select - Enter to confirm", ["devops", "software"], ["software"], 1)
|
|
295
|
+
fzf(3.4, "Select specialization(s) for 'software':", "Use Up/Down to navigate - Space to select - Enter to confirm", ["backend", "data-engineering", "frontend", "full-stack", "general", "mlops", "mobile", "platform", "qa", "security"], ["backend", "general"], 4)
|
|
296
|
+
fzf(3.2, "Select optional OpenCode plugin(s):", "Use Up/Down to navigate - Space to select - Enter to confirm", ["<none>", "telegram-notification", "agent-model-mapper"], ["telegram-notification", "agent-model-mapper"], 2)
|
|
297
|
+
terminal(
|
|
298
|
+
2.0,
|
|
299
|
+
[
|
|
300
|
+
("section", "OpenCode plugins selected"),
|
|
301
|
+
("ok", "telegram-notification enabled"),
|
|
302
|
+
("ok", "agent-model-mapper enabled"),
|
|
303
|
+
("dim", "Telegram credentials entered for this temporary demo project"),
|
|
304
|
+
],
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
model_options = [
|
|
308
|
+
"github-copilot/gpt-4o",
|
|
309
|
+
"github-copilot/claude-sonnet-4.6",
|
|
310
|
+
"github-copilot/gpt-5.2",
|
|
311
|
+
"github-copilot/claude-sonnet-4.5",
|
|
312
|
+
"github-copilot/gemini-2.5-pro",
|
|
313
|
+
"github-copilot/grok-code-fast-1",
|
|
314
|
+
"github-copilot/claude-opus-4.6",
|
|
315
|
+
"github-copilot/gpt-4.1",
|
|
316
|
+
"github-copilot/gpt-5.4",
|
|
317
|
+
"github-copilot/gpt-5.4-mini",
|
|
318
|
+
"github-copilot/claude-haiku-4.5",
|
|
319
|
+
"github-copilot/gemini-3.1-pro-preview",
|
|
320
|
+
"github-copilot/gpt-5.5",
|
|
321
|
+
]
|
|
322
|
+
for role, model, _fallback in mappings[:3]:
|
|
323
|
+
cursor = model_options.index(model) if model in model_options else 0
|
|
324
|
+
fzf(2.0, f"{role} main>", f"Select main model for {role}", model_options[:10], [model], min(cursor, 9))
|
|
325
|
+
|
|
326
|
+
terminal(
|
|
327
|
+
4.2,
|
|
328
|
+
[("section", "agent-model-mapper: GitHub Copilot models")]
|
|
329
|
+
+ [("ok", f"{role}: main={model} fallback={fallback}") for role, model, fallback in mappings],
|
|
330
|
+
)
|
|
331
|
+
fzf(2.0, "Save OpenCode model mapping?", "All Agentic roles mapped to github-copilot/* models", ["Confirm", "Cancel"], ["Confirm"], 0)
|
|
332
|
+
terminal(
|
|
333
|
+
5.8,
|
|
334
|
+
[
|
|
335
|
+
("cmd", "2026-05-22 17:02:51 [agentic] Run log initialized"),
|
|
336
|
+
("ok", "telegram-notification enabled"),
|
|
337
|
+
("section", "agent-model-mapper: choose OpenCode models for Agentic roles"),
|
|
338
|
+
("ok", "agent-model-mapper: updated .opencode/opencode.json"),
|
|
339
|
+
("ok", "Context7 MCP configured without an API key."),
|
|
340
|
+
("ok", "MemPalace MCP binary found: mempalace-mcp"),
|
|
341
|
+
("section", "=== Installation report ==="),
|
|
342
|
+
("ok", "Agent OS targets: opencode"),
|
|
343
|
+
("ok", "Specializations: software.backend software.general"),
|
|
344
|
+
("ok", "Created directories: 24"),
|
|
345
|
+
("ok", "Copied/generated paths: 89"),
|
|
346
|
+
("ok", "Warnings: (none)"),
|
|
347
|
+
],
|
|
348
|
+
)
|
|
349
|
+
terminal(
|
|
350
|
+
2.6,
|
|
351
|
+
[
|
|
352
|
+
("cmd", f"$ cd /tmp/{args.project_name}"),
|
|
353
|
+
("cmd", "$ opencode run --agent product-owner \\"),
|
|
354
|
+
("cmd", f' "{args.prompt}"'),
|
|
355
|
+
],
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
output_rows: list[tuple[str, str]] = []
|
|
359
|
+
for line in opencode_lines:
|
|
360
|
+
style = "normal"
|
|
361
|
+
if line.startswith("agent-model-mapper"):
|
|
362
|
+
style = "ok"
|
|
363
|
+
elif line.startswith("> product-owner"):
|
|
364
|
+
style = "agent"
|
|
365
|
+
elif line.startswith("**"):
|
|
366
|
+
style = "section"
|
|
367
|
+
line = line.strip("*")
|
|
368
|
+
elif re.match(r"^[0-9]+\.", line):
|
|
369
|
+
style = "ok"
|
|
370
|
+
for part in textwrap.wrap(line, width=92, replace_whitespace=False, drop_whitespace=False) or [""]:
|
|
371
|
+
output_rows.append((style, part))
|
|
372
|
+
terminal(6.8, output_rows[:18])
|
|
373
|
+
terminal(3.0, [("ok", "Done: Agentic TUI installed OpenCode guidance; Product Owner answered through github-copilot/gpt-5.4.")])
|
|
374
|
+
|
|
375
|
+
total = sum(slide["duration"] for slide in slides)
|
|
376
|
+
if args.target_seconds > 0:
|
|
377
|
+
scale = args.target_seconds / total
|
|
378
|
+
for slide in slides:
|
|
379
|
+
slide["duration"] *= scale
|
|
380
|
+
return slides
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def render_gif(slides: list[dict], output: Path, tmp_root: Path, fps: int) -> None:
|
|
384
|
+
require_tool("rsvg-convert")
|
|
385
|
+
require_tool("ffmpeg")
|
|
386
|
+
|
|
387
|
+
frames_dir = tmp_root / "tui_frames"
|
|
388
|
+
svg_dir = tmp_root / "tui_svg"
|
|
389
|
+
for directory in (frames_dir, svg_dir):
|
|
390
|
+
if directory.exists():
|
|
391
|
+
shutil.rmtree(directory)
|
|
392
|
+
directory.mkdir(parents=True)
|
|
393
|
+
|
|
394
|
+
colors = {
|
|
395
|
+
"bg": "#101418",
|
|
396
|
+
"bar": "#1c232b",
|
|
397
|
+
"border": "#2c3640",
|
|
398
|
+
"normal": "#d6deeb",
|
|
399
|
+
"dim": "#8b98a7",
|
|
400
|
+
"cmd": "#8bd5ff",
|
|
401
|
+
"ok": "#a6e3a1",
|
|
402
|
+
"warn": "#f9e2af",
|
|
403
|
+
"section": "#f5c2e7",
|
|
404
|
+
"title": "#ffffff",
|
|
405
|
+
"agent": "#94e2d5",
|
|
406
|
+
"selected": "#0f2f3a",
|
|
407
|
+
"accent": "#89dceb",
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
def text_node(x: int, y: int, text: str, style: str = "normal", *, weight: str | None = None, size: int | None = None) -> str:
|
|
411
|
+
color = colors.get(style, colors["normal"])
|
|
412
|
+
weight = weight or ("700" if style in {"title", "section", "agent"} else "400")
|
|
413
|
+
size = size or (14 if style == "title" else 13)
|
|
414
|
+
return (
|
|
415
|
+
f'<text x="{x}" y="{y}" fill="{color}" font-family="Menlo, Monaco, monospace" '
|
|
416
|
+
f'font-size="{size}" font-weight="{weight}" xml:space="preserve">{html.escape(text)}</text>'
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def base(nodes: list[str]) -> str:
|
|
420
|
+
return (
|
|
421
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{GIF_WIDTH}" height="{GIF_HEIGHT}" '
|
|
422
|
+
f'viewBox="0 0 {GIF_WIDTH} {GIF_HEIGHT}">'
|
|
423
|
+
f'<rect width="{GIF_WIDTH}" height="{GIF_HEIGHT}" fill="{colors["bg"]}"/>'
|
|
424
|
+
f'<rect x="0" y="0" width="{GIF_WIDTH}" height="34" fill="{colors["bar"]}"/>'
|
|
425
|
+
'<circle cx="20" cy="17" r="5" fill="#ff5f57"/>'
|
|
426
|
+
'<circle cx="38" cy="17" r="5" fill="#ffbd2e"/>'
|
|
427
|
+
'<circle cx="56" cy="17" r="5" fill="#28c840"/>'
|
|
428
|
+
f'{text_node(76, 22, "agentic tui - OpenCode setup", "dim", size=12)}'
|
|
429
|
+
f'<rect x="0.5" y="0.5" width="{GIF_WIDTH - 1}" height="{GIF_HEIGHT - 1}" fill="none" stroke="{colors["border"]}"/>'
|
|
430
|
+
f'{"".join(nodes)}</svg>'
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def render_terminal(slide: dict) -> str:
|
|
434
|
+
nodes: list[str] = []
|
|
435
|
+
y = 58
|
|
436
|
+
for style, line in slide["lines"][-25:]:
|
|
437
|
+
nodes.append(text_node(18, y, line, style))
|
|
438
|
+
y += 17
|
|
439
|
+
return base(nodes)
|
|
440
|
+
|
|
441
|
+
def render_fzf(slide: dict) -> str:
|
|
442
|
+
nodes = [text_node(18, 54, "$ ./agentic tui", "cmd")]
|
|
443
|
+
px, py, pw, ph = 28, 92, 764, 390
|
|
444
|
+
nodes.append(f'<rect x="{px}" y="{py}" width="{pw}" height="{ph}" rx="4" fill="#0b1117" stroke="{colors["border"]}"/>')
|
|
445
|
+
nodes.append(text_node(px + 16, py + 28, slide["header"], "dim", size=12))
|
|
446
|
+
if slide.get("query") is not None:
|
|
447
|
+
nodes.append(text_node(px + 16, py + 58, f'{slide["prompt"]} {slide["query"]}', "cmd"))
|
|
448
|
+
opt_y = py + 92
|
|
449
|
+
else:
|
|
450
|
+
nodes.append(text_node(px + 16, py + 58, slide["prompt"], "cmd"))
|
|
451
|
+
opt_y = py + 90
|
|
452
|
+
|
|
453
|
+
selected = slide["selected"]
|
|
454
|
+
cursor = slide["cursor"]
|
|
455
|
+
options = slide["options"]
|
|
456
|
+
start = 0
|
|
457
|
+
max_opts = 16
|
|
458
|
+
if len(options) > max_opts:
|
|
459
|
+
start = max(0, min(cursor - 7, len(options) - max_opts))
|
|
460
|
+
visible = options[start : start + max_opts]
|
|
461
|
+
for index, option in enumerate(visible, start):
|
|
462
|
+
yy = opt_y + (index - start) * 22
|
|
463
|
+
is_cursor = index == cursor
|
|
464
|
+
is_selected = option in selected
|
|
465
|
+
if is_cursor:
|
|
466
|
+
nodes.append(f'<rect x="{px + 10}" y="{yy - 15}" width="{pw - 20}" height="21" fill="{colors["selected"]}"/>')
|
|
467
|
+
marker = "[x]" if is_selected else "[ ]"
|
|
468
|
+
prefix = "> " if is_cursor else " "
|
|
469
|
+
style = "ok" if is_selected else ("accent" if is_cursor else "normal")
|
|
470
|
+
nodes.append(text_node(px + 18, yy, f"{prefix}{marker} {option}", style))
|
|
471
|
+
nodes.append(text_node(px + 16, py + ph - 18, "Space: toggle Enter: confirm fzf", "dim", size=12))
|
|
472
|
+
return base(nodes)
|
|
473
|
+
|
|
474
|
+
cumulative: list[tuple[float, dict]] = []
|
|
475
|
+
elapsed = 0.0
|
|
476
|
+
for slide in slides:
|
|
477
|
+
elapsed += slide["duration"]
|
|
478
|
+
cumulative.append((elapsed, slide))
|
|
479
|
+
|
|
480
|
+
def slide_at(timestamp: float) -> dict:
|
|
481
|
+
for end, slide in cumulative:
|
|
482
|
+
if timestamp <= end:
|
|
483
|
+
return slide
|
|
484
|
+
return slides[-1]
|
|
485
|
+
|
|
486
|
+
frame_count = math.ceil(elapsed * fps)
|
|
487
|
+
for frame in range(frame_count):
|
|
488
|
+
slide = slide_at(frame / fps)
|
|
489
|
+
svg = render_fzf(slide) if slide["kind"] == "fzf" else render_terminal(slide)
|
|
490
|
+
svg_path = svg_dir / f"frame_{frame:04d}.svg"
|
|
491
|
+
png_path = frames_dir / f"frame_{frame:04d}.png"
|
|
492
|
+
svg_path.write_text(svg, encoding="utf-8")
|
|
493
|
+
subprocess.run(["rsvg-convert", "-w", str(GIF_WIDTH), "-h", str(GIF_HEIGHT), str(svg_path), "-o", str(png_path)], check=True)
|
|
494
|
+
|
|
495
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
496
|
+
run(
|
|
497
|
+
[
|
|
498
|
+
"ffmpeg",
|
|
499
|
+
"-hide_banner",
|
|
500
|
+
"-y",
|
|
501
|
+
"-framerate",
|
|
502
|
+
str(fps),
|
|
503
|
+
"-i",
|
|
504
|
+
str(frames_dir / "frame_%04d.png"),
|
|
505
|
+
"-vf",
|
|
506
|
+
"fps="
|
|
507
|
+
+ str(fps)
|
|
508
|
+
+ ",split[s0][s1];[s0]palettegen=max_colors=96[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5",
|
|
509
|
+
"-loop",
|
|
510
|
+
"0",
|
|
511
|
+
str(output),
|
|
512
|
+
]
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def scan_gif_for_private_strings(output: Path) -> None:
|
|
517
|
+
data = output.read_bytes()
|
|
518
|
+
needles = [b"/Users", b"token", b"secret", b"auth", b"google/", b"openai/", b"ollama-cloud/", b"deepseek/", b"minimax-"]
|
|
519
|
+
found = [needle.decode("utf-8", errors="ignore") for needle in needles if needle in data]
|
|
520
|
+
if found:
|
|
521
|
+
raise SystemExit(f"Generated GIF contains suspicious strings: {', '.join(found)}")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def main() -> int:
|
|
525
|
+
args = parse_args()
|
|
526
|
+
require_tool("rsvg-convert")
|
|
527
|
+
require_tool("ffmpeg")
|
|
528
|
+
require_tool(args.opencode)
|
|
529
|
+
|
|
530
|
+
tmp_root = Path(args.tmp_root) if args.tmp_root else Path(tempfile.mkdtemp(prefix="agentic-tui-gif."))
|
|
531
|
+
tmp_root.mkdir(parents=True, exist_ok=True)
|
|
532
|
+
project_dir = tmp_root / "project"
|
|
533
|
+
demo_home = tmp_root / "home"
|
|
534
|
+
demo_home.mkdir(parents=True, exist_ok=True)
|
|
535
|
+
|
|
536
|
+
copy_opencode_state(Path.home(), demo_home)
|
|
537
|
+
print(f"[gif] temp root: {tmp_root}")
|
|
538
|
+
print("[gif] running agentic TUI with deterministic fzf selections")
|
|
539
|
+
run_agentic_tui(args, tmp_root, project_dir, demo_home)
|
|
540
|
+
|
|
541
|
+
mappings = read_mappings(project_dir)
|
|
542
|
+
print("[gif] mapped roles:")
|
|
543
|
+
for role, model, fallback in mappings:
|
|
544
|
+
print(f"[gif] {role}: main={model} fallback={fallback}")
|
|
545
|
+
|
|
546
|
+
if args.skip_opencode:
|
|
547
|
+
print("[gif] using built-in OpenCode output sample (--skip-opencode)")
|
|
548
|
+
else:
|
|
549
|
+
print("[gif] running OpenCode product-owner")
|
|
550
|
+
opencode_output = run_opencode(args, tmp_root, project_dir, demo_home)
|
|
551
|
+
opencode_lines = clean_opencode_output(opencode_output)
|
|
552
|
+
if not args.skip_opencode and not any(line.startswith("> product-owner") for line in opencode_lines):
|
|
553
|
+
raise SystemExit(f"OpenCode output did not include product-owner header; see {opencode_output}")
|
|
554
|
+
|
|
555
|
+
output = Path(args.output)
|
|
556
|
+
print(f"[gif] rendering {output}")
|
|
557
|
+
slides = build_slides(args, mappings, opencode_lines)
|
|
558
|
+
render_gif(slides, output, tmp_root, args.fps)
|
|
559
|
+
scan_gif_for_private_strings(output)
|
|
560
|
+
print(f"[gif] wrote {output}")
|
|
561
|
+
return 0
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
if __name__ == "__main__":
|
|
565
|
+
raise SystemExit(main())
|