@puzzmo/cli 1.0.33 → 1.0.34

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 +5 -0
  2. package/lib/agents/claude.js +69 -0
  3. package/lib/agents/claude.js.map +1 -0
  4. package/lib/agents/codex.d.ts +4 -0
  5. package/lib/agents/codex.js +90 -0
  6. package/lib/agents/codex.js.map +1 -0
  7. package/lib/agents/copilot.d.ts +4 -0
  8. package/lib/agents/copilot.js +123 -0
  9. package/lib/agents/copilot.js.map +1 -0
  10. package/lib/agents/gemini.d.ts +5 -0
  11. package/lib/agents/gemini.js +69 -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 +5 -0
  17. package/lib/agents/opencode.js +159 -0
  18. package/lib/agents/opencode.js.map +1 -0
  19. package/lib/agents/stream-json-cli.d.ts +12 -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 +37 -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 +3 -0
  26. package/lib/commands/agent-test.js +79 -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 +160 -267
  34. package/lib/tui/pipeline.js.map +1 -1
  35. package/package.json +4 -1
  36. package/src/agents/claude.ts +72 -0
  37. package/src/agents/codex.ts +82 -0
  38. package/src/agents/copilot.ts +118 -0
  39. package/src/agents/gemini.ts +74 -0
  40. package/src/agents/index.ts +20 -0
  41. package/src/agents/opencode.ts +154 -0
  42. package/src/agents/stream-json-cli.ts +86 -0
  43. package/src/agents/types.ts +22 -0
  44. package/src/commands/agent-test.ts +81 -0
  45. package/src/commands/game/create.ts +6 -4
  46. package/src/index.ts +5 -0
  47. package/src/tui/pipeline.ts +151 -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,44 @@ 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
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
44
+ const stripAnsi = (s) => s.replace(ANSI_RE, "");
52
45
  const buildPrompt = async (stepName, gameDir, repoContext) => {
53
46
  const instructions = await fetchSkillPrompt(stepName, gameDir);
54
47
  return `Follow these instructions. The game source is in the current directory.\n\n${repoContext}\n\n${instructions}`;
55
48
  };
49
+ /** Format a non-streaming event into discrete lines. text/thinking are handled
50
+ * separately by appendText so token deltas merge into one in-progress line. */
51
+ const formatDiscreteEvent = (e) => {
52
+ if (e.type === "tool_use")
53
+ return [`${yellow(e.name)} ${dim(e.summary)}`];
54
+ if (e.type === "tool_result")
55
+ return [(e.ok ? green(" ✓ ") : red(" ✗ ")) + dim(e.summary)];
56
+ if (e.type === "system")
57
+ return [dim(e.text)];
58
+ if (e.type === "result") {
59
+ const cost = e.costUSD != null ? ` ($${e.costUSD.toFixed(4)})` : "";
60
+ return [(e.ok ? green : red)(`Done${cost}`)];
61
+ }
62
+ if (e.type === "error")
63
+ return [red(`Error: ${e.message}`)];
64
+ return [];
65
+ };
56
66
  class PipelineTUI {
57
67
  agent;
58
68
  gameDir;
59
69
  repoContext;
60
70
  states;
61
71
  currentStep = 0;
62
- outputLines = [];
63
- chatLines = [];
64
72
  phase = "idle";
65
73
  done = false;
66
- activeProc = null;
74
+ outputLines = [];
75
+ currentLine = "";
76
+ chatLines = [];
77
+ renderScheduled = false;
67
78
  sidebarWidth = 30;
68
79
  logsDir;
80
+ abortController = null;
69
81
  constructor(agent, gameDir, repoContext) {
70
82
  this.agent = agent;
71
83
  this.gameDir = gameDir;
@@ -80,37 +92,79 @@ class PipelineTUI {
80
92
  get cols() {
81
93
  return process.stdout.columns || 80;
82
94
  }
83
- get maxOutputLines() {
95
+ get outputWidth() {
96
+ return Math.max(this.cols - this.sidebarWidth - 1, 20);
97
+ }
98
+ get contentRows() {
84
99
  return Math.max(this.rows - 4, 8);
85
100
  }
86
101
  write(s) {
87
102
  process.stdout.write(s);
88
103
  }
89
- /** Draw the entire screen */
104
+ scheduleRender() {
105
+ if (this.renderScheduled)
106
+ return;
107
+ this.renderScheduled = true;
108
+ setImmediate(() => {
109
+ this.renderScheduled = false;
110
+ this.render();
111
+ });
112
+ }
113
+ /** Lock the in-progress streaming line as a complete entry. */
114
+ lockCurrent() {
115
+ if (this.currentLine.length === 0)
116
+ return;
117
+ this.outputLines.push(this.currentLine);
118
+ this.chatLines.push(stripAnsi(this.currentLine));
119
+ this.currentLine = "";
120
+ }
121
+ /** Append streaming text. Newlines lock the in-progress line; the trailing
122
+ * fragment continues as the in-progress line. Optional styler wraps each
123
+ * fragment in ANSI codes (e.g. gray() for thinking). */
124
+ appendText(text, styler) {
125
+ if (text.length === 0)
126
+ return;
127
+ const parts = text.split("\n");
128
+ this.currentLine += styler ? styler(parts[0]) : parts[0];
129
+ for (let i = 1; i < parts.length; i++) {
130
+ this.lockCurrent();
131
+ this.currentLine = styler ? styler(parts[i]) : parts[i];
132
+ }
133
+ if (this.outputLines.length > 1000)
134
+ this.outputLines = this.outputLines.slice(-this.contentRows * 4);
135
+ this.scheduleRender();
136
+ }
137
+ /** Append discrete pre-formatted lines (each becomes its own row). Locks any
138
+ * in-progress streaming line first so token streams don't bleed into a
139
+ * following tool_use marker. */
140
+ appendLines(lines) {
141
+ if (lines.length === 0)
142
+ return;
143
+ this.lockCurrent();
144
+ for (const line of lines) {
145
+ this.outputLines.push(line);
146
+ this.chatLines.push(stripAnsi(line));
147
+ }
148
+ if (this.outputLines.length > 1000)
149
+ this.outputLines = this.outputLines.slice(-this.contentRows * 4);
150
+ this.scheduleRender();
151
+ }
90
152
  render() {
91
- const { rows, cols, sidebarWidth } = this;
92
- const outputWidth = cols - sidebarWidth - 1;
153
+ const { rows, sidebarWidth, outputWidth, contentRows } = this;
93
154
  let buf = "";
94
155
  buf += clearScreen();
95
156
  buf += moveTo(1, 1);
96
- // Top border
97
157
  buf += "╭" + "─".repeat(sidebarWidth - 2) + "┬" + "─".repeat(outputWidth) + "╮";
98
- // Sidebar header
99
158
  buf += moveTo(2, 1);
100
159
  buf += "│ " + bold("Migration Steps") + " ".repeat(Math.max(0, sidebarWidth - 18)) + "│";
101
- // Output panel header
102
160
  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
161
+ const styledHeader = phaseColor[this.phase](bold(headerLabel));
162
+ const headerVisible = stripAnsi(headerLabel).length;
163
+ buf += " " + styledHeader + " ".repeat(Math.max(0, outputWidth - headerVisible - 1)) + "│";
107
164
  buf += moveTo(3, 1);
108
165
  buf += "├" + "─".repeat(sidebarWidth - 2) + "┼" + "─".repeat(outputWidth) + "┤";
109
- // Content rows
110
- const contentRows = rows - 4; // top border + header + separator + bottom border
111
166
  for (let row = 0; row < contentRows; row++) {
112
167
  buf += moveTo(row + 4, 1);
113
- // Sidebar column
114
168
  const skillIndex = row;
115
169
  if (skillIndex < skillsPipeline.length) {
116
170
  const skill = skillsPipeline[skillIndex];
@@ -120,11 +174,10 @@ class PipelineTUI {
120
174
  const colorFn = stateColor[state];
121
175
  const label = `${icon} ${skill.name}`;
122
176
  const styled = isActive ? bold(colorFn(label)) : colorFn(label);
123
- const rawLen = this.stripAnsi(styled).length;
177
+ const rawLen = stripAnsi(styled).length;
124
178
  buf += "│ " + styled + " ".repeat(Math.max(0, sidebarWidth - rawLen - 3)) + "│";
125
179
  }
126
180
  else if (skillIndex === skillsPipeline.length + 1) {
127
- // Status line
128
181
  const completedCount = this.states.filter((s) => s === "success").length;
129
182
  const failedCount = this.states.filter((s) => s === "failed").length;
130
183
  const status = this.done
@@ -132,248 +185,78 @@ class PipelineTUI {
132
185
  ? red(`${failedCount} failure(s)`)
133
186
  : green("All complete!")
134
187
  : dim(`${completedCount}/${skillsPipeline.length} done`);
135
- const rawLen = this.stripAnsi(status).length;
188
+ const rawLen = stripAnsi(status).length;
136
189
  buf += "│ " + status + " ".repeat(Math.max(0, sidebarWidth - rawLen - 3)) + "│";
137
190
  }
138
191
  else {
139
192
  buf += "│" + " ".repeat(sidebarWidth - 2) + "│";
140
193
  }
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;
194
+ const visibleCount = this.outputLines.length + (this.currentLine.length > 0 ? 1 : 0);
195
+ const lineIndex = visibleCount - contentRows + row;
196
+ const rawLine = lineIndex < 0 ? "" : lineIndex < this.outputLines.length ? (this.outputLines[lineIndex] ?? "") : this.currentLine;
197
+ const truncated = truncateVisible(rawLine, outputWidth - 2);
198
+ const visibleLen = stripAnsi(truncated).length;
146
199
  buf += " " + truncated + " ".repeat(Math.max(0, outputWidth - visibleLen - 1)) + "│";
147
200
  }
148
- // Bottom border
149
201
  buf += moveTo(rows, 1);
150
202
  buf += "╰" + "─".repeat(sidebarWidth - 2) + "┴" + "─".repeat(outputWidth) + "╯";
151
203
  this.write(buf);
152
204
  }
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;
205
+ async runAgent(prompt) {
206
+ this.abortController = new AbortController();
207
+ let sawError = false;
208
+ let resultOk = null;
209
+ try {
210
+ for await (const event of this.agent.run({ prompt, cwd: this.gameDir, signal: this.abortController.signal })) {
211
+ if (event.type === "error")
212
+ sawError = true;
213
+ if (event.type === "result")
214
+ resultOk = event.ok;
215
+ if (event.type === "text") {
216
+ this.appendText(event.text);
167
217
  continue;
168
218
  }
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));
219
+ if (event.type === "thinking") {
220
+ this.appendText(event.text, gray);
221
+ continue;
193
222
  }
223
+ const lines = formatDiscreteEvent(event);
224
+ if (lines.length > 0)
225
+ this.appendLines(lines);
194
226
  }
195
227
  }
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 : ""}]`);
228
+ catch (e) {
229
+ this.appendLines([red(`Error: ${e.message ?? e}`)]);
230
+ return false;
289
231
  }
290
- catch {
291
- // Not JSON - show as-is
292
- return line;
232
+ finally {
233
+ this.abortController = null;
293
234
  }
235
+ this.lockCurrent();
236
+ if (resultOk != null)
237
+ return resultOk;
238
+ return !sawError;
294
239
  }
295
- /** Run a shell command, capturing output into the TUI panel */
296
240
  runCmd(cmd) {
297
241
  try {
298
242
  const output = execSync(cmd, { cwd: this.gameDir, encoding: "utf-8", stdio: "pipe" });
299
243
  if (output.trim())
300
- this.appendOutput(output.trim());
244
+ this.appendLines(output.trim().split("\n"));
301
245
  return { success: true, output };
302
246
  }
303
247
  catch (e) {
304
248
  const output = (e.stdout || "") + (e.stderr || "") || e.message;
305
249
  if (output.trim())
306
- this.appendOutput(output.trim());
250
+ this.appendLines(output.trim().split("\n"));
307
251
  return { success: false, output };
308
252
  }
309
253
  }
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
254
  async runStep(stepIndex) {
373
255
  const skill = skillsPipeline[stepIndex];
374
256
  this.states[stepIndex] = "running";
375
257
  this.currentStep = stepIndex;
376
258
  this.outputLines = [];
259
+ this.currentLine = "";
377
260
  this.chatLines = [];
378
261
  this.phase = "agent";
379
262
  this.render();
@@ -382,71 +265,58 @@ class PipelineTUI {
382
265
  prompt = await buildPrompt(skill.name, this.gameDir, this.repoContext);
383
266
  }
384
267
  catch (e) {
385
- this.appendOutput(`Failed to fetch instructions: ${e.message}`);
268
+ this.appendLines([red(`Failed to fetch instructions: ${e.message}`)]);
386
269
  this.writeSkillLog(skill.name);
387
270
  this.states[stepIndex] = "failed";
388
- this.render();
389
271
  return false;
390
272
  }
391
273
  let success = await this.runAgent(prompt);
392
274
  if (!success) {
393
- this.appendOutput("\n--- Retrying ---\n");
275
+ this.appendLines(["", dim("--- Retrying ---")]);
394
276
  success = await this.runAgent(prompt);
395
277
  if (!success) {
396
278
  this.writeSkillLog(skill.name);
397
279
  this.states[stepIndex] = "failed";
398
- this.render();
399
280
  return false;
400
281
  }
401
282
  }
402
- // Verify build
403
283
  this.phase = "build";
404
284
  this.render();
405
- this.appendOutput("Verifying build...");
285
+ this.appendLines([dim("Verifying build...")]);
406
286
  const buildResult = this.runCmd("npx vite build");
407
287
  if (!buildResult.success) {
408
- this.appendOutput("Asking agent to fix...");
288
+ this.appendLines([dim("Asking agent to fix...")]);
409
289
  const fixPrompt = `The vite build failed after the "${skill.name}" step. Fix the build errors:\n\n${buildResult.output}`;
410
290
  await this.runAgent(fixPrompt);
411
291
  const retry = this.runCmd("npx vite build");
412
292
  if (!retry.success) {
413
293
  this.writeSkillLog(skill.name);
414
294
  this.states[stepIndex] = "failed";
415
- this.render();
416
295
  return false;
417
296
  }
418
297
  }
419
- // Commit
420
298
  this.phase = "commit";
421
299
  this.render();
422
300
  this.runCmd("git add -A");
423
301
  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
302
+ this.appendLines([dim(commitResult.success ? "Committed." : "No changes to commit.")]);
429
303
  this.writeSkillLog(skill.name);
430
304
  this.states[stepIndex] = "success";
431
305
  this.render();
432
306
  return true;
433
307
  }
434
- /** Writes the collected chat lines for a skill to a log file */
435
308
  writeSkillLog(skillName) {
436
309
  const logPath = path.join(this.logsDir, `${skillName}.txt`);
437
310
  fs.writeFileSync(logPath, this.chatLines.join("\n") + "\n");
438
311
  }
439
- /** Set up keyboard handling (Ctrl+C to quit) */
440
312
  setupInput() {
441
- if (process.stdin.isTTY) {
313
+ if (process.stdin.isTTY)
442
314
  process.stdin.setRawMode(true);
443
- }
444
315
  process.stdin.resume();
445
316
  process.stdin.on("data", (data) => {
446
317
  // Ctrl+C
447
318
  if (data[0] === 3) {
448
- if (this.activeProc)
449
- this.activeProc.kill();
319
+ this.abortController?.abort();
450
320
  this.cleanup();
451
321
  process.exit(0);
452
322
  }
@@ -454,22 +324,19 @@ class PipelineTUI {
454
324
  }
455
325
  cleanup() {
456
326
  this.write(showCursor());
457
- if (process.stdin.isTTY) {
327
+ if (process.stdin.isTTY)
458
328
  process.stdin.setRawMode(false);
459
- }
460
329
  process.stdin.pause();
461
330
  }
462
- /** Run the full pipeline */
463
331
  async run() {
464
332
  this.write(hideCursor());
465
333
  this.setupInput();
466
334
  this.render();
467
- // Redraw on terminal resize
468
335
  process.stdout.on("resize", () => this.render());
469
336
  for (let i = 0; i < skillsPipeline.length; i++) {
470
337
  const ok = await this.runStep(i);
471
338
  if (!ok) {
472
- this.appendOutput("\nPipeline stopped due to failure.");
339
+ this.appendLines(["", red("Pipeline stopped due to failure.")]);
473
340
  break;
474
341
  }
475
342
  }
@@ -479,8 +346,34 @@ class PipelineTUI {
479
346
  this.cleanup();
480
347
  }
481
348
  }
482
- /** Render the pipeline TUI and wait for it to complete */
483
- export const runPipelineTUI = async (agent, gameDir, repoContext) => {
349
+ /** Truncate a string to a visible width, preserving ANSI codes. */
350
+ const truncateVisible = (s, maxWidth) => {
351
+ let visible = 0;
352
+ let i = 0;
353
+ while (i < s.length && visible < maxWidth) {
354
+ if (s[i] === "\x1b" && s[i + 1] === "[") {
355
+ const end = s.indexOf("m", i);
356
+ if (end !== -1) {
357
+ i = end + 1;
358
+ continue;
359
+ }
360
+ }
361
+ visible++;
362
+ i++;
363
+ }
364
+ while (i < s.length && s[i] === "\x1b" && s[i + 1] === "[") {
365
+ const end = s.indexOf("m", i);
366
+ if (end !== -1)
367
+ i = end + 1;
368
+ else
369
+ break;
370
+ }
371
+ return s.slice(0, i);
372
+ };
373
+ export const runPipelineTUI = async (agentName, gameDir, repoContext) => {
374
+ const agent = getAgent(agentName);
375
+ if (!agent)
376
+ throw new Error(`Unknown agent: ${agentName}`);
484
377
  const tui = new PipelineTUI(agent, gameDir, repoContext);
485
378
  await tui.run();
486
379
  };