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