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