@capagents/forgeqe 1.0.9 → 1.1.1

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.
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs"
2
2
  import os from "node:os"
3
3
  import path from "node:path"
4
4
  import { SyntaxStyle } from "@opentui/core"
5
+ import type { ReactNode } from "react"
5
6
 
6
7
  import type { CodeResult, RunStatus } from "../types.ts"
7
8
  import { CARD_MARK, type Card } from "../card.ts"
@@ -50,6 +51,9 @@ export const TEST_VERDICT: Record<CodeResult["status"], [string, string]> = {
50
51
  /** The closing card of a request: the report of each run, and the test written for it, or why none was. */
51
52
  export function producedCard(p: Produced, tests: TestsSetting): Card | null {
52
53
  if (!p.runs.length && !p.suites.length && !p.tests.length) return null
54
+ // Nothing to show when nothing was made: no report, no test, no suite (a run that only looked something up).
55
+ const made = p.suites.length || p.tests.length || p.runs.some((r) => r.files.some((f) => path.basename(f) === "report.html"))
56
+ if (!made) return null
53
57
  const show = shortPath
54
58
  const row = (mark: string, label: string, value: string) => `${mark.padEnd(2)}${label.padEnd(10)}${value}`
55
59
  const body: string[] = []
@@ -89,72 +93,69 @@ export function producedCard(p: Produced, tests: TestsSetting): Card | null {
89
93
  return { status, title: "What this produced", body }
90
94
  }
91
95
 
96
+ /**
97
+ * The conversation reads top to bottom, as in Claude Code: what you asked after ">", each reply, tool call, or result
98
+ * after "⏺", and what came of it indented under the call after "⎿". No boxes or backgrounds: marks and colour carry it.
99
+ */
100
+ export const MARK = { item: "⏺", child: "⎿", user: ">" }
101
+
102
+ /** A mark in its own column and the content beside it, so wrapped lines stay under the content. */
103
+ export function Marked(props: { mark: string; color: string; indent?: number; gap?: boolean; children: ReactNode }) {
104
+ return (
105
+ <box style={{ flexDirection: "row", flexShrink: 0, paddingLeft: props.indent || 0, marginTop: props.gap ? 1 : 0 }}>
106
+ <text fg={props.color} style={{ width: props.mark.length + 1, flexShrink: 0 }}>
107
+ {props.mark}
108
+ </text>
109
+ <box style={{ flexDirection: "column", flexGrow: 1, flexShrink: 1 }}>{props.children}</box>
110
+ </box>
111
+ )
112
+ }
113
+
114
+ /** What came of a call, under it: "⎿" before the first line of a group, a matching indent before the rest. */
115
+ export function Under(props: { first: boolean; children: ReactNode }) {
116
+ return (
117
+ <Marked mark={props.first ? MARK.child : " "} color={theme.faint} indent={2}>
118
+ {props.children}
119
+ </Marked>
120
+ )
121
+ }
122
+
92
123
  export function SummaryCard({ summary }: { summary: RunSummary }) {
93
124
  const color = summary.status === "complete" ? theme.ok : summary.status === "stopped" ? theme.warn : theme.err
125
+ const detail = (label: string, value: ReactNode) => (
126
+ <text>
127
+ <span fg={theme.faint}>{label.padEnd(10)}</span>
128
+ {value}
129
+ </text>
130
+ )
94
131
  return (
95
- <box
96
- border={["left"]}
97
- borderColor={color}
98
- customBorderChars={BAR}
99
- style={{
100
- flexDirection: "column",
101
- backgroundColor: theme.panel,
102
- paddingLeft: 2,
103
- paddingRight: 2,
104
- paddingTop: 1,
105
- paddingBottom: 1,
106
- marginTop: 1,
107
- marginBottom: 1,
108
- flexShrink: 0,
109
- }}
110
- >
111
- <box style={{ flexDirection: "row", justifyContent: "space-between" }}>
112
- <text>
113
- <span fg={color}>{`${STATUS_MARK[summary.status]} `}</span>
114
- <span fg={theme.text}>{summary.title}</span>
115
- </text>
116
- <text fg={theme.muted}>{summary.stats}</text>
117
- </box>
118
- {summary.failed.map((f, i) => (
119
- <text key={i} fg={theme.err} style={i === 0 ? { marginTop: 1 } : undefined}>
120
- {`✗ ${f}`}
121
- </text>
122
- ))}
123
- <text fg={theme.text} style={{ marginTop: 1 }}>
124
- {summary.text}
125
- </text>
126
- {summary.finalUrl || summary.savedDir ? <box style={{ height: 1 }} /> : null}
127
- {summary.finalUrl ? (
128
- <text>
129
- <span fg={theme.faint}>{"Ended on "}</span>
130
- <span fg={theme.muted}>{summary.finalUrl}</span>
131
- </text>
132
- ) : null}
133
- {summary.memory ? (
134
- <text>
135
- <span fg={theme.faint}>{"Memory "}</span>
136
- <span fg={theme.memory}>{summary.memory}</span>
137
- </text>
138
- ) : null}
139
- {summary.quality ? (
140
- <text>
141
- <span fg={theme.faint}>{"Quality "}</span>
142
- <span fg={theme.muted}>{summary.quality}</span>
143
- </text>
144
- ) : null}
145
- {summary.usage ? (
146
- <text>
147
- <span fg={theme.faint}>{"Usage "}</span>
148
- <span fg={theme.muted}>{summary.usage}</span>
149
- </text>
150
- ) : null}
151
- {summary.savedDir ? (
132
+ <box style={{ flexDirection: "column", flexShrink: 0 }}>
133
+ <Marked mark={MARK.item} color={color} gap>
152
134
  <text>
153
- <span fg={theme.faint}>{"Saved "}</span>
154
- <Linked text={`${summary.savedDir}/ `} color={theme.muted} />
155
- <span fg={theme.faint}>{summary.savedFiles.join(" · ")}</span>
135
+ <span fg={theme.text}>
136
+ <strong>{summary.title}</strong>
137
+ </span>
138
+ <span fg={theme.faint}>{` ${summary.stats}`}</span>
156
139
  </text>
157
- ) : null}
140
+ </Marked>
141
+ <Under first>
142
+ {summary.failed.map((f, i) => (
143
+ <text key={i} fg={theme.err}>
144
+ {`✗ ${f}`}
145
+ </text>
146
+ ))}
147
+ <text fg={theme.text}>{summary.text}</text>
148
+ {summary.finalUrl ? detail("Ended on", <span fg={theme.muted}>{summary.finalUrl}</span>) : null}
149
+ {summary.memory ? detail("Memory", <span fg={theme.muted}>{summary.memory}</span>) : null}
150
+ {summary.quality ? detail("Quality", <span fg={theme.muted}>{summary.quality}</span>) : null}
151
+ {summary.savedDir ? (
152
+ <text>
153
+ <span fg={theme.faint}>{"Saved "}</span>
154
+ <Linked text={`${summary.savedDir}/ `} color={theme.muted} />
155
+ <span fg={theme.faint}>{summary.savedFiles.join(" · ")}</span>
156
+ </text>
157
+ ) : null}
158
+ </Under>
158
159
  </box>
159
160
  )
160
161
  }
@@ -221,30 +222,16 @@ export function SuiteCard({ suite }: { suite: SuiteResult }) {
221
222
  const show = (f: string) => path.relative(process.cwd(), f) || f
222
223
  const caseColor = (s: keyof typeof CASE_MARK) => (s === "passed" ? theme.ok : s === "stopped" ? theme.warn : theme.err)
223
224
  return (
224
- <box
225
- border={["left"]}
226
- borderColor={color}
227
- customBorderChars={BAR}
228
- style={{
229
- flexDirection: "column",
230
- backgroundColor: theme.panel,
231
- paddingLeft: 2,
232
- paddingRight: 2,
233
- paddingTop: 1,
234
- paddingBottom: 1,
235
- marginTop: 1,
236
- marginBottom: 1,
237
- flexShrink: 0,
238
- }}
239
- >
240
- <box style={{ flexDirection: "row", justifyContent: "space-between" }}>
225
+ <box style={{ flexDirection: "column", flexShrink: 0 }}>
226
+ <Marked mark={MARK.item} color={color} gap>
241
227
  <text>
242
- <span fg={color}>{suite.stopped ? "■ " : ok ? "✓ " : "✗ "}</span>
243
- <span fg={theme.text}>{suite.stopped ? "Suite stopped" : ok ? "Suite passed" : "Suite failed"}</span>
228
+ <span fg={theme.text}>
229
+ <strong>{suite.stopped ? "Suite stopped" : ok ? "Suite passed" : "Suite failed"}</strong>
230
+ </span>
231
+ <span fg={theme.faint}>{` ${c.passed}/${c.total} passed · ${formatDuration(suite.ms)} · ${formatTokens(tokens)} tokens`}</span>
244
232
  </text>
245
- <text fg={theme.muted}>{`${c.passed}/${c.total} passed · ${formatDuration(suite.ms)} · ${formatTokens(tokens)} tokens`}</text>
246
- </box>
247
- <box style={{ height: 1 }} />
233
+ </Marked>
234
+ <Under first>
248
235
  {suite.results.map((r) => (
249
236
  <box key={r.case.id} style={{ flexDirection: "column", flexShrink: 0 }}>
250
237
  <text>
@@ -265,9 +252,9 @@ export function SuiteCard({ suite }: { suite: SuiteResult }) {
265
252
  <span fg={theme.muted}>{`${w.text}${w.url ? ` ${w.url}` : ""}${w.error ? `: ${w.error}` : ""}`}</span>
266
253
  </text>
267
254
  ))}
268
- <box style={{ height: 1 }} />
269
255
  <Row label="Report" value={show(suite.files.html)} color={theme.text} />
270
256
  <Row label="JUnit" value={show(suite.files.junit)} />
257
+ </Under>
271
258
  </box>
272
259
  )
273
260
  }
@@ -277,34 +264,25 @@ export const CARD_COLOR: Record<Card["status"], string> = { ok: theme.ok, fail:
277
264
  export function ResultCard({ card }: { card: Card }) {
278
265
  const color = CARD_COLOR[card.status]
279
266
  const lineColor = (l: string) => (/^\s*✓/.test(l) ? theme.ok : /^\s*[✗!]/.test(l) ? theme.err : /^\s*\?/.test(l) ? theme.warn : theme.muted)
267
+ // What a run used is in the footer and the reports; price talk stays out of the conversation.
268
+ const body = card.body.filter((l) => !/^\s*Usage\s{2,}/.test(l)).filter((l, i, all) => l || (i > 0 && i < all.length - 1))
280
269
  return (
281
- <box
282
- border={["left"]}
283
- borderColor={color}
284
- customBorderChars={BAR}
285
- style={{
286
- flexDirection: "column",
287
- backgroundColor: theme.panel,
288
- paddingLeft: 2,
289
- paddingRight: 2,
290
- paddingTop: 1,
291
- paddingBottom: 1,
292
- marginTop: 1,
293
- marginBottom: 1,
294
- flexShrink: 0,
295
- }}
296
- >
297
- <box style={{ flexDirection: "row", justifyContent: "space-between" }}>
270
+ <box style={{ flexDirection: "column", flexShrink: 0 }}>
271
+ <Marked mark={MARK.item} color={color} gap>
298
272
  <text>
299
- <span fg={color}>{`${CARD_MARK[card.status]} `}</span>
300
- <span fg={theme.text}>{card.title}</span>
273
+ <span fg={theme.text}>
274
+ <strong>{card.title}</strong>
275
+ </span>
276
+ {card.stats ? <span fg={theme.faint}>{` ${card.stats}`}</span> : null}
301
277
  </text>
302
- {card.stats ? <text fg={theme.muted}>{card.stats}</text> : null}
303
- </box>
304
- {card.body.length ? <box style={{ height: 1 }} /> : null}
305
- {card.body.map((l, i) => (
306
- <text key={i}>{l ? <Linked text={l} color={lineColor(l)} /> : " "}</text>
307
- ))}
278
+ </Marked>
279
+ {body.length ? (
280
+ <Under first>
281
+ {body.map((l, i) => (
282
+ <text key={i}>{l ? <Linked text={l} color={lineColor(l)} /> : " "}</text>
283
+ ))}
284
+ </Under>
285
+ ) : null}
308
286
  </box>
309
287
  )
310
288
  }
@@ -401,64 +379,91 @@ function ReplyBody({ text }: { text: string }) {
401
379
  )
402
380
  }
403
381
 
404
- export function TranscriptLine({ line }: { line: Line }) {
382
+ /** A progress line that starts a phase of the work (a browser run, the handover to the coding agent, a test case). */
383
+ const PHASE = /^(Browser: |Handing over|\d+\/\d+ {2})/
384
+
385
+ /** Lines that start an entry ("⏺" or ">"); the rest are what came of the entry before them, under it. */
386
+ function isHead(line: Line): boolean {
387
+ return ["user", "reply", "tool", "file", "card", "summary", "suitesummary"].includes(line.kind) || (line.kind === "code" && PHASE.test(line.text))
388
+ }
389
+
390
+ /** A tool call's name, and its arguments in brackets: "Azure DevOps · pipelines build", "(project QI-Claims)". */
391
+ function callParts(text: string): [string, string] {
392
+ const at = text.indexOf(" (")
393
+ return at > 0 && text.endsWith(")") ? [text.slice(0, at), text.slice(at + 1)] : [text, ""]
394
+ }
395
+
396
+ /**
397
+ * One line of the conversation. `prev` decides the layout: a call right after another call keeps no gap between them,
398
+ * and the first line under an entry gets "⎿".
399
+ */
400
+ export function TranscriptLine({ line, prev }: { line: Line; prev?: Line }) {
401
+ // An entry with nothing to say is left out rather than shown as a bare mark.
402
+ if (!line.text.trim() && !line.card && !line.summary && !line.suite && line.kind !== "sys") return null
405
403
  if (line.kind === "card" && line.card) return <ResultCard card={line.card} />
406
404
  if (line.kind === "summary" && line.summary) return <SummaryCard summary={line.summary} />
407
405
  if (line.kind === "suitesummary" && line.suite) return <SuiteCard suite={line.suite} />
406
+ if (line.kind === "user") {
407
+ return (
408
+ <Marked mark={MARK.user} color={theme.muted} gap>
409
+ <text fg={theme.muted}>{line.text}</text>
410
+ </Marked>
411
+ )
412
+ }
408
413
  if (line.kind === "reply") {
409
- const color = line.outcome === "failed" ? theme.err : line.outcome === "needs_input" ? theme.warn : theme.ok
414
+ const color = line.outcome === "failed" ? theme.err : line.outcome === "needs_input" ? theme.warn : theme.text
410
415
  return (
411
- <box
412
- border={["left"]}
413
- borderColor={color}
414
- customBorderChars={BAR}
415
- style={{ backgroundColor: theme.panel, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1, marginTop: 1, marginBottom: 1, flexShrink: 0 }}
416
- >
416
+ <Marked mark={MARK.item} color={color} gap>
417
417
  <ReplyBody text={line.text} />
418
- </box>
418
+ </Marked>
419
419
  )
420
420
  }
421
- if (line.kind === "tool") return <text fg={theme.faint}>{` ⚙ ${line.text}`}</text>
422
- if (line.kind === "file") return <text fg={theme.accent}>{` ✎ ${line.text}`}</text>
423
- if (line.kind === "code") return <text fg={theme.memory}>{` ▸ ${line.text}`}</text>
424
- if (line.kind === "user") {
421
+ if (line.kind === "tool" || line.kind === "file") {
422
+ // Calls in a row read as one block; a gap only before the first.
423
+ const [name, args] = callParts(line.text)
425
424
  return (
426
- <box
427
- border={["left"]}
428
- borderColor={theme.accent}
429
- customBorderChars={BAR}
430
- style={{
431
- backgroundColor: theme.panel,
432
- paddingLeft: 2,
433
- paddingTop: 1,
434
- paddingBottom: 1,
435
- marginTop: 1,
436
- marginBottom: 1,
437
- flexShrink: 0,
438
- }}
439
- >
440
- <text fg={theme.text}>{line.text}</text>
441
- </box>
425
+ <Marked mark={MARK.item} color={line.kind === "file" ? theme.accent : theme.ok} gap={!prev || !(prev.kind === "tool" || prev.kind === "file")}>
426
+ <text>
427
+ <span fg={theme.text}>
428
+ <strong>{name}</strong>
429
+ </span>
430
+ {args ? <span fg={theme.faint}>{` ${args}`}</span> : null}
431
+ </text>
432
+ </Marked>
442
433
  )
443
434
  }
444
- if (line.kind === "timing") return <text fg={theme.warn}>{` + ${line.text}`}</text>
445
- if (line.kind === "memory") return <text fg={theme.memory}>{` ↻ ${line.text}`}</text>
446
- if (line.kind === "thought") return <text fg={theme.thought}>{` ${line.text}`}</text>
447
- if (line.kind === "ok") {
435
+ if (line.kind === "code" && PHASE.test(line.text)) {
448
436
  return (
437
+ <Marked mark={MARK.item} color={theme.memory} gap>
438
+ <text fg={theme.text}>
439
+ <strong>{line.text}</strong>
440
+ </text>
441
+ </Marked>
442
+ )
443
+ }
444
+ // Everything else is what came of the entry above it.
445
+ const first = !prev || isHead(prev)
446
+ const body =
447
+ line.kind === "ok" ? (
449
448
  <text>
450
- <span fg={theme.ok}>{" ✓ "}</span>
449
+ <span fg={theme.ok}>{"✓ "}</span>
451
450
  <span fg={theme.text}>{line.text}</span>
452
451
  </text>
453
- )
454
- }
455
- if (line.kind === "err") {
456
- return (
452
+ ) : line.kind === "err" ? (
457
453
  <text>
458
- <span fg={theme.err}>{" ✗ "}</span>
454
+ <span fg={theme.err}>{"✗ "}</span>
459
455
  <span fg={theme.text}>{line.text}</span>
460
456
  </text>
457
+ ) : line.kind === "thought" ? (
458
+ <text fg={theme.thought}>{line.text}</text>
459
+ ) : line.kind === "timing" ? (
460
+ <text fg={theme.faint}>{line.text}</text>
461
+ ) : line.kind === "code" ? (
462
+ <text fg={theme.memory}>{line.text}</text>
463
+ ) : line.kind === "memory" ? (
464
+ <text fg={theme.memory}>{`↻ ${line.text}`}</text>
465
+ ) : (
466
+ <text fg={theme.muted}>{line.text || " "}</text>
461
467
  )
462
- }
463
- return <text fg={theme.muted}>{` ${line.text || " "}`}</text>
468
+ return <Under first={first}>{body}</Under>
464
469
  }