@puzzmo/cli 1.0.33 → 1.0.35

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.
Files changed (47) hide show
  1. package/lib/agents/claude.d.ts +7 -0
  2. package/lib/agents/claude.js +71 -0
  3. package/lib/agents/claude.js.map +1 -0
  4. package/lib/agents/codex.d.ts +6 -0
  5. package/lib/agents/codex.js +92 -0
  6. package/lib/agents/codex.js.map +1 -0
  7. package/lib/agents/copilot.d.ts +6 -0
  8. package/lib/agents/copilot.js +125 -0
  9. package/lib/agents/copilot.js.map +1 -0
  10. package/lib/agents/gemini.d.ts +7 -0
  11. package/lib/agents/gemini.js +73 -0
  12. package/lib/agents/gemini.js.map +1 -0
  13. package/lib/agents/index.d.ts +5 -0
  14. package/lib/agents/index.js +15 -0
  15. package/lib/agents/index.js.map +1 -0
  16. package/lib/agents/opencode.d.ts +7 -0
  17. package/lib/agents/opencode.js +161 -0
  18. package/lib/agents/opencode.js.map +1 -0
  19. package/lib/agents/stream-json-cli.d.ts +14 -0
  20. package/lib/agents/stream-json-cli.js +75 -0
  21. package/lib/agents/stream-json-cli.js.map +1 -0
  22. package/lib/agents/types.d.ts +39 -0
  23. package/lib/agents/types.js +2 -0
  24. package/lib/agents/types.js.map +1 -0
  25. package/lib/commands/agent-test.d.ts +5 -0
  26. package/lib/commands/agent-test.js +81 -0
  27. package/lib/commands/agent-test.js.map +1 -0
  28. package/lib/commands/game/create.js +6 -4
  29. package/lib/commands/game/create.js.map +1 -1
  30. package/lib/index.js +5 -0
  31. package/lib/index.js.map +1 -1
  32. package/lib/tui/pipeline.d.ts +1 -2
  33. package/lib/tui/pipeline.js +167 -267
  34. package/lib/tui/pipeline.js.map +1 -1
  35. package/package.json +4 -1
  36. package/src/agents/claude.ts +74 -0
  37. package/src/agents/codex.ts +84 -0
  38. package/src/agents/copilot.ts +120 -0
  39. package/src/agents/gemini.ts +78 -0
  40. package/src/agents/index.ts +20 -0
  41. package/src/agents/opencode.ts +156 -0
  42. package/src/agents/stream-json-cli.ts +88 -0
  43. package/src/agents/types.ts +24 -0
  44. package/src/commands/agent-test.ts +83 -0
  45. package/src/commands/game/create.ts +6 -4
  46. package/src/index.ts +5 -0
  47. package/src/tui/pipeline.ts +158 -268
@@ -1,19 +1,19 @@
1
- import { spawn } from "node:child_process";
1
+ import { execSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { execSync } from "node:child_process";
4
+ import { getAgent } from "../agents/index.js";
5
5
  import { skillsPipeline } from "../skills/registry.js";
6
6
  import { fetchSkillPrompt } from "../skills/mcp-client.js";
7
- // ANSI escape helpers
8
7
  const ESC = "\x1b";
9
8
  const CSI = `${ESC}[`;
10
9
  const moveTo = (row, col) => `${CSI}${row};${col}H`;
11
10
  const clearScreen = () => `${CSI}2J`;
12
11
  const hideCursor = () => `${CSI}?25l`;
13
12
  const showCursor = () => `${CSI}?25h`;
14
- const bold = (s) => `${CSI}1m${s}${CSI}0m`;
15
- const dim = (s) => `${CSI}2m${s}${CSI}0m`;
16
- const fg = (code, s) => `${CSI}${code}m${s}${CSI}0m`;
13
+ const reset = () => `${CSI}0m`;
14
+ const bold = (s) => `${CSI}1m${s}${reset()}`;
15
+ const dim = (s) => `${CSI}2m${s}${reset()}`;
16
+ const fg = (code, s) => `${CSI}${code}m${s}${reset()}`;
17
17
  const green = (s) => fg(32, s);
18
18
  const yellow = (s) => fg(33, s);
19
19
  const red = (s) => fg(31, s);
@@ -40,32 +40,47 @@ const phaseColor = {
40
40
  commit: magenta,
41
41
  idle: gray,
42
42
  };
43
- /** Build agent-specific command for non-interactive mode */
44
- const buildAgentCmd = (agent, prompt) => {
45
- if (agent === "claude")
46
- return { cmd: "claude", args: ["-p", "--verbose", "--output-format", "stream-json", prompt] };
47
- if (agent === "codex")
48
- return { cmd: "codex", args: ["--quiet", prompt] };
49
- return { cmd: agent, args: [prompt] };
50
- };
51
- /** Fetches step instructions from the MCP server and wraps them as an agent prompt */
43
+ // oxlint-disable-next-line no-control-regex
44
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
45
+ const stripAnsi = (s) => s.replace(ANSI_RE, "");
52
46
  const buildPrompt = async (stepName, gameDir, repoContext) => {
53
47
  const instructions = await fetchSkillPrompt(stepName, gameDir);
54
48
  return `Follow these instructions. The game source is in the current directory.\n\n${repoContext}\n\n${instructions}`;
55
49
  };
50
+ /**
51
+ * Format a non-streaming event into discrete lines. text/thinking are handled
52
+ * separately by appendText so token deltas merge into one in-progress line.
53
+ */
54
+ const formatDiscreteEvent = (e) => {
55
+ if (e.type === "tool_use")
56
+ return [`${yellow(e.name)} ${dim(e.summary)}`];
57
+ if (e.type === "tool_result")
58
+ return [(e.ok ? green(" ✓ ") : red(" ✗ ")) + dim(e.summary)];
59
+ if (e.type === "system")
60
+ return [dim(e.text)];
61
+ if (e.type === "result") {
62
+ const cost = e.costUSD != null ? ` ($${e.costUSD.toFixed(4)})` : "";
63
+ return [(e.ok ? green : red)(`Done${cost}`)];
64
+ }
65
+ if (e.type === "error")
66
+ return [red(`Error: ${e.message}`)];
67
+ return [];
68
+ };
56
69
  class PipelineTUI {
57
70
  agent;
58
71
  gameDir;
59
72
  repoContext;
60
73
  states;
61
74
  currentStep = 0;
62
- outputLines = [];
63
- chatLines = [];
64
75
  phase = "idle";
65
76
  done = false;
66
- activeProc = null;
77
+ outputLines = [];
78
+ currentLine = "";
79
+ chatLines = [];
80
+ renderScheduled = false;
67
81
  sidebarWidth = 30;
68
82
  logsDir;
83
+ abortController = null;
69
84
  constructor(agent, gameDir, repoContext) {
70
85
  this.agent = agent;
71
86
  this.gameDir = gameDir;
@@ -80,37 +95,83 @@ class PipelineTUI {
80
95
  get cols() {
81
96
  return process.stdout.columns || 80;
82
97
  }
83
- get maxOutputLines() {
98
+ get outputWidth() {
99
+ return Math.max(this.cols - this.sidebarWidth - 1, 20);
100
+ }
101
+ get contentRows() {
84
102
  return Math.max(this.rows - 4, 8);
85
103
  }
86
104
  write(s) {
87
105
  process.stdout.write(s);
88
106
  }
89
- /** Draw the entire screen */
107
+ scheduleRender() {
108
+ if (this.renderScheduled)
109
+ return;
110
+ this.renderScheduled = true;
111
+ setImmediate(() => {
112
+ this.renderScheduled = false;
113
+ this.render();
114
+ });
115
+ }
116
+ /** Lock the in-progress streaming line as a complete entry. */
117
+ lockCurrent() {
118
+ if (this.currentLine.length === 0)
119
+ return;
120
+ this.outputLines.push(this.currentLine);
121
+ this.chatLines.push(stripAnsi(this.currentLine));
122
+ this.currentLine = "";
123
+ }
124
+ /**
125
+ * Append streaming text. Newlines lock the in-progress line; the trailing
126
+ * fragment continues as the in-progress line. Optional styler wraps each
127
+ * fragment in ANSI codes (e.g. gray() for thinking).
128
+ */
129
+ appendText(text, styler) {
130
+ if (text.length === 0)
131
+ return;
132
+ const parts = text.split("\n");
133
+ this.currentLine += styler ? styler(parts[0]) : parts[0];
134
+ for (let i = 1; i < parts.length; i++) {
135
+ this.lockCurrent();
136
+ this.currentLine = styler ? styler(parts[i]) : parts[i];
137
+ }
138
+ if (this.outputLines.length > 1000)
139
+ this.outputLines = this.outputLines.slice(-this.contentRows * 4);
140
+ this.scheduleRender();
141
+ }
142
+ /**
143
+ * Append discrete pre-formatted lines (each becomes its own row). Locks any
144
+ * in-progress streaming line first so token streams don't bleed into a
145
+ * following tool_use marker.
146
+ */
147
+ appendLines(lines) {
148
+ if (lines.length === 0)
149
+ return;
150
+ this.lockCurrent();
151
+ for (const line of lines) {
152
+ this.outputLines.push(line);
153
+ this.chatLines.push(stripAnsi(line));
154
+ }
155
+ if (this.outputLines.length > 1000)
156
+ this.outputLines = this.outputLines.slice(-this.contentRows * 4);
157
+ this.scheduleRender();
158
+ }
90
159
  render() {
91
- const { rows, cols, sidebarWidth } = this;
92
- const outputWidth = cols - sidebarWidth - 1;
160
+ const { rows, sidebarWidth, outputWidth, contentRows } = this;
93
161
  let buf = "";
94
162
  buf += clearScreen();
95
163
  buf += moveTo(1, 1);
96
- // Top border
97
164
  buf += "╭" + "─".repeat(sidebarWidth - 2) + "┬" + "─".repeat(outputWidth) + "╮";
98
- // Sidebar header
99
165
  buf += moveTo(2, 1);
100
166
  buf += "│ " + bold("Migration Steps") + " ".repeat(Math.max(0, sidebarWidth - 18)) + "│";
101
- // Output panel header
102
167
  const headerLabel = this.done ? "Done" : `${skillsPipeline[this.currentStep]?.name ?? ""} ${dim(`(${this.phase})`)}`;
103
- const headerColor = phaseColor[this.phase];
104
- buf += " " + headerColor(bold(headerLabel));
105
- buf += " ".repeat(Math.max(0, outputWidth - this.stripAnsi(headerLabel).length - 1)) + "│";
106
- // Separator
168
+ const styledHeader = phaseColor[this.phase](bold(headerLabel));
169
+ const headerVisible = stripAnsi(headerLabel).length;
170
+ buf += " " + styledHeader + " ".repeat(Math.max(0, outputWidth - headerVisible - 1)) + "│";
107
171
  buf += moveTo(3, 1);
108
172
  buf += "├" + "─".repeat(sidebarWidth - 2) + "┼" + "─".repeat(outputWidth) + "┤";
109
- // Content rows
110
- const contentRows = rows - 4; // top border + header + separator + bottom border
111
173
  for (let row = 0; row < contentRows; row++) {
112
174
  buf += moveTo(row + 4, 1);
113
- // Sidebar column
114
175
  const skillIndex = row;
115
176
  if (skillIndex < skillsPipeline.length) {
116
177
  const skill = skillsPipeline[skillIndex];
@@ -120,11 +181,10 @@ class PipelineTUI {
120
181
  const colorFn = stateColor[state];
121
182
  const label = `${icon} ${skill.name}`;
122
183
  const styled = isActive ? bold(colorFn(label)) : colorFn(label);
123
- const rawLen = this.stripAnsi(styled).length;
184
+ const rawLen = stripAnsi(styled).length;
124
185
  buf += "│ " + styled + " ".repeat(Math.max(0, sidebarWidth - rawLen - 3)) + "│";
125
186
  }
126
187
  else if (skillIndex === skillsPipeline.length + 1) {
127
- // Status line
128
188
  const completedCount = this.states.filter((s) => s === "success").length;
129
189
  const failedCount = this.states.filter((s) => s === "failed").length;
130
190
  const status = this.done
@@ -132,248 +192,78 @@ class PipelineTUI {
132
192
  ? red(`${failedCount} failure(s)`)
133
193
  : green("All complete!")
134
194
  : dim(`${completedCount}/${skillsPipeline.length} done`);
135
- const rawLen = this.stripAnsi(status).length;
195
+ const rawLen = stripAnsi(status).length;
136
196
  buf += "│ " + status + " ".repeat(Math.max(0, sidebarWidth - rawLen - 3)) + "│";
137
197
  }
138
198
  else {
139
199
  buf += "│" + " ".repeat(sidebarWidth - 2) + "│";
140
200
  }
141
- // Output column
142
- const lineIndex = this.outputLines.length - contentRows + row;
143
- const line = lineIndex >= 0 ? (this.outputLines[lineIndex] ?? "") : "";
144
- const truncated = this.truncateVisible(line, outputWidth - 2);
145
- const visibleLen = this.stripAnsi(truncated).length;
201
+ const visibleCount = this.outputLines.length + (this.currentLine.length > 0 ? 1 : 0);
202
+ const lineIndex = visibleCount - contentRows + row;
203
+ const rawLine = lineIndex < 0 ? "" : lineIndex < this.outputLines.length ? (this.outputLines[lineIndex] ?? "") : this.currentLine;
204
+ const truncated = truncateVisible(rawLine, outputWidth - 2);
205
+ const visibleLen = stripAnsi(truncated).length;
146
206
  buf += " " + truncated + " ".repeat(Math.max(0, outputWidth - visibleLen - 1)) + "│";
147
207
  }
148
- // Bottom border
149
208
  buf += moveTo(rows, 1);
150
209
  buf += "╰" + "─".repeat(sidebarWidth - 2) + "┴" + "─".repeat(outputWidth) + "╯";
151
210
  this.write(buf);
152
211
  }
153
- /** Strip ANSI escape codes for length calculation */
154
- stripAnsi(s) {
155
- // oxlint-disable-next-line no-control-regex
156
- return s.replace(/\x1b\[[0-9;]*m/g, "");
157
- }
158
- /** Truncate a string to a visible width, preserving ANSI codes */
159
- truncateVisible(s, maxWidth) {
160
- let visible = 0;
161
- let i = 0;
162
- while (i < s.length && visible < maxWidth) {
163
- if (s[i] === "\x1b" && s[i + 1] === "[") {
164
- const end = s.indexOf("m", i);
165
- if (end !== -1) {
166
- i = end + 1;
212
+ async runAgent(prompt) {
213
+ this.abortController = new AbortController();
214
+ let sawError = false;
215
+ let resultOk = null;
216
+ try {
217
+ for await (const event of this.agent.run({ prompt, cwd: this.gameDir, signal: this.abortController.signal })) {
218
+ if (event.type === "error")
219
+ sawError = true;
220
+ if (event.type === "result")
221
+ resultOk = event.ok;
222
+ if (event.type === "text") {
223
+ this.appendText(event.text);
167
224
  continue;
168
225
  }
169
- }
170
- visible++;
171
- i++;
172
- }
173
- // Include any trailing ANSI sequences (like reset codes) right after the cut point
174
- while (i < s.length && s[i] === "\x1b" && s[i + 1] === "[") {
175
- const end = s.indexOf("m", i);
176
- if (end !== -1) {
177
- i = end + 1;
178
- }
179
- else
180
- break;
181
- }
182
- return s.slice(0, i);
183
- }
184
- /** Parse stream-json output from claude, or plain text from other agents */
185
- appendOutput(text) {
186
- const rawLines = text.split("\n").filter((l) => l.trim());
187
- for (const line of rawLines) {
188
- const parsed = this.parseStreamLine(line);
189
- if (parsed) {
190
- for (const displayLine of parsed.split("\n")) {
191
- this.outputLines.push(displayLine);
192
- this.chatLines.push(this.stripAnsi(displayLine));
226
+ if (event.type === "thinking") {
227
+ this.appendText(event.text, gray);
228
+ continue;
193
229
  }
230
+ const lines = formatDiscreteEvent(event);
231
+ if (lines.length > 0)
232
+ this.appendLines(lines);
194
233
  }
195
234
  }
196
- // Keep display buffer bounded, but chatLines grows unbounded per skill
197
- if (this.outputLines.length > 500) {
198
- this.outputLines = this.outputLines.slice(-this.maxOutputLines);
199
- }
200
- this.render();
201
- }
202
- /** Format a tool use block into a display line */
203
- formatToolUse(t) {
204
- const name = t.name ?? "unknown";
205
- if (name === "Edit")
206
- return `${yellow("Edit")} ${t.input?.file_path ?? ""}`;
207
- if (name === "Write")
208
- return `${yellow("Write")} ${t.input?.file_path ?? ""}`;
209
- if (name === "Read")
210
- return `${yellow("Read")} ${t.input?.file_path ?? ""}`;
211
- if (name === "Bash")
212
- return `${yellow("Bash")} ${t.input?.command ?? ""}`;
213
- if (name === "Glob")
214
- return `${yellow("Glob")} ${t.input?.pattern ?? ""}`;
215
- if (name === "Grep")
216
- return `${yellow("Grep")} ${t.input?.pattern ?? ""}`;
217
- return `${yellow(name)}`;
218
- }
219
- /** Extract human-readable text from a stream-json line, or pass through raw text */
220
- parseStreamLine(line) {
221
- try {
222
- const obj = JSON.parse(line);
223
- // Assistant messages - may contain text, tool_use, thinking, or a mix
224
- if (obj.type === "assistant" && obj.message?.content) {
225
- const content = obj.message.content;
226
- const parts = [];
227
- const texts = content.filter((c) => c.type === "text").map((c) => c.text);
228
- if (texts.length > 0)
229
- parts.push(texts.join(""));
230
- const toolUses = content.filter((c) => c.type === "tool_use");
231
- if (toolUses.length > 0)
232
- parts.push(toolUses.map((t) => this.formatToolUse(t)).join("\n"));
233
- // Thinking blocks - skip silently, they're just internal reasoning
234
- if (parts.length > 0)
235
- return parts.join("\n");
236
- if (content.some((c) => c.type === "thinking"))
237
- return null;
238
- return null;
239
- }
240
- // Content block delta (streaming text chunks)
241
- if (obj.type === "content_block_delta" && obj.delta?.text) {
242
- return obj.delta.text;
243
- }
244
- // Standalone tool use event
245
- if (obj.type === "tool_use") {
246
- return this.formatToolUse(obj);
247
- }
248
- // User messages (tool results coming back)
249
- if (obj.type === "user") {
250
- const content = obj.message?.content;
251
- if (!content)
252
- return null;
253
- // Show file create/update results
254
- const result = obj.tool_use_result;
255
- if (result?.type === "create")
256
- return dim(`Created ${result.filePath}`);
257
- if (result?.type === "update")
258
- return dim(`Updated ${result.filePath}`);
259
- return null;
260
- }
261
- // Rate limit events - suppress
262
- if (obj.type === "rate_limit_event")
263
- return null;
264
- // Tool results
265
- if (obj.type === "result") {
266
- const cost = obj.cost_usd ? ` ($${obj.cost_usd.toFixed(4)})` : "";
267
- return green(`Done${cost}`);
268
- }
269
- // System messages
270
- if (obj.type === "system") {
271
- if (obj.subtype === "init")
272
- return dim(`Session started`);
273
- if (obj.subtype === "hook_started")
274
- return null;
275
- if (obj.subtype === "hook_response")
276
- return null;
277
- if (obj.subtype === "task_started")
278
- return dim(`Subagent: ${obj.description ?? "started"}`);
279
- if (obj.subtype === "task_progress")
280
- return dim(` ${obj.last_tool_name ?? "working"}${obj.description ? ": " + obj.description : ""}`);
281
- if (obj.subtype === "task_notification")
282
- return obj.status === "completed"
283
- ? dim(`Subagent done: ${obj.summary ?? ""}`)
284
- : dim(`Subagent ${obj.status ?? "update"}: ${obj.summary ?? ""}`);
285
- return dim(`[system:${obj.subtype}]`);
286
- }
287
- // Show unrecognized types so we can add support
288
- return dim(`[${obj.type}${obj.subtype ? ":" + obj.subtype : ""}]`);
235
+ catch (e) {
236
+ this.appendLines([red(`Error: ${e.message ?? e}`)]);
237
+ return false;
289
238
  }
290
- catch {
291
- // Not JSON - show as-is
292
- return line;
239
+ finally {
240
+ this.abortController = null;
293
241
  }
242
+ this.lockCurrent();
243
+ if (resultOk != null)
244
+ return resultOk;
245
+ return !sawError;
294
246
  }
295
- /** Run a shell command, capturing output into the TUI panel */
296
247
  runCmd(cmd) {
297
248
  try {
298
249
  const output = execSync(cmd, { cwd: this.gameDir, encoding: "utf-8", stdio: "pipe" });
299
250
  if (output.trim())
300
- this.appendOutput(output.trim());
251
+ this.appendLines(output.trim().split("\n"));
301
252
  return { success: true, output };
302
253
  }
303
254
  catch (e) {
304
255
  const output = (e.stdout || "") + (e.stderr || "") || e.message;
305
256
  if (output.trim())
306
- this.appendOutput(output.trim());
257
+ this.appendLines(output.trim().split("\n"));
307
258
  return { success: false, output };
308
259
  }
309
260
  }
310
- /** Spawn agent and stream output */
311
- runAgent(prompt) {
312
- return new Promise((resolve) => {
313
- const { cmd, args } = buildAgentCmd(this.agent, prompt);
314
- const env = { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" };
315
- // Remove env vars that prevent nested claude sessions
316
- delete env.CLAUDECODE;
317
- const proc = spawn(cmd, args, {
318
- cwd: this.gameDir,
319
- env,
320
- stdio: ["ignore", "pipe", "pipe"],
321
- });
322
- this.activeProc = proc;
323
- let resolved = false;
324
- let gotResult = false;
325
- const debugLog = path.join(this.gameDir, "pipeline-debug.log");
326
- const finish = (success) => {
327
- if (resolved)
328
- return;
329
- resolved = true;
330
- this.activeProc = null;
331
- resolve(success);
332
- };
333
- proc.stdout?.on("data", (data) => {
334
- const text = data.toString();
335
- fs.appendFileSync(debugLog, text);
336
- this.appendOutput(text);
337
- // Detect the result event in stream-json - the agent is done
338
- if (!gotResult) {
339
- for (const line of text.split("\n")) {
340
- try {
341
- const obj = JSON.parse(line);
342
- if (obj.type === "result") {
343
- gotResult = true;
344
- // Give the process a moment to exit cleanly, then force-resolve
345
- setTimeout(() => {
346
- if (!resolved) {
347
- proc.kill();
348
- finish(true);
349
- }
350
- }, 3000);
351
- }
352
- }
353
- catch { }
354
- }
355
- }
356
- });
357
- proc.stderr?.on("data", (data) => {
358
- fs.appendFileSync(debugLog, `[stderr] ${data.toString()}`);
359
- this.appendOutput(data.toString());
360
- });
361
- proc.on("close", (code) => {
362
- fs.appendFileSync(debugLog, `\n[close] code=${code}\n`);
363
- finish(gotResult || code === 0);
364
- });
365
- proc.on("error", (err) => {
366
- this.appendOutput(`Error: ${err.message}`);
367
- finish(false);
368
- });
369
- });
370
- }
371
- /** Run one pipeline step */
372
261
  async runStep(stepIndex) {
373
262
  const skill = skillsPipeline[stepIndex];
374
263
  this.states[stepIndex] = "running";
375
264
  this.currentStep = stepIndex;
376
265
  this.outputLines = [];
266
+ this.currentLine = "";
377
267
  this.chatLines = [];
378
268
  this.phase = "agent";
379
269
  this.render();
@@ -382,71 +272,58 @@ class PipelineTUI {
382
272
  prompt = await buildPrompt(skill.name, this.gameDir, this.repoContext);
383
273
  }
384
274
  catch (e) {
385
- this.appendOutput(`Failed to fetch instructions: ${e.message}`);
275
+ this.appendLines([red(`Failed to fetch instructions: ${e.message}`)]);
386
276
  this.writeSkillLog(skill.name);
387
277
  this.states[stepIndex] = "failed";
388
- this.render();
389
278
  return false;
390
279
  }
391
280
  let success = await this.runAgent(prompt);
392
281
  if (!success) {
393
- this.appendOutput("\n--- Retrying ---\n");
282
+ this.appendLines(["", dim("--- Retrying ---")]);
394
283
  success = await this.runAgent(prompt);
395
284
  if (!success) {
396
285
  this.writeSkillLog(skill.name);
397
286
  this.states[stepIndex] = "failed";
398
- this.render();
399
287
  return false;
400
288
  }
401
289
  }
402
- // Verify build
403
290
  this.phase = "build";
404
291
  this.render();
405
- this.appendOutput("Verifying build...");
292
+ this.appendLines([dim("Verifying build...")]);
406
293
  const buildResult = this.runCmd("npx vite build");
407
294
  if (!buildResult.success) {
408
- this.appendOutput("Asking agent to fix...");
295
+ this.appendLines([dim("Asking agent to fix...")]);
409
296
  const fixPrompt = `The vite build failed after the "${skill.name}" step. Fix the build errors:\n\n${buildResult.output}`;
410
297
  await this.runAgent(fixPrompt);
411
298
  const retry = this.runCmd("npx vite build");
412
299
  if (!retry.success) {
413
300
  this.writeSkillLog(skill.name);
414
301
  this.states[stepIndex] = "failed";
415
- this.render();
416
302
  return false;
417
303
  }
418
304
  }
419
- // Commit
420
305
  this.phase = "commit";
421
306
  this.render();
422
307
  this.runCmd("git add -A");
423
308
  const commitResult = this.runCmd(`git commit -m "step: ${skill.name}"`);
424
- if (commitResult.success)
425
- this.appendOutput("Committed.");
426
- else
427
- this.appendOutput("No changes to commit.");
428
- // Write chat log for this skill
309
+ this.appendLines([dim(commitResult.success ? "Committed." : "No changes to commit.")]);
429
310
  this.writeSkillLog(skill.name);
430
311
  this.states[stepIndex] = "success";
431
312
  this.render();
432
313
  return true;
433
314
  }
434
- /** Writes the collected chat lines for a skill to a log file */
435
315
  writeSkillLog(skillName) {
436
316
  const logPath = path.join(this.logsDir, `${skillName}.txt`);
437
317
  fs.writeFileSync(logPath, this.chatLines.join("\n") + "\n");
438
318
  }
439
- /** Set up keyboard handling (Ctrl+C to quit) */
440
319
  setupInput() {
441
- if (process.stdin.isTTY) {
320
+ if (process.stdin.isTTY)
442
321
  process.stdin.setRawMode(true);
443
- }
444
322
  process.stdin.resume();
445
323
  process.stdin.on("data", (data) => {
446
324
  // Ctrl+C
447
325
  if (data[0] === 3) {
448
- if (this.activeProc)
449
- this.activeProc.kill();
326
+ this.abortController?.abort();
450
327
  this.cleanup();
451
328
  process.exit(0);
452
329
  }
@@ -454,22 +331,19 @@ class PipelineTUI {
454
331
  }
455
332
  cleanup() {
456
333
  this.write(showCursor());
457
- if (process.stdin.isTTY) {
334
+ if (process.stdin.isTTY)
458
335
  process.stdin.setRawMode(false);
459
- }
460
336
  process.stdin.pause();
461
337
  }
462
- /** Run the full pipeline */
463
338
  async run() {
464
339
  this.write(hideCursor());
465
340
  this.setupInput();
466
341
  this.render();
467
- // Redraw on terminal resize
468
342
  process.stdout.on("resize", () => this.render());
469
343
  for (let i = 0; i < skillsPipeline.length; i++) {
470
344
  const ok = await this.runStep(i);
471
345
  if (!ok) {
472
- this.appendOutput("\nPipeline stopped due to failure.");
346
+ this.appendLines(["", red("Pipeline stopped due to failure.")]);
473
347
  break;
474
348
  }
475
349
  }
@@ -479,8 +353,34 @@ class PipelineTUI {
479
353
  this.cleanup();
480
354
  }
481
355
  }
482
- /** Render the pipeline TUI and wait for it to complete */
483
- export const runPipelineTUI = async (agent, gameDir, repoContext) => {
356
+ /** Truncate a string to a visible width, preserving ANSI codes. */
357
+ const truncateVisible = (s, maxWidth) => {
358
+ let visible = 0;
359
+ let i = 0;
360
+ while (i < s.length && visible < maxWidth) {
361
+ if (s[i] === "\x1b" && s[i + 1] === "[") {
362
+ const end = s.indexOf("m", i);
363
+ if (end !== -1) {
364
+ i = end + 1;
365
+ continue;
366
+ }
367
+ }
368
+ visible++;
369
+ i++;
370
+ }
371
+ while (i < s.length && s[i] === "\x1b" && s[i + 1] === "[") {
372
+ const end = s.indexOf("m", i);
373
+ if (end !== -1)
374
+ i = end + 1;
375
+ else
376
+ break;
377
+ }
378
+ return s.slice(0, i);
379
+ };
380
+ export const runPipelineTUI = async (agentName, gameDir, repoContext) => {
381
+ const agent = getAgent(agentName);
382
+ if (!agent)
383
+ throw new Error(`Unknown agent: ${agentName}`);
484
384
  const tui = new PipelineTUI(agent, gameDir, repoContext);
485
385
  await tui.run();
486
386
  };