@capagents/forgeqe 1.0.9 → 1.1.0

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.
@@ -11,32 +11,84 @@ import { allStatuses, configuredSystems, SYSTEM_LABEL, type Probes, MACHINE } fr
11
11
  * in the folder, so it changes as the setup changes.
12
12
  */
13
13
 
14
- /** "C# · .NET solution · Playwright Test": what the folder's own files say it is built with. */
15
- export function projectStack(cwd: string): string[] {
16
- let names: string[] = []
14
+ /** Files a stack is read from, at the top and one or two folders down (tests/Shop.Tests/Shop.Tests.csproj). */
15
+ function projectFiles(cwd: string, match: RegExp, depth = 2): string[] {
16
+ const out: string[] = []
17
+ const walk = (dir: string, level: number) => {
18
+ let names: string[] = []
19
+ try {
20
+ names = readdirSync(dir)
21
+ } catch {
22
+ return
23
+ }
24
+ for (const n of names) {
25
+ if (n.startsWith(".") || n === "node_modules" || n === "bin" || n === "obj") continue
26
+ const full = path.join(dir, n)
27
+ if (match.test(n)) out.push(full)
28
+ else if (level < depth) {
29
+ try {
30
+ if (statSync(full).isDirectory()) walk(full, level + 1)
31
+ } catch {}
32
+ }
33
+ if (out.length > 20) return
34
+ }
35
+ }
36
+ walk(cwd, 0)
37
+ return out
38
+ }
39
+
40
+ const read = (file: string) => {
17
41
  try {
18
- names = readdirSync(cwd)
42
+ return readFileSync(file, "utf8")
19
43
  } catch {
20
- return []
44
+ return ""
21
45
  }
22
- const has = (re: RegExp) => names.some((n) => re.test(n))
23
- const out: string[] = []
24
- if (has(/^playwright\.config\.(ts|js|mjs|cjs)$/)) out.push("Playwright Test")
25
- if (has(/^cypress\.config\.(ts|js|mjs|cjs)$/)) out.push("Cypress")
26
- if (has(/\.sln$/) || has(/\.csproj$/)) out.push(".NET")
27
- if (has(/^pom\.xml$/)) out.push("Maven")
28
- if (has(/^build\.gradle(\.kts)?$/)) out.push("Gradle")
29
- if (has(/^(pytest\.ini|conftest\.py)$/) || (has(/^pyproject\.toml$/) && !out.length)) out.push("Python")
30
- if (has(/^package\.json$/) && !out.some((s) => s === "Playwright Test" || s === "Cypress")) out.push("Node")
31
- return out.slice(0, 2)
32
46
  }
33
47
 
34
- /** The project line: folder, stack, the systems ForgeQE works with here, and the model. */
35
- export function projectLine(cfg: AppConfig | null, cwd: string, model: string): string[] {
36
- const parts = [path.basename(cwd), ...projectStack(cwd)]
37
- if (cfg) parts.push(...[...configuredSystems(cfg)].filter((s) => s !== "xray" || !configuredSystems(cfg).has("jira")).map((s) => SYSTEM_LABEL[s]))
38
- if (model) parts.push(model)
39
- return parts.filter(Boolean)
48
+ /**
49
+ * "C# · NUnit · Playwright": the language, test framework, and browser library the project's own files declare
50
+ * (project files, package.json, Python requirements). Read from the files, never guessed from folder names.
51
+ */
52
+ export function projectStack(cwd: string): string[] {
53
+ const csproj = projectFiles(cwd, /\.csproj$/).map(read).join("\n")
54
+ if (csproj) {
55
+ const framework = /PackageReference Include="NUnit"/i.test(csproj) ? "NUnit" : /PackageReference Include="xunit/i.test(csproj) ? "xUnit" : /PackageReference Include="MSTest/i.test(csproj) ? "MSTest" : ""
56
+ const browser = /Microsoft\.Playwright/i.test(csproj) ? "Playwright" : /Selenium\.WebDriver/i.test(csproj) ? "Selenium" : /Reqnroll|SpecFlow/i.test(csproj) ? "BDD" : ""
57
+ return ["C#", framework, browser].filter(Boolean)
58
+ }
59
+ const pkg = read(path.join(cwd, "package.json"))
60
+ if (pkg) {
61
+ const lang = existsSync(path.join(cwd, "tsconfig.json")) ? "TypeScript" : "JavaScript"
62
+ const runner = /"@playwright\/test"/.test(pkg) ? "Playwright Test" : /"cypress"/.test(pkg) ? "Cypress" : /"webdriverio"|"@wdio\//.test(pkg) ? "WebdriverIO" : /"vitest"/.test(pkg) ? "Vitest" : /"jest"/.test(pkg) ? "Jest" : ""
63
+ return [lang, runner].filter(Boolean)
64
+ }
65
+ const py = ["requirements.txt", "pyproject.toml", "setup.cfg"].map((f) => read(path.join(cwd, f))).join("\n")
66
+ if (py.trim() || existsSync(path.join(cwd, "pytest.ini")) || existsSync(path.join(cwd, "conftest.py"))) {
67
+ return ["Python", /pytest/i.test(py) || existsSync(path.join(cwd, "pytest.ini")) ? "pytest" : "", /playwright/i.test(py) ? "Playwright" : /selenium/i.test(py) ? "Selenium" : ""].filter(Boolean)
68
+ }
69
+ const java = [read(path.join(cwd, "pom.xml")), read(path.join(cwd, "build.gradle")), read(path.join(cwd, "build.gradle.kts"))].join("\n")
70
+ if (java.trim()) return ["Java", /junit-jupiter|junit/i.test(java) ? "JUnit" : /testng/i.test(java) ? "TestNG" : "", /playwright/i.test(java) ? "Playwright" : /selenium/i.test(java) ? "Selenium" : ""].filter(Boolean)
71
+ return []
72
+ }
73
+
74
+ export type Fact = { text: string; tone: "name" | "plain" | "warn" }
75
+
76
+ /**
77
+ * The project line: the folder, its stack, each system ForgeQE works with here and how it is signed in ("Azure DevOps
78
+ * (PAT)"), and the model; or, where nothing is set up, that this folder has no forgeqe.yaml.
79
+ */
80
+ export function projectLine(cfg: AppConfig | null, cwd: string, opts: { model: string; signIns?: Record<string, string> }): Fact[] {
81
+ const facts: Fact[] = [{ text: path.basename(cwd), tone: "name" }, ...projectStack(cwd).map((t) => ({ text: t, tone: "plain" as const }))]
82
+ if (!cfg) return facts
83
+ if (!cfg.configPath) return [...facts, { text: "no forgeqe.yaml here", tone: "warn" }]
84
+ const systems = [...configuredSystems(cfg)].filter((s) => s !== "xray" || !configuredSystems(cfg).has("jira"))
85
+ for (const s of systems) {
86
+ const how = opts.signIns?.[SYSTEM_LABEL[s]]
87
+ facts.push({ text: how ? `${SYSTEM_LABEL[s]} (${how})` : SYSTEM_LABEL[s], tone: "plain" })
88
+ }
89
+ const live = cfg.llm.provider !== "none" && cfg.llm.provider !== "mock"
90
+ facts.push(live && opts.model ? { text: opts.model, tone: "plain" } : { text: "no model", tone: "warn" })
91
+ return facts
40
92
  }
41
93
 
42
94
  const ALL_SYSTEMS = Object.values(SYSTEM_LABEL)
@@ -61,8 +113,9 @@ const START_LABEL: Record<string, string> = {
61
113
  "browser-goals": "Try a flow in the browser",
62
114
  }
63
115
 
64
- export function startActions(cfg: AppConfig | null, limit = 4, probes: Probes = MACHINE): StartAction[] {
65
- if (!cfg) return []
116
+ export function startActions(cfg: AppConfig | null, limit = 4, probes: Probes = MACHINE, hasRuns = true): StartAction[] {
117
+ // Without a live model nothing on the list can run: the start screen offers Get started instead.
118
+ if (!cfg || cfg.llm.provider === "none" || cfg.llm.provider === "mock") return []
66
119
  const statuses = allStatuses(cfg, probes)
67
120
  const systems = [...configuredSystems(cfg)].map((x) => SYSTEM_LABEL[x])
68
121
  const out: StartAction[] = []
@@ -70,9 +123,12 @@ export function startActions(cfg: AppConfig | null, limit = 4, probes: Probes =
70
123
  const c = CAPABILITIES.find((x) => x.id === id)
71
124
  if (!c || statuses.get(id)?.state === "needs_setup") continue
72
125
  // An example in the words of this setup: one that names a system that is set up, else one that names none.
73
- const named = c.examples.find((e) => systems.some((n) => e.includes(n)))
74
- const neutral = c.examples.find((e) => !ALL_SYSTEMS.some((n) => e.includes(n)))
75
- const example = named || neutral || c.examples[0] || c.title
126
+ // Not an example that builds on a run when there is none to build on.
127
+ const usable = c.examples.filter((e) => hasRuns || !/\blast run\b|\bthe run\b|that run/i.test(e))
128
+ if (!usable.length) continue
129
+ const named = usable.find((e) => systems.some((n) => e.includes(n)))
130
+ const neutral = usable.find((e) => !ALL_SYSTEMS.some((n) => e.includes(n)))
131
+ const example = named || neutral || usable[0]!
76
132
  out.push({ id, label: START_LABEL[id] || c.title, detail: example, request: example })
77
133
  if (out.length === limit) break
78
134
  }
@@ -91,9 +147,11 @@ export function recentWork(outDir: string, limit = 3): RecentItem[] {
91
147
  if (!statSync(dir).isDirectory()) continue
92
148
  const when = statSync(dir).mtime
93
149
  if (existsSync(path.join(dir, "run.json"))) {
94
- const run = JSON.parse(readFileSync(path.join(dir, "run.json"), "utf8")) as { goal?: string; status?: string }
150
+ // The engine writes success; ForgeQE's own records write status. Either says how the run ended.
151
+ const run = JSON.parse(readFileSync(path.join(dir, "run.json"), "utf8")) as { goal?: string; status?: string; success?: boolean }
95
152
  const report = path.join(dir, "report.html")
96
- items.push({ kind: "run", title: String(run.goal || name), dir, open: existsSync(report) ? report : dir, when, status: run.status })
153
+ const status = run.status || (run.success === true ? "passed" : run.success === false ? "fail" : undefined)
154
+ items.push({ kind: "run", title: String(run.goal || name), dir, open: existsSync(report) ? report : dir, when, ...(status ? { status } : {}) })
97
155
  } else if (existsSync(path.join(dir, "report.md"))) {
98
156
  const first = readFileSync(path.join(dir, "report.md"), "utf8").split("\n")[0] || ""
99
157
  const html = path.join(dir, "report.html")
@@ -126,3 +184,18 @@ export function setupSentence(missing: string[], room: number): string {
126
184
  const named = `${listWords(missing)} ${missing.length === 1 ? "is" : "are"} not set up`
127
185
  return named.length <= room ? named : `${missing.length} ${missing.length === 1 ? "item is" : "items are"} not set up`
128
186
  }
187
+
188
+ /** What to do first where little is set up: each step runs a command or says how, in the order they are needed. */
189
+ export type GetStarted = { id: string; label: string; detail: string; command?: string; fix?: string }
190
+
191
+ export function getStarted(cfg: AppConfig | null, setup: { id: string; done: boolean; fix?: string; command?: string }[]): GetStarted[] {
192
+ if (!cfg) return []
193
+ const out: GetStarted[] = []
194
+ if (!cfg.configPath) out.push({ id: "init", label: "Set up ForgeQE in this folder", detail: "writes forgeqe.yaml, llms.json, prompts, skills", command: "/init" })
195
+ const missing = (id: string) => setup.find((i) => i.id === id && !i.done)
196
+ const model = missing("model")
197
+ if (model) out.push({ id: "model", label: "Add a model", detail: "an API key or Microsoft Entra ID, in llms.json", fix: model.fix })
198
+ const system = missing("system")
199
+ if (system) out.push({ id: "system", label: "Sign in to Azure DevOps or Jira", detail: "test cases, stories, pipelines", command: "/auth" })
200
+ return out.slice(0, 4)
201
+ }
package/src/tui/next.ts CHANGED
@@ -26,7 +26,7 @@ export function nextSteps(p: Produced, opts: { testsOn: boolean; followUps?: Fol
26
26
  steps.push({ label: "Rerun the cases that failed", run: { kind: "insert", text: "Run the cases that failed in the last suite again" } })
27
27
  } else if (run) {
28
28
  const report = path.join(run.runDir, "report.html")
29
- if (run.status === "complete" && !p.tests.some((t) => t.runDir === run.runDir)) steps.push({ label: "Write a test for this run", run: { kind: "insert", text: "Write a test for the last run" } })
29
+ if (opts.testsOn && run.status === "complete" && !p.tests.some((t) => t.runDir === run.runDir)) steps.push({ label: "Write a test for this run", run: { kind: "insert", text: "Write a test for the last run" } })
30
30
  if (run.status !== "complete") {
31
31
  steps.push({ label: "Why did it fail?", run: { kind: "insert", text: "Why did the last run fail, and what should change?" } })
32
32
  steps.push({ label: "Run it again, visible", run: { kind: "insert", text: "Run the last goal again in a visible browser" } })
@@ -34,6 +34,7 @@ export function paletteItems(opts: { running: boolean; headed: boolean; detailsF
34
34
  items.push({ id: "shortcuts", group: "Action", title: "Keyboard shortcuts", keys: "?", run: { kind: "action", id: "shortcuts" } })
35
35
  command("features", "What ForgeQE can do here", "/features", "and what each feature still needs")
36
36
  command("auth", "Sign in to a system", "/auth", "Azure DevOps, GitHub, Jira, Confluence, TestRail, Zephyr, qTest, Xray")
37
+ command("init", "Set up ForgeQE in this folder", "/init", "forgeqe.yaml, llms.json, prompts, skills, knowledge")
37
38
  command("auth-status", "What is signed in", "/auth status")
38
39
  command("headed", opts.headed ? "Hide the browser window" : "Show the browser window", `/headed ${opts.headed ? "off" : "on"}`)
39
40
  command("details", opts.detailsFull ? "Show progress only" : "Show everything the agents do", `/details ${opts.detailsFull ? "off" : "on"}`)
@@ -18,9 +18,21 @@ export type RefineChoice = (typeof REFINE_CHOICES)[number]["id"]
18
18
 
19
19
  const COLOR = theme.memory
20
20
 
21
+ /** A fact as written in context and in grounded, compared without case, spaces, or a closing full stop. */
22
+ const norm = (s: string) => s.trim().replace(/\.$/, "").replace(/\s+/g, " ").toLowerCase()
23
+
24
+ /** The choice the panel starts on: Edit while Refine still has a question, so an unclear request is not sent. */
25
+ export function initialChoice(missing: string[]): number {
26
+ return REFINE_CHOICES.findIndex((c) => c.id === (missing.length ? "edit" : "use"))
27
+ }
28
+
21
29
  export function RefinePanel(props: { state: RefineState; width: number; onChoose: (id: RefineChoice) => void; onHover: (i: number) => void }) {
22
30
  const { state, width } = props
23
31
  const title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : " ✦ Refined request "
32
+ // Facts Refine checked that are also context lines are marked there, not repeated under Checked.
33
+ const contextLines = state.phase === "done" ? state.result.refined.split("\n").filter((l) => l.startsWith("- ")).map((l) => norm(l.slice(2))) : []
34
+ const inContext = new Set(contextLines)
35
+ const checked = new Set(state.phase === "done" ? state.result.grounded.filter((g) => g.checked).map((g) => norm(g.fact)).filter((f) => inContext.has(f)) : [])
24
36
  const section = (label: string, color: string, mark: string, items: string[]) =>
25
37
  items.length ? (
26
38
  <box style={{ flexDirection: "column", marginTop: 1 }}>
@@ -52,6 +64,15 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
52
64
  {/* The request as it is sent: its labelled parts (Goal, Context, Steps, Rules, Result) stand out. */}
53
65
  {state.result.refined.split("\n").map((line, i) => {
54
66
  const label = /^(Goal|Context|Steps|Rules|Result):/.exec(line)
67
+ // A context line Refine checked against the systems: ✓ in place of its dash, so it is not listed twice.
68
+ if (line.startsWith("- ") && checked.has(norm(line.slice(2)))) {
69
+ return (
70
+ <text key={i}>
71
+ <span fg={theme.ok}>{"✓ "}</span>
72
+ <span fg={theme.text}>{line.slice(2)}</span>
73
+ </text>
74
+ )
75
+ }
55
76
  return label ? (
56
77
  <text key={i}>
57
78
  <span fg={COLOR}>{label[0]}</span>
@@ -63,7 +84,7 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
63
84
  </text>
64
85
  )
65
86
  })}
66
- {section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked).map((g) => g.fact))}
87
+ {section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked && !inContext.has(norm(g.fact))).map((g) => g.fact))}
67
88
  {section("Assumed", theme.warn, "~", [...state.result.grounded.filter((g) => !g.checked).map((g) => g.fact), ...state.result.assumed])}
68
89
  {section("Missing — answer these in your request", theme.err, "?", state.result.missing)}
69
90
  </>
@@ -88,7 +109,7 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
88
109
  {state.phase === "done" ? (
89
110
  <>
90
111
  <span fg={theme.text}>{"enter"}</span>
91
- <span fg={theme.muted}>{" use "}</span>
112
+ <span fg={theme.muted}>{` ${REFINE_CHOICES[state.choice]!.id === "keep" ? "keep mine" : REFINE_CHOICES[state.choice]!.id} `}</span>
92
113
  <span fg={theme.text}>{"e"}</span>
93
114
  <span fg={theme.muted}>{" edit "}</span>
94
115
  <span fg={theme.text}>{"esc"}</span>
@@ -17,7 +17,7 @@ export const SHORTCUTS: { group: string; keys: [string, string][] }[] = [
17
17
  keys: [
18
18
  ["ctrl+p", "search: actions, commands, profiles, examples"],
19
19
  ["shift+tab", "move between the chat and the plan, activity, and files panels"],
20
- ["ctrl+o", "chat or details, on a narrow terminal"],
20
+ ["ctrl+o", "open or close the panels: plan, activity, files, context, tokens"],
21
21
  ["alt+1 to alt+4", "a start action, or a next step"],
22
22
  ],
23
23
  },
@@ -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
  }