@capagents/forgeqe 1.0.5 → 1.0.7

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.
package/HOW_TO_USE.md CHANGED
@@ -66,6 +66,7 @@ Everything ForgeQE can do, by area. ForgeQE's agent answers "can you…?" questi
66
66
  | Release | **Requirement coverage and release readiness**: Gives requirement coverage and a GO / GO WITH RISKS / NO-GO verdict for a sprint, area, epic, or stories, from test cases, results, bugs, and flaky tests; can publish it to the wiki. | `release_readiness` | [docs](#requirement-coverage-and-release-readiness) |
67
67
  | Operations | **Scheduled monitoring with alerts**: Runs checks on a schedule and alerts Teams or email when one keeps failing, and again when it recovers. | `monitor` | [docs](#9-monitoring) |
68
68
  | Operations | **Teams and email notifications**: Tells the team in Microsoft Teams channels or by email when a suite fails or passes, when a release readiness verdict is in, and when the domain evaluation falls below its gate, with the failing cases, their bugs, and links to the report and the CI build. Each channel picks its events; the agent also posts a summary to a channel when asked. | `notify` | [docs](#17-notifications) |
69
+ | Release | **Reports you can share**: Writes up what ForgeQE's agent found, such as release status across projects, open defects, or pipeline health, as a Markdown file and an HTML page in the reports folder, with a summary in the chat and links to open them. | `write_report` | [docs](#reports-from-the-agent) |
69
70
  | Operations | **CI pipelines**: Writes a GitHub Actions or Azure Pipelines workflow that runs ForgeQE, stores the secrets it needs, pushes a branch, and makes the pipeline green. | `push_branch`, `set_ci_secret` | [docs](#8-pipelines) |
70
71
  | Systems | **Work with Azure DevOps, Jira, and Confluence**: Reads anything in the configured systems (work items, issues, queries, boards, pages), makes changes the user asks for, and builds Azure DevOps dashboards and wiki pages. | `query_api`, `read_confluence`, `create_ado_dashboard`, `write_ado_wiki_page` | [docs](README.md#azure-devops-jira-and-other-systems) |
71
72
  | Setup | **Setup, skills, and documentation**: Shows the model profiles and MCP servers, installs or repairs the browser engine, creates a forgeqe.yaml, manages skills, and answers questions about ForgeQE from its documentation and this list. | `forgeqe_setup`, `capabilities` | [docs](#2-scaffold-config) |
@@ -366,6 +367,18 @@ While a run is going, the bar under the prompt shows a spinner, the tokens used,
366
367
 
367
368
  The text is the agent's own result. Failed runs show `✗ Task failed` with the reason, and stopped runs show `■ Run stopped`. `forgeqe run` prints the same summary at the end.
368
369
 
370
+ ### Reports from the agent
371
+
372
+ Ask for a report and ForgeQE's agent writes one you can share: "create a release report for all projects", "write up the open defects", "summarise this sprint's test results as a report". It gathers the facts with its tools (Azure DevOps, Jira, the runs it made), then saves the report with `write_report` as a Markdown file and an HTML page in the reports folder (`export.out_dir`, `.forgeqe/reports/` by default):
373
+
374
+ ```
375
+ .forgeqe/reports/release-status-all-projects-20261009-001512/
376
+ report.md # to edit, paste into a wiki, or attach
377
+ report.html # to open or share; light and dark, tables scroll on a phone
378
+ ```
379
+
380
+ The chat gets a short summary and a card with both paths as links, and **Open the report** appears as a next step. The report holds only what the agent read in that request. To post it, ask for it ("post the report to the release channel") once `notify.channels` is set up; the reports folder is linked when it is served (`notify.report_url`).
381
+
369
382
  ### The screen
370
383
 
371
384
  On a terminal 120 columns wide or more, the shell splits in two once you send a request:
@@ -375,6 +388,12 @@ On a terminal 120 columns wide or more, the shell splits in two once you send a
375
388
 
376
389
  On a narrower terminal the chat fills the screen, and Ctrl-O switches between the chat and the same panels.
377
390
 
391
+ **Buttons.** Under the prompt: **✦ Refine** (Ctrl-R; it fills in once there is text to refine) and **⌕ Search** (Ctrl-P, the command palette); while a request runs, **■ Stop** (Esc) takes Refine's place. In the status line above the panels: **▦ Panels** (Shift-Tab) and **⤢ Wide chat**, which gives the conversation the whole width for reading a long answer; **◧ Split view**, under the prompt, brings the panels back. On a narrow terminal, **◧ Details** (Ctrl-O) and **⇄ Profile** (Tab) are under the prompt. Each is clickable.
392
+
393
+ **Progress.** While a request runs, the status line above the panels shows a bar for how far the agent's plan has got (`███████░░░░░ 2/3`), or the suite's cases while a suite runs.
394
+
395
+ **Replies.** The agent's answers lead with the answer, then short headed sections; lists keep their bullets in a column, so a wrapped line stays under its text instead of running back to the margin.
396
+
378
397
  **Moving between panels.** Shift-Tab (or a click) moves the keys from the prompt to Plan, Activity, then Files; the focused panel's border lights up and shows its keys. Esc, or typing, goes back to the prompt.
379
398
 
380
399
  | Panel | Keys |
@@ -393,7 +412,7 @@ On a narrower terminal the chat fills the screen, and Ctrl-O switches between th
393
412
 
394
413
  **Refine.** Ctrl-R (or *Refine my request* in the palette) turns what you typed into a precise request before it is sent. A read-only agent checks it against this setup: what ForgeQE can do here, the systems that are set up, the project's sprints and test cases, and recent runs. It shows the refined request with what it **checked** ✓, what it **assumed** ~, and what is **missing** ?. Enter uses it, `e` puts it in the prompt to edit, Esc keeps yours. Refine never runs anything, writes files, or changes a work item.
395
414
 
396
- **Next steps.** When a request finishes, up to three suggestions appear above the prompt, such as *Write a test for this run*, *Why did it fail?*, *Open the report*, or *Rerun the cases that failed*. Click one or press Alt-1 to Alt-3; suggestions that start work are put in the prompt first.
415
+ **Next steps.** When a request finishes, up to three suggestions appear above the prompt: from what it produced (*Open the report*, *Write a test for this run*, *Why did it fail?*, *Rerun the cases that failed*) and from the agent's answer, which suggests the follow-ups you are likely to want (*Write a release report*, *Show the failed CD logs*). Click one or press Alt-1 to Alt-3; suggestions that start work are put in the prompt first, to adjust and send.
397
416
 
398
417
  | Key | Action |
399
418
  |-----|--------|
@@ -286,7 +286,7 @@ forgeqe docs --copy ./docs # copy the guide, the architecture page, and the re
286
286
  <div class="area"><a class="area-name" href="#area-pull-requests"><span class="mono">Pull requests</span></a><ul class="tiles"><li><a class="tile" href="#cap-pr-review"><b>Review pull requests</b><span>Review PR 42 and focus on error handling</span></a></li><li><a class="tile" href="#cap-pr-impact"><b>Pull request impact and test selection</b><span>What does PR 42 impact?</span></a></li></ul></div>
287
287
  <div class="area"><a class="area-name" href="#area-api"><span class="mono">API</span></a><ul class="tiles"><li><a class="tile" href="#cap-api-testing"><b>API and contract testing</b><span>Turn the last run's API calls into tests</span></a></li></ul></div>
288
288
  <div class="area"><a class="area-name" href="#area-quality"><span class="mono">Quality</span></a><ul class="tiles"><li><a class="tile" href="#cap-accessibility-performance"><b>Accessibility and page performance</b><span>Is the checkout page accessible?</span></a></li><li><a class="tile" href="#cap-visual-comparison"><b>Visual comparison with baselines</b><span>What changed visually since the last run?</span></a></li></ul></div>
289
- <div class="area"><a class="area-name" href="#area-release"><span class="mono">Release</span></a><ul class="tiles"><li><a class="tile" href="#cap-release-readiness"><b>Requirement coverage and release readiness</b><span>Are we ready to release?</span></a></li></ul></div>
289
+ <div class="area"><a class="area-name" href="#area-release"><span class="mono">Release</span></a><ul class="tiles"><li><a class="tile" href="#cap-release-readiness"><b>Requirement coverage and release readiness</b><span>Are we ready to release?</span></a></li><li><a class="tile" href="#cap-reports"><b>Reports you can share</b><span>Create a release report for all projects</span></a></li></ul></div>
290
290
  <div class="area"><a class="area-name" href="#area-operations"><span class="mono">Operations</span></a><ul class="tiles"><li><a class="tile" href="#cap-monitoring"><b>Scheduled monitoring with alerts</b><span>Check the login flow every 15 minutes and alert Teams</span></a></li><li><a class="tile" href="#cap-notifications"><b>Teams and email notifications</b><span>Post the results to the QA channel</span></a></li><li><a class="tile" href="#cap-pipelines"><b>CI pipelines</b><span>Add a GitHub Actions workflow that runs tests/cases.yaml on every PR, and make it green</span></a></li></ul></div>
291
291
  <div class="area"><a class="area-name" href="#area-systems"><span class="mono">Systems</span></a><ul class="tiles"><li><a class="tile" href="#cap-systems"><b>Work with Azure DevOps, Jira, and Confluence</b><span>Show the open bugs in this sprint</span></a></li></ul></div>
292
292
  <div class="area"><a class="area-name" href="#area-setup"><span class="mono">Setup</span></a><ul class="tiles"><li><a class="tile" href="#cap-setup-help"><b>Setup, skills, and documentation</b><span>What can you do?</span></a></li></ul></div>
@@ -550,6 +550,9 @@ Add a GitHub Actions workflow that runs tests/cases.yaml on every PR, and make i
550
550
  <li><strong>Right (60%)</strong>: a status line with who is working, for how long, and a <strong>■ Stop</strong> button (a running suite adds a progress bar, <code>██████░░░░░░ 5/8</code>); then <strong>Plan</strong> (the agent's to-do list, then each browser step or suite case with ✓, ✗ and why, ◐ running), <strong>Activity</strong> (every tool call, browser step, and the agents' reasoning), <strong>Files</strong> (each file read ◉, written ✚, or edited ✎, newest first), <strong>Context</strong> (how full the model's context window is, and the skills, product knowledge, domain, and MCP servers in it), and <strong>Tokens</strong> (total, input, output, cached; no prices, which are in reports and <code>forgeqe models</code>).</li>
551
551
  </ul>
552
552
  <p>On a narrower terminal the chat fills the screen, and Ctrl-O switches between the chat and the same panels.</p>
553
+ <p><strong>Buttons.</strong> Under the prompt: <strong>✦ Refine</strong> (Ctrl-R; it fills in once there is text to refine) and <strong>⌕ Search</strong> (Ctrl-P, the command palette); while a request runs, <strong>■ Stop</strong> (Esc) takes Refine's place. In the status line above the panels: <strong>▦ Panels</strong> (Shift-Tab) and <strong>⤢ Wide chat</strong>, which gives the conversation the whole width for reading a long answer; <strong>◧ Split view</strong>, under the prompt, brings the panels back. On a narrow terminal, <strong>◧ Details</strong> (Ctrl-O) and <strong>⇄ Profile</strong> (Tab) are under the prompt. Each is clickable.</p>
554
+ <p><strong>Progress.</strong> While a request runs, the status line above the panels shows a bar for how far the agent's plan has got (<code>███████░░░░░ 2/3</code>), or the suite's cases while a suite runs.</p>
555
+ <p><strong>Replies.</strong> The agent's answers lead with the answer, then short headed sections; lists keep their bullets in a column, so a wrapped line stays under its text instead of running back to the margin.</p>
553
556
  <p><strong>Moving between panels.</strong> Shift-Tab (or a click) moves the keys from the prompt to Plan, Activity, then Files; the focused panel's border lights up and shows its keys. Esc, or typing, goes back to the prompt.</p>
554
557
  <div class="tbl"><table>
555
558
  <thead>
@@ -566,7 +569,7 @@ Add a GitHub Actions workflow that runs tests/cases.yaml on every PR, and make i
566
569
  <p><strong>Command palette.</strong> Ctrl-P opens a palette of every action, command, profile, and example request; type a few letters to find one (&quot;rr&quot; finds <em>Refine my request</em>, &quot;auth&quot; finds the sign-in commands), ↑/↓ to choose, Enter to run it. Example requests are put in the prompt for you to edit, never sent straight away.</p>
567
570
  <p><strong><code>/</code> and <code>@</code> in the prompt.</strong> Typing <code>/</code> lists the commands that match; typing <code>@</code> suggests references: recent runs (<em>last run</em>, then each by its folder), files in the repository by name or path (<code>@Login</code> finds <code>pages/Login.ts</code>), and, after <code>@</code> and a number such as <code>@1761</code>, the Azure DevOps work item with its title (read-only). Tab completes, ↑/↓ choose, Esc closes the list.</p>
568
571
  <p><strong>Refine.</strong> Ctrl-R (or <em>Refine my request</em> in the palette) turns what you typed into a precise request before it is sent. A read-only agent checks it against this setup: what ForgeQE can do here, the systems that are set up, the project's sprints and test cases, and recent runs. It shows the refined request with what it <strong>checked</strong> ✓, what it <strong>assumed</strong> ~, and what is <strong>missing</strong> ?. Enter uses it, <code>e</code> puts it in the prompt to edit, Esc keeps yours. Refine never runs anything, writes files, or changes a work item.</p>
569
- <p><strong>Next steps.</strong> When a request finishes, up to three suggestions appear above the prompt, such as <em>Write a test for this run</em>, <em>Why did it fail?</em>, <em>Open the report</em>, or <em>Rerun the cases that failed</em>. Click one or press Alt-1 to Alt-3; suggestions that start work are put in the prompt first.</p>
572
+ <p><strong>Next steps.</strong> When a request finishes, up to three suggestions appear above the prompt: from what it produced (<em>Open the report</em>, <em>Write a test for this run</em>, <em>Why did it fail?</em>, <em>Rerun the cases that failed</em>) and from the agent's answer, which suggests the follow-ups you are likely to want (<em>Write a release report</em>, <em>Show the failed CD logs</em>). Click one or press Alt-1 to Alt-3; suggestions that start work are put in the prompt first, to adjust and send.</p>
570
573
  <div class="tbl"><table>
571
574
  <thead>
572
575
  <tr><th>Key</th><th>Action</th></tr>
@@ -1559,6 +1562,28 @@ forgeqe visual approve --page /home # make this run's /home the baseline (asks
1559
1562
  </section>
1560
1563
  </div>
1561
1564
  </article>
1565
+ <article class="spec feat" id="cap-reports">
1566
+ <div class="hd"><span class="mono">Release</span><h3>Reports you can share</h3></div>
1567
+ <p class="ds">Writes up what ForgeQE's agent found, such as release status across projects, open defects, or pipeline health, as a Markdown file and an HTML page in the reports folder, with a summary in the chat and links to open them.</p>
1568
+ <div class="facts">
1569
+ <div class="fact"><span class="mono">Ask it</span><ul class="asks"><li>Create a release report for all projects</li><li>Write up the open defects as a report I can share</li></ul></div>
1570
+ <div class="fact"><span class="mono">What it needs</span><ul class="needs"><li><span class="need req">Required</span><span>A live model profile in llms.json (an API key or Microsoft Entra ID sign-in)</span></li></ul></div>
1571
+ <div class="fact"><span class="mono">Limits</span><ul class="plainlist"><li>The report holds what the agent read with its tools in this request; it does not invent figures, and it does not publish the page anywhere unless the reports folder is served (notify.report_url).</li></ul></div>
1572
+ <div class="fact"><span class="mono">Tools</span><p class="tools"><code>write_report</code></p></div>
1573
+ </div>
1574
+ <div class="detail"><span class="mono">In detail</span>
1575
+ <section class="doc" id="doc-how_to_use-reports-from-the-agent">
1576
+ <h4>Reports from the agent</h4>
1577
+ <div class="prose"><p>Ask for a report and ForgeQE's agent writes one you can share: &quot;create a release report for all projects&quot;, &quot;write up the open defects&quot;, &quot;summarise this sprint's test results as a report&quot;. It gathers the facts with its tools (Azure DevOps, Jira, the runs it made), then saves the report with <code>write_report</code> as a Markdown file and an HTML page in the reports folder (<code>export.out_dir</code>, <code>.forgeqe/reports/</code> by default):</p>
1578
+ <pre><code>.forgeqe/reports/release-status-all-projects-20261009-001512/
1579
+ report.md # to edit, paste into a wiki, or attach
1580
+ report.html # to open or share; light and dark, tables scroll on a phone
1581
+ </code></pre>
1582
+ <p>The chat gets a short summary and a card with both paths as links, and <strong>Open the report</strong> appears as a next step. The report holds only what the agent read in that request. To post it, ask for it (&quot;post the report to the release channel&quot;) once <code>notify.channels</code> is set up; the reports folder is linked when it is served (<code>notify.report_url</code>).</p>
1583
+ </div>
1584
+ </section>
1585
+ </div>
1586
+ </article>
1562
1587
  </section>
1563
1588
  <section class="chapter" id="area-operations" aria-labelledby="area-operations-h">
1564
1589
  <header class="chead"><span class="mono">11</span><h2 id="area-operations-h">Operations</h2><p>Monitoring and CI pipelines</p></header>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "ForgeQE: a browser agent CLI that runs goals and test cases in Chrome and turns passing runs into tests in your repo",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/prompts/brain.md CHANGED
@@ -163,4 +163,7 @@ Rules:
163
163
  - Never ask for an API token, API key, or password for Azure DevOps, GitHub, Jira, Confluence, TestRail, Zephyr, qTest, Xray, or a model provider in the chat. When one of them is not set up, or rejects the credentials (401, 403, "needs <NAME> in the environment"), tell the user to type /auth <system> in the shell (or run forgeqe auth login <system> in a terminal): it signs in through the browser and saves the token, and you get it on the next message. /auth <ENV_NAME> saves any other secret the same way. Website logins for browser runs are different: those go through the logins tool.
164
164
  - Never push except with push_branch, never to the default branch, and never force.
165
165
  - Stay inside the working directory, except to read ForgeQE's run and memory folders.
166
- - Replies are shown as markdown: use short lists, bold, and links where they help, and a table only for a few short columns. Keep replies short and concrete; test cases, tests, and reviews you were asked for are given in full, and only your own commentary is kept short. End every turn with the structured answer: reply (what you did and found, or your question) and outcome.
166
+ - Replies are shown as markdown in a narrow chat pane, so write for scanning: one or two plain sentences with the answer first, then a short `##` heading per group if there is more than one, bullets of one line each, and bold only for the one word or figure that matters in a line (a status, a count), never whole sentences. Use a table only for two or three short columns. Keep replies short and concrete; test cases, tests, and reviews you were asked for are given in full, and only your own commentary is kept short.
167
+ - When the user asks for a report, a write-up, or a summary to share ("create a release report", "write up the open defects"), gather the facts with your tools, then save it with write_report and reply with three to five lines of summary; the shell shows the report's paths as links, so do not repeat them. Never only paste a long report into the reply.
168
+ - Do not end a reply with "If you want, I can…" options: put up to three follow-ups the user is likely to want next in the structured answer's next (a short label, and the request as the user would type it). They are shown as buttons.
169
+ - End every turn with the structured answer: reply (what you did and found, or your question), outcome, and next.
@@ -8,7 +8,7 @@ import type { AppConfig } from "../config.ts"
8
8
  import { externalMcp, externalNotes, type ExternalPlan } from "../mcp/external.ts"
9
9
  import { MCP_NAME, TOOL_TIMEOUT_MS, localToolServer } from "../mcp/server.ts"
10
10
  import { TOOLS, environmentNotes, type ToolContext } from "../mcp/tools.ts"
11
- import { brainPrompt, BRAIN_SCHEMA, type BrainReport } from "./prompt.ts"
11
+ import { brainPrompt, BRAIN_SCHEMA, cleanFollowUps, type BrainReport, type FollowUp } from "./prompt.ts"
12
12
  import { redactSecrets } from "../sessionLogins.ts"
13
13
  import { logEvent } from "../sessionLog.ts"
14
14
  import { loadSkills } from "../skills/registry.ts"
@@ -16,7 +16,7 @@ import { domainNotes } from "../domain.ts"
16
16
 
17
17
  export const BRAIN_AGENT = "forgeqe"
18
18
 
19
- export type BrainAnswer = { reply: string; outcome: "done" | "failed" | "needs_input" | "stopped"; error?: string }
19
+ export type BrainAnswer = { reply: string; outcome: "done" | "failed" | "needs_input" | "stopped"; error?: string; next?: FollowUp[] }
20
20
 
21
21
  export type Brain = {
22
22
  model: string
@@ -173,7 +173,8 @@ export async function openBrain(opts: BrainOptions): Promise<Brain> {
173
173
  if (stopped || out.error === "stopped" || opts.signal?.aborted) return { reply: "", outcome: "stopped" }
174
174
  const reply = out.report?.reply?.trim() || out.text.trim()
175
175
  if (out.error && !out.report) return { reply, outcome: "failed", error: out.error }
176
- return { reply, outcome: out.report?.outcome || (out.error ? "failed" : "done"), error: out.error }
176
+ const next = cleanFollowUps(out.report?.next)
177
+ return { reply, outcome: out.report?.outcome || (out.error ? "failed" : "done"), error: out.error, ...(next.length ? { next } : {}) }
177
178
  },
178
179
  abort: () => {
179
180
  stopped = true
@@ -6,14 +6,46 @@ export const brainPrompt = () => prompt("brain")
6
6
  export const BRAIN_SCHEMA = {
7
7
  type: "object",
8
8
  properties: {
9
- reply: { type: "string", description: "What you did and found, or the question you need answered. Plain text, short." },
9
+ reply: { type: "string", description: "What you did and found, or the question you need answered: Markdown, answer first, short lines." },
10
10
  outcome: {
11
11
  type: "string",
12
12
  enum: ["done", "failed", "needs_input"],
13
13
  description: "done: the request was carried out and everything checked passed. failed: a run, test, or pipeline failed or could not be done. needs_input: you asked the user something.",
14
14
  },
15
+ next: {
16
+ type: "array",
17
+ maxItems: 3,
18
+ description: "Up to three follow-ups the user is likely to want next, shown as buttons; empty when there is no clear next step.",
19
+ items: {
20
+ type: "object",
21
+ properties: {
22
+ label: { type: "string", description: "Two to five words, e.g. Write a release report" },
23
+ request: { type: "string", description: "The request as the user would type it, complete on its own" },
24
+ },
25
+ required: ["label", "request"],
26
+ },
27
+ },
15
28
  },
16
29
  required: ["reply", "outcome"],
17
30
  } as const
18
31
 
19
- export type BrainReport = { reply?: string; outcome?: "done" | "failed" | "needs_input" }
32
+ /** A follow-up the agent suggests: a button label, and the request it puts in the prompt. */
33
+ export type FollowUp = { label: string; request: string }
34
+
35
+ export type BrainReport = { reply?: string; outcome?: "done" | "failed" | "needs_input"; next?: FollowUp[] }
36
+
37
+ /** The follow-ups worth showing: well-formed, short enough for a button, at most three, no repeats. */
38
+ export function cleanFollowUps(next: unknown): FollowUp[] {
39
+ if (!Array.isArray(next)) return []
40
+ const seen = new Set<string>()
41
+ const out: FollowUp[] = []
42
+ for (const n of next) {
43
+ const label = typeof n?.label === "string" ? n.label.replace(/\s+/g, " ").trim() : ""
44
+ const request = typeof n?.request === "string" ? n.request.trim() : ""
45
+ if (!label || !request || label.length > 40 || seen.has(label.toLowerCase())) continue
46
+ seen.add(label.toLowerCase())
47
+ out.push({ label, request })
48
+ if (out.length === 3) break
49
+ }
50
+ return out
51
+ }
@@ -290,6 +290,18 @@ export const CAPABILITIES: Capability[] = [
290
290
  limits: ["Automatic notifications come from runs nobody is watching (forgeqe suite, forgeqe run, pipelines, the MCP server), not from the interactive shell, where the agent posts only when asked."],
291
291
  docs: [{ doc: "HOW_TO_USE.md", heading: "17. Notifications" }],
292
292
  },
293
+ {
294
+ id: "reports",
295
+ area: "Release",
296
+ title: "Reports you can share",
297
+ summary:
298
+ "Writes up what ForgeQE's agent found, such as release status across projects, open defects, or pipeline health, as a Markdown file and an HTML page in the reports folder, with a summary in the chat and links to open them.",
299
+ tools: ["write_report"],
300
+ examples: ["Create a release report for all projects", "Write up the open defects as a report I can share"],
301
+ requires: [{ kind: "live_model" }],
302
+ limits: ["The report holds what the agent read with its tools in this request; it does not invent figures, and it does not publish the page anywhere unless the reports folder is served (notify.report_url)."],
303
+ docs: [{ doc: "HOW_TO_USE.md", heading: "Reports from the agent" }],
304
+ },
293
305
  {
294
306
  id: "pipelines",
295
307
  area: "Operations",
@@ -0,0 +1,101 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { McpTool } from "./tools.ts"
5
+
6
+ /**
7
+ * Reports the agent writes from what it found (release status, open defects, pipeline health): a Markdown file to
8
+ * edit or paste, and the same report as a page to open or share. The chat gets the summary and the links.
9
+ */
10
+
11
+ /** "Release status: all projects" → "release-status-all-projects". */
12
+ export function reportSlug(title: string): string {
13
+ return (
14
+ title
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .slice(0, 40)
18
+ .replace(/^-+|-+$/g, "") || "report"
19
+ )
20
+ }
21
+
22
+ const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
23
+
24
+ /** The report as a page: the Markdown rendered, with raw HTML in it shown as text so a report can never run script. */
25
+ export function reportHtml(title: string, markdown: string, written: Date): string {
26
+ const safe = markdown.replace(/<(?=[A-Za-z/!?])/g, "&lt;")
27
+ const body = Bun.markdown.html(safe)
28
+ return `<!doctype html>
29
+ <html lang="en">
30
+ <head>
31
+ <meta charset="utf-8">
32
+ <meta name="viewport" content="width=device-width, initial-scale=1">
33
+ <title>${escapeHtml(title)}</title>
34
+ <style>
35
+ :root { --bg: #ffffff; --text: #1f2328; --muted: #59636e; --border: #d1d9e0; --accent: #0969da; --panel: #f6f8fa; }
36
+ @media (prefers-color-scheme: dark) { :root { --bg: #0d1117; --text: #e6edf3; --muted: #8b949e; --border: #30363d; --accent: #4ea1ff; --panel: #161b22; } }
37
+ * { box-sizing: border-box; }
38
+ body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 -apple-system, "Segoe UI", Roboto, sans-serif; }
39
+ main { max-width: 960px; margin: 0 auto; padding: 32px 16px 64px; }
40
+ header { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 24px; }
41
+ header p { margin: 4px 0 0; color: var(--muted); font-size: 13px; }
42
+ h1 { font-size: 28px; line-height: 1.25; margin: 0; }
43
+ h2 { font-size: 20px; margin: 32px 0 8px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
44
+ h3 { font-size: 16px; margin: 24px 0 8px; }
45
+ a { color: var(--accent); }
46
+ code { background: var(--panel); border-radius: 4px; padding: 1px 4px; font-size: 13px; }
47
+ pre { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; }
48
+ pre code { background: none; padding: 0; }
49
+ table { border-collapse: collapse; width: 100%; margin: 12px 0; display: block; overflow-x: auto; }
50
+ th, td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; vertical-align: top; }
51
+ th { background: var(--panel); }
52
+ blockquote { margin: 12px 0; padding: 4px 12px; border-left: 4px solid var(--border); color: var(--muted); }
53
+ li { margin: 2px 0; }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <main>
58
+ <header>
59
+ <h1>${escapeHtml(title)}</h1>
60
+ <p>Written by ForgeQE on ${escapeHtml(written.toLocaleString())}</p>
61
+ </header>
62
+ ${body}
63
+ </main>
64
+ </body>
65
+ </html>
66
+ `
67
+ }
68
+
69
+ export const REPORT_TOOLS: McpTool[] = [
70
+ {
71
+ name: "write_report",
72
+ description:
73
+ 'Save a report the user asks for ("create a release report", "write up the open defects", "give me a report I can share") as a Markdown file and an HTML page in the reports folder. Gather the facts with your other tools first, then pass the whole report as Markdown: a one-paragraph summary first, then ## sections, tables for per-item status (one row per project, case, or build), and links to builds, work items, and pull requests. Do not invent data you did not read. The user is shown the report paths as links, so reply with three to five lines of summary only: do not repeat the paths or paste the report.',
74
+ inputSchema: {
75
+ type: "object",
76
+ properties: {
77
+ title: { type: "string", description: "The report's title, e.g. Release status: all projects, 9 October 2026" },
78
+ markdown: { type: "string", description: "The report body in Markdown, without the title (it is added as the heading)" },
79
+ },
80
+ required: ["title", "markdown"],
81
+ },
82
+ async run(args, ctx) {
83
+ const title = String(args.title || "Report").trim()
84
+ const markdown = String(args.markdown || "").trim()
85
+ if (!markdown) throw new Error("The report is empty: pass its body as markdown.")
86
+ const written = new Date()
87
+ const stamp = written.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15)
88
+ // Beside the run reports, so the shell links it and outDir publishing (notify.report_url) serves it too.
89
+ const dir = path.resolve(ctx.config.export.outDir, `${reportSlug(title)}-${stamp}`)
90
+ mkdirSync(dir, { recursive: true })
91
+ const md = path.join(dir, "report.md")
92
+ const html = path.join(dir, "report.html")
93
+ writeFileSync(md, `# ${title}\n\n${markdown}\n`)
94
+ writeFileSync(html, reportHtml(title, markdown, written))
95
+ const show = (p: string) => path.relative(process.cwd(), p) || p
96
+ ctx.emit?.({ type: "report", title, html, markdown: md })
97
+ ctx.emit?.({ type: "card", card: { status: "ok", title: `Report: ${title}`, body: [`Page ${show(html)}`, `Markdown ${show(md)}`] } })
98
+ return { html: show(html), markdown: show(md) }
99
+ },
100
+ },
101
+ ]
package/src/mcp/tools.ts CHANGED
@@ -29,6 +29,7 @@ import { EXPLORE_TOOLS } from "./exploretools.ts"
29
29
  import { QUALITY_TOOLS } from "./qualitytools.ts"
30
30
  import { CAPABILITY_TOOLS } from "./captools.ts"
31
31
  import { NOTIFY_TOOLS } from "./notifytools.ts"
32
+ import { REPORT_TOOLS } from "./reporttools.ts"
32
33
  import { capabilityIndex } from "../capabilities/render.ts"
33
34
  import { domainNotes } from "../domain.ts"
34
35
  import { readNetwork } from "../api/traffic.ts"
@@ -95,6 +96,8 @@ export type ToolEvent =
95
96
  | { type: "code-done"; result?: CodeResult; error?: string; ms: number }
96
97
  /** A tool's result to show as a panel (a test written, tests healed, a monitoring round). */
97
98
  | { type: "card"; card: Card }
99
+ /** write_report saved a report: its page and its Markdown. */
100
+ | { type: "report"; title: string; html: string; markdown: string }
98
101
 
99
102
  export type ToolContext = {
100
103
  config: AppConfig
@@ -722,6 +725,7 @@ export const TOOLS: McpTool[] = [
722
725
  ...QUALITY_TOOLS,
723
726
  ...CAPABILITY_TOOLS,
724
727
  ...NOTIFY_TOOLS,
728
+ ...REPORT_TOOLS,
725
729
  ]
726
730
 
727
731
  /** The setup the agent should know about: where it works, which systems are configured, and the defaults. */
package/src/tui/App.tsx CHANGED
@@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from "react"
7
7
  import { NO_MODEL_SET_UP } from "../llm.ts"
8
8
  import type { CodeEvent, ReplayMode } from "../types.ts"
9
9
  import { brainUnavailable, carryOver, openBrain, sessionSetup, type Brain, type BrainOptions } from "../brain/index.ts"
10
+ import type { FollowUp } from "../brain/prompt.ts"
10
11
  import { INSTALL_HINT } from "../code/runtime.ts"
11
12
  import { briefText, detailFrom, shows, showsAgentTool, stepIntent, type Detail } from "../detail.ts"
12
13
  import { stepVerdictLines } from "../cases/types.ts"
@@ -23,7 +24,7 @@ import { counts } from "../suite/report.ts"
23
24
  import { buildSummary, shortPath } from "../summary.ts"
24
25
  import { meterSnapshot, requestLine } from "../usage.ts"
25
26
  import { cleanSelection, copyText } from "./clipboard.ts"
26
- import { ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Hint } from "./components.tsx"
27
+ import { ButtonBar, ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Button, type Hint } from "./components.tsx"
27
28
  import { setupChecklist, type SetupItem } from "./setup.ts"
28
29
  import { EXAMPLES, TIPS, formatDuration, formatTokens, packageVersion, pick, shortCwd, theme } from "./theme.ts"
29
30
  import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
@@ -48,6 +49,7 @@ import {
48
49
  nextFocus,
49
50
  planLines,
50
51
  PlanPanel,
52
+ planProgress,
51
53
  planRows,
52
54
  SessionPanel,
53
55
  StatusBar,
@@ -167,6 +169,8 @@ export function App({ options }: { options: TuiOptions }) {
167
169
  const requests = useRef(0)
168
170
  /** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
169
171
  const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
172
+ // The split view's chat at the full width, for reading a long answer; the panels come back with Split view.
173
+ const [chatWide, setChatWide] = useState(false)
170
174
  const setupRef = useRef<{ key: string; items: SetupItem[] }>({ key: "", items: [] })
171
175
  // Which panel the keys go to (shift+tab moves on), the row selected in the plan and files, and the activity filter.
172
176
  const [nav, setNavState] = useState<{ focus: Focus; plan: number; files: number; filter: ActivityFilter }>({ focus: "chat", plan: 0, files: 0, filter: "all" })
@@ -481,6 +485,7 @@ export function App({ options }: { options: TuiOptions }) {
481
485
  active: configRef.current?.activeProfile || "",
482
486
  narrow: widthRef.current < SPLIT_MIN,
483
487
  view: narrowView,
488
+ chatWide,
484
489
  })
485
490
  const paletteMatches = (query: string) => searchPalette(query, paletteAll())
486
491
 
@@ -556,6 +561,8 @@ export function App({ options }: { options: TuiOptions }) {
556
561
  else if (run.id === "stop") openDialog("stop")
557
562
  else if (run.id === "details" || run.id === "chat") setNarrowView(run.id)
558
563
  else if (run.id === "refine") refineRef.current()
564
+ else if (run.id === "wide") setChatWide((w) => !w)
565
+ else if (run.id === "panels") setNav((n) => ({ ...n, focus: planLines(inspRef.current).length ? "plan" : "activity" }))
559
566
  }
560
567
 
561
568
  const cycleProfile = () => {
@@ -641,7 +648,7 @@ export function App({ options }: { options: TuiOptions }) {
641
648
  return
642
649
  }
643
650
  // The panels: shift+tab moves focus through those on screen; a focused panel takes the arrows, enter, and esc.
644
- const panes = width >= SPLIT_MIN || narrowView === "details"
651
+ const panes = (width >= SPLIT_MIN && !chatWide) || (width < SPLIT_MIN && narrowView === "details")
645
652
  const insp = inspRef.current
646
653
  const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && width >= SPLIT_MIN)
647
654
  if (panes && key.name === "tab" && key.shift && !entryRef.current) {
@@ -916,6 +923,9 @@ export function App({ options }: { options: TuiOptions }) {
916
923
  case "card":
917
924
  pushLine({ kind: "card", text: e.card.title, card: e.card })
918
925
  return
926
+ case "report":
927
+ producedRef.current.reports.push({ title: e.title, html: e.html })
928
+ return
919
929
  }
920
930
  }
921
931
 
@@ -971,8 +981,8 @@ export function App({ options }: { options: TuiOptions }) {
971
981
  return controller
972
982
  }
973
983
 
974
- const finish = (finalStatus: string) => {
975
- setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on }))
984
+ const finish = (finalStatus: string, followUps: FollowUp[] = []) => {
985
+ setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on, followUps }))
976
986
  progressDone(inspRef.current, finalStatus === "stopped")
977
987
  bump()
978
988
  setStatus(finalStatus)
@@ -1016,6 +1026,7 @@ export function App({ options }: { options: TuiOptions }) {
1016
1026
  const setup = sessionSetup(cfg, options.repo)
1017
1027
  testsRef.current = { on: setup.writeTests, why: "the session was started with --no-code" }
1018
1028
  let finalStatus = "fail"
1029
+ let followUps: FollowUp[] = []
1019
1030
  let fresh = false
1020
1031
  try {
1021
1032
  if (!brainRef.current) {
@@ -1043,6 +1054,7 @@ export function App({ options }: { options: TuiOptions }) {
1043
1054
  finalStatus = "stopped"
1044
1055
  } else {
1045
1056
  if (answer.reply) pushLine({ kind: "reply", text: answer.reply, outcome: answer.outcome })
1057
+ followUps = answer.next || []
1046
1058
  if (answer.error && !answer.reply) push("err", answer.error, "chat")
1047
1059
  finalStatus = answer.outcome === "done" ? "done" : answer.outcome === "needs_input" ? "idle" : "fail"
1048
1060
  }
@@ -1055,7 +1067,7 @@ export function App({ options }: { options: TuiOptions }) {
1055
1067
  // The split view's session and tokens panels show this; only the one-pane view needs it in the chat.
1056
1068
  const spent = widthRef.current < SPLIT_MIN ? requestLine(meterStart) : ""
1057
1069
  if (spent) push("sys", spent, "chat")
1058
- finish(finalStatus)
1070
+ finish(finalStatus, followUps)
1059
1071
  }
1060
1072
  }
1061
1073
 
@@ -1285,35 +1297,38 @@ export function App({ options }: { options: TuiOptions }) {
1285
1297
 
1286
1298
  const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
1287
1299
  const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
1288
- // The split view's right pane shows tokens, time, and the last result, so the hints under the prompt leave them out.
1300
+ // The split view's right pane shows tokens, time, and the last result, so the row under the prompt leaves them out.
1289
1301
  const wide = width >= SPLIT_MIN
1290
- const viewHint: Hint[] = wide ? [] : [{ key: "ctrl+o", label: "details", onPress: () => setNarrowView("details") }]
1291
- // Each hint's `drop` is when it gives way on a narrow row: the last run and profiles (also in the palette) go first.
1292
- const idleHints: Hint[] = [
1293
- ...copiedHint.map((h) => ({ ...h, drop: 0 })),
1294
- ...(lastStats && !wide ? [{ key: `last run ${lastStats}`, label: "", drop: 5 }] : []),
1295
- ...viewHint.map((h) => ({ ...h, drop: 3 })),
1296
- { key: "ctrl+r", label: "refine", onPress: () => refineRef.current(), drop: 1 },
1297
- ...(wide ? [{ key: "shift+tab", label: "panels", onPress: () => setNav((n) => ({ ...n, focus: planLines(inspRef.current).length ? "plan" : "activity" })), drop: 4 }] : [{ key: "tab", label: "profiles", drop: 4 }]),
1298
- { key: "ctrl+p", label: "commands" },
1299
- closeHint,
1300
- ]
1301
- const runningHints: Hint[] = [
1302
- // A note ("opened report.html") is the last to give way: it answers what was just pressed.
1303
- ...copiedHint.map((h) => ({ ...h, drop: 0 })),
1304
- // The split view's panels show tokens and time; the one-pane view shows them here while there is room.
1305
- ...(wide ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "", drop: 3 }, ...viewHint.map((h) => ({ ...h, drop: 2 }))]),
1306
- { key: "ctrl+p", label: "commands", drop: 1 },
1307
- closeHint,
1302
+ const openPalette = () => {
1303
+ paletteRef.current = { query: "", selected: 0 }
1304
+ setPalette(paletteRef.current)
1305
+ }
1306
+ const focusPanels = () => setNav((n) => ({ ...n, focus: planLines(inspRef.current).length ? "plan" : "activity" }))
1307
+ // What can be done from here, as buttons: Refine fills with its colour once there is a request to refine.
1308
+ // `drop`: the order they give way on a narrow row; Refine, Search, and Stop always stay.
1309
+ const refineButton: Button = { icon: "✦", label: "Refine", keys: "ctrl+r", color: theme.memory, onPress: () => refineRef.current(), active: Boolean(promptText.trim()) }
1310
+ const searchButton: Button = { icon: "⌕", label: "Search", keys: "ctrl+p", color: theme.accent, onPress: openPalette }
1311
+ // The view: in the split view, Panels and Wide chat sit in the right pane's status line, where there is room;
1312
+ // Split view comes back here when the chat is wide, and Details on a narrow terminal.
1313
+ const toggleWide = () => setChatWide((w) => !w)
1314
+ const paneButtons: Button[] = [
1315
+ { icon: "▦", label: "Panels", keys: "shift+tab", color: theme.thought, onPress: focusPanels, drop: 2 },
1316
+ { icon: "⤢", label: "Wide chat", color: theme.muted, onPress: toggleWide, drop: 1 },
1308
1317
  ]
1318
+ const viewButtons: Button[] = wide
1319
+ ? chatWide
1320
+ ? [{ icon: "◧", label: "Split view", color: theme.thought, onPress: toggleWide, drop: 2 }]
1321
+ : []
1322
+ : [{ icon: "◧", label: "Details", keys: "ctrl+o", color: theme.thought, onPress: () => setNarrowView("details"), drop: 1 }]
1323
+ const idleButtons: Button[] = [refineButton, searchButton, ...viewButtons, ...(wide ? [] : [{ icon: "⇄", label: "Profile", keys: "tab", color: theme.muted, onPress: cycleProfile, drop: 4 }])]
1324
+ const runningButtons: Button[] = [{ icon: "■", label: "Stop", keys: "esc", color: theme.warn, onPress: () => openDialog("stop") }, searchButton, ...viewButtons]
1325
+ const idleHints: Hint[] = [...copiedHint, ...(lastStats && !wide && !copied ? [{ key: `last run ${lastStats}`, label: "" }] : []), closeHint]
1326
+ // The one-pane view has no tokens panel, so the tokens and time show here.
1327
+ const runningHints: Hint[] = [...copiedHint, ...(wide || copied ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" }]), closeHint]
1309
1328
  const runningLeft = (
1310
- <>
1329
+ <box style={{ flexDirection: "row", marginRight: 1 }}>
1311
1330
  <Spinner kind="bar" />
1312
- <text onMouseDown={() => openDialog("stop")}>
1313
- <span fg={theme.text}>{" esc"}</span>
1314
- <span fg={theme.muted}>{" stop"}</span>
1315
- </text>
1316
- </>
1331
+ </box>
1317
1332
  )
1318
1333
 
1319
1334
  const renderControls = (promptWidth: number, placeholder: string, rows: [number, number]) => {
@@ -1406,7 +1421,7 @@ export function App({ options }: { options: TuiOptions }) {
1406
1421
  setPromptText(t)
1407
1422
  }}
1408
1423
  />
1409
- <Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} reserve={running ? 20 : 0} />
1424
+ <ButtonBar width={promptWidth} buttons={running ? runningButtons : idleButtons} hints={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
1410
1425
  </>
1411
1426
  )
1412
1427
  }
@@ -1509,7 +1524,7 @@ export function App({ options }: { options: TuiOptions }) {
1509
1524
  const sel = (f: "plan" | "files") => (nav.focus === f ? Math.min(nav[f], Math.max(0, (f === "plan" ? planLines(insp) : filesByUse(insp)).length - 1)) : undefined)
1510
1525
  return (
1511
1526
  <box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
1512
- <StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress} />
1527
+ <StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress || planProgress(insp)} actions={paneButtons} />
1513
1528
  <box style={{ height: 1, flexShrink: 0 }} />
1514
1529
  {plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel("plan")} onFocus={focusOn("plan")} /> : null}
1515
1530
  <ActivityPanel
@@ -1536,7 +1551,7 @@ export function App({ options }: { options: TuiOptions }) {
1536
1551
  {all.map((line) => (
1537
1552
  <TranscriptLine key={line.id} line={line} />
1538
1553
  ))}
1539
- {running && !split ? (
1554
+ {running && (!split || chatWide) ? (
1540
1555
  <box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
1541
1556
  <Spinner kind="dots" />
1542
1557
  <text fg={theme.text}>{` ${activity || "Working…"}`}</text>
@@ -1548,18 +1563,22 @@ export function App({ options }: { options: TuiOptions }) {
1548
1563
 
1549
1564
  const split = width >= SPLIT_MIN
1550
1565
  if (split) {
1551
- const leftWidth = Math.floor(width * 0.4)
1566
+ // Wide chat: the conversation across the screen for reading, the panels hidden until Split view.
1567
+ const leftWidth = chatWide ? width : Math.floor(width * 0.4)
1552
1568
  return (
1553
1569
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1554
1570
  <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1555
- <box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1556
- <SessionPanel info={sessionInfo} width={leftWidth - 3} />
1557
- {transcript(chatLines, {})}
1558
- <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1559
- </box>
1560
- <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1561
- {details(width - leftWidth - 3, height - 2)}
1571
+ <box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: chatWide ? 2 : 1 }}>
1572
+ <SessionPanel info={sessionInfo} width={leftWidth - (chatWide ? 4 : 3)} />
1573
+ {/* Wide chat shows what the agents do in the conversation, as the one-pane view does. */}
1574
+ {transcript(chatWide ? lines : chatLines, {})}
1575
+ <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - (chatWide ? 4 : 3))}</box>
1562
1576
  </box>
1577
+ {chatWide ? null : (
1578
+ <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1579
+ {details(width - leftWidth - 3, height - 2)}
1580
+ </box>
1581
+ )}
1563
1582
  </box>
1564
1583
  <Footer left={shortCwd()} right={footerRight} width={width} />
1565
1584
  {paletteOverlay}
@@ -206,6 +206,55 @@ export function Hints({ width, items: all, left, reserve = 0 }: { width: number;
206
206
  )
207
207
  }
208
208
 
209
+ /**
210
+ * A button under the prompt: an icon, a label, and its key, on a raised background; `active` fills it with its colour
211
+ * (Refine once there is text to refine). `drop`: when it gives way on a narrow row, highest first.
212
+ */
213
+ export type Button = { icon: string; label: string; keys?: string; color: string; onPress: () => void; active?: boolean; drop?: number }
214
+
215
+ const buttonCells = (b: Button) => b.icon.length + 1 + b.label.length + (b.keys ? b.keys.length + 1 : 0) + 3
216
+
217
+ /** The buttons that fit in `room` columns; a narrow row first loses whole buttons by their `drop`, then the keys. */
218
+ export function fitButtons(items: Button[], room: number): Button[] {
219
+ const cells = (list: Button[]) => list.reduce((n, b) => n + buttonCells(b), 0)
220
+ let kept = [...items]
221
+ for (const b of items.filter((x) => x.drop !== undefined).sort((x, y) => y.drop! - x.drop!)) {
222
+ if (cells(kept) <= room) return kept
223
+ kept = kept.filter((k) => k !== b)
224
+ }
225
+ return cells(kept) <= room ? kept : kept.map((b) => ({ ...b, keys: undefined }))
226
+ }
227
+
228
+ /** The row under the prompt: buttons on the left, the hints (close, a note) on the right. */
229
+ export function ButtonBar({ width, buttons, hints, left }: { width: number; buttons: Button[]; hints: Hint[]; left?: ReactNode }) {
230
+ const hintCells = hints.reduce((n, h, i) => n + h.key.length + (h.label ? h.label.length + 1 : 0) + (i ? 2 : 0), 0)
231
+ const shown = fitButtons(buttons, Math.max(0, width - hintCells - (left ? 22 : 0) - 1))
232
+ return (
233
+ <box style={{ width, height: 1, flexShrink: 0, flexDirection: "row", justifyContent: "space-between" }}>
234
+ <box style={{ flexDirection: "row" }}>
235
+ {left}
236
+ {shown.map((b) => (
237
+ <box key={b.label} onMouseDown={b.onPress} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: b.active ? b.color : theme.button }}>
238
+ <text>
239
+ <span fg={b.active ? theme.onButton : b.color}>{`${b.icon} `}</span>
240
+ <span fg={b.active ? theme.onButton : theme.text}>{b.label}</span>
241
+ {b.keys ? <span fg={b.active ? theme.onButton : theme.faint}>{` ${b.keys}`}</span> : null}
242
+ </text>
243
+ </box>
244
+ ))}
245
+ </box>
246
+ <box style={{ flexDirection: "row" }}>
247
+ {hints.map((item, i) => (
248
+ <text key={`${item.key}-${item.label}`} onMouseDown={item.onPress}>
249
+ <span fg={theme.text}>{`${i ? " " : ""}${item.key}`}</span>
250
+ <span fg={item.onPress ? theme.muted : theme.faint}>{item.label ? ` ${item.label}` : ""}</span>
251
+ </text>
252
+ ))}
253
+ </box>
254
+ </box>
255
+ )
256
+ }
257
+
209
258
  export type Choice = { id: string; label: string }
210
259
 
211
260
  export type ChoicePanelProps = {
@@ -2,7 +2,7 @@ import type { ScrollBoxRenderable } from "@opentui/core"
2
2
  import type { ReactNode, RefObject } from "react"
3
3
 
4
4
  import type { Usage } from "../types.ts"
5
- import { Spinner } from "./components.tsx"
5
+ import { fitButtons, Spinner, type Button } from "./components.tsx"
6
6
  import { filesByUse, type FileUse, type Inspector, type Progress } from "./session.ts"
7
7
  import { clip, tailCut } from "./text.ts"
8
8
  import { formatTokens, theme } from "./theme.ts"
@@ -102,6 +102,11 @@ export function SessionPanel({ info, width }: { info: SessionInfo; width: number
102
102
  }
103
103
 
104
104
  /** The right pane's first line: who is working on what and for how long, and Stop while something runs. */
105
+ /** How far the agent's own plan has got, while it has one of two steps or more. */
106
+ export function planProgress(s: Inspector): { done: number; total: number } | null {
107
+ return s.plan.length > 1 ? { done: s.plan.filter((p) => p.status === "completed").length, total: s.plan.length } : null
108
+ }
109
+
105
110
  /** "███████░░░░░ 5/8": how far a suite has got, in `cells` cells. */
106
111
  export function progressBar(done: number, total: number, cells: number): [string, string] {
107
112
  const filled = total ? Math.round((Math.min(done, total) / total) * cells) : 0
@@ -118,6 +123,8 @@ export function StatusBar(props: {
118
123
  onStop: () => void
119
124
  /** A suite's cases: done so far, of how many. */
120
125
  progress?: { done: number; total: number } | null
126
+ /** Buttons for the view (Panels, Wide chat), before Stop; the first to give way when the line is short. */
127
+ actions?: Button[]
121
128
  }) {
122
129
  const done = props.status === "done"
123
130
  const failed = props.status === "fail"
@@ -134,7 +141,9 @@ export function StatusBar(props: {
134
141
  const bar = props.running && props.progress && props.progress.total > 1 ? props.progress : null
135
142
  const cells = 12
136
143
  const barText = bar ? ` ${bar.done}/${bar.total} ` : ""
137
- const room = Math.max(10, props.width - props.who.length - (props.running ? 30 : 6) - (bar ? cells + barText.length : 0))
144
+ const fixed = props.who.length + (props.running ? 30 : 6) + (bar ? cells + barText.length : 0)
145
+ const actions = fitButtons(props.actions || [], Math.max(0, props.width - fixed - 28))
146
+ const room = Math.max(10, props.width - fixed - actions.reduce((n, b) => n + b.icon.length + b.label.length + (b.keys ? b.keys.length + 1 : 0) + 4, 0))
138
147
  const [full, empty] = bar ? progressBar(bar.done, bar.total, cells) : ["", ""]
139
148
  return (
140
149
  <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
@@ -145,8 +154,18 @@ export function StatusBar(props: {
145
154
  <span fg={props.running ? theme.muted : color}>{`${props.running ? " " : " "}${clip(what, room)}`}</span>
146
155
  </text>
147
156
  </box>
157
+ <box style={{ flexDirection: "row" }}>
158
+ {actions.map((b) => (
159
+ <box key={b.label} onMouseDown={b.onPress} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginLeft: 1, backgroundColor: theme.button }}>
160
+ <text>
161
+ <span fg={b.color}>{`${b.icon} `}</span>
162
+ <span fg={theme.text}>{b.label}</span>
163
+ {b.keys ? <span fg={theme.faint}>{` ${b.keys}`}</span> : null}
164
+ </text>
165
+ </box>
166
+ ))}
148
167
  {props.running ? (
149
- <box style={{ flexDirection: "row" }}>
168
+ <box style={{ flexDirection: "row", marginLeft: 2 }}>
150
169
  {bar ? (
151
170
  <text>
152
171
  <span fg={theme.ok}>{full}</span>
@@ -160,6 +179,7 @@ export function StatusBar(props: {
160
179
  </box>
161
180
  </box>
162
181
  ) : null}
182
+ </box>
163
183
  </box>
164
184
  )
165
185
  }
@@ -242,6 +262,12 @@ const ACTIVITY_STYLE: Record<string, [string, string, string]> = {
242
262
  err: ["✗", theme.err, theme.text],
243
263
  }
244
264
 
265
+ /** A tool line's call and its arguments in brackets, or null when it has none. */
266
+ export function toolArgs(text: string): [string, string] | null {
267
+ const at = text.indexOf(" (")
268
+ return at > 0 && text.endsWith(")") ? [text.slice(0, at), text.slice(at + 1)] : null
269
+ }
270
+
245
271
  /** One activity line, its mark in a column of its own so wrapped text stays under the text. */
246
272
  export function ActivityRow({ line }: { line: Line }) {
247
273
  const [mark, markColor, textColor] = ACTIVITY_STYLE[line.kind] || ["·", theme.faint, theme.muted]
@@ -250,9 +276,17 @@ export function ActivityRow({ line }: { line: Line }) {
250
276
  <text fg={markColor} style={{ width: 2, flexShrink: 0 }}>
251
277
  {mark}
252
278
  </text>
253
- <text fg={textColor} style={{ flexGrow: 1, flexShrink: 1 }}>
254
- {line.text || " "}
255
- </text>
279
+ {line.kind === "tool" && toolArgs(line.text) ? (
280
+ // "Azure DevOps · pipelines build (action list, project QI-Claims)": the call readable, its arguments quiet.
281
+ <text style={{ flexGrow: 1, flexShrink: 1 }}>
282
+ <span fg={theme.text}>{toolArgs(line.text)![0]}</span>
283
+ <span fg={theme.faint}>{` ${toolArgs(line.text)![1]}`}</span>
284
+ </text>
285
+ ) : (
286
+ <text fg={textColor} style={{ flexGrow: 1, flexShrink: 1 }}>
287
+ {line.text || " "}
288
+ </text>
289
+ )}
256
290
  </box>
257
291
  )
258
292
  }
package/src/tui/next.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import path from "node:path"
3
3
 
4
+ import type { FollowUp } from "../brain/prompt.ts"
4
5
  import type { Produced } from "./transcript.tsx"
5
6
 
6
7
  /**
@@ -10,8 +11,14 @@ import type { Produced } from "./transcript.tsx"
10
11
 
11
12
  export type NextStep = { label: string; run: { kind: "insert"; text: string } | { kind: "open"; path: string } }
12
13
 
13
- export function nextSteps(p: Produced, opts: { testsOn: boolean }): NextStep[] {
14
+ /**
15
+ * `followUps`: what ForgeQE's agent suggests from its answer ("Write a release report"), after what the request
16
+ * produced; a follow-up that repeats a step already offered is left out.
17
+ */
18
+ export function nextSteps(p: Produced, opts: { testsOn: boolean; followUps?: FollowUp[] }): NextStep[] {
14
19
  const steps: NextStep[] = []
20
+ const report = p.reports.at(-1)
21
+ if (report && existsSync(report.html)) steps.push({ label: "Open the report", run: { kind: "open", path: report.html } })
15
22
  const run = p.runs.at(-1)
16
23
  const suite = p.suites.at(-1)
17
24
  if (suite) {
@@ -28,5 +35,9 @@ export function nextSteps(p: Produced, opts: { testsOn: boolean }): NextStep[] {
28
35
  }
29
36
  const test = p.tests.at(-1)
30
37
  if (test?.result?.testFile && test.result.status === "passed") steps.unshift({ label: "Open the new test", run: { kind: "open", path: test.result.testFile } })
38
+ for (const f of opts.followUps || []) {
39
+ if (steps.some((s) => s.label.toLowerCase() === f.label.toLowerCase())) continue
40
+ steps.push({ label: f.label, run: { kind: "insert", text: f.request } })
41
+ }
31
42
  return steps.slice(0, 3)
32
43
  }
@@ -15,17 +15,21 @@ export type PaletteRun =
15
15
  /** Put in the prompt to edit and send: an example request. */
16
16
  | { kind: "insert"; text: string }
17
17
  /** Something the shell does itself. */
18
- | { kind: "action"; id: "refine" | "stop" | "details" | "chat" }
18
+ | { kind: "action"; id: "refine" | "stop" | "details" | "chat" | "wide" | "panels" }
19
19
 
20
20
  export type PaletteItem = { id: string; group: PaletteGroup; title: string; detail?: string; keys?: string; run: PaletteRun }
21
21
 
22
22
  export const GROUP_COLOR: Record<PaletteGroup, string> = { Action: theme.warn, Command: theme.accent, Profile: theme.memory, Ask: theme.ok }
23
23
 
24
- export function paletteItems(opts: { running: boolean; headed: boolean; detailsFull: boolean; profiles: string[]; active: string; narrow: boolean; view: "chat" | "details" }): PaletteItem[] {
24
+ export function paletteItems(opts: { running: boolean; headed: boolean; detailsFull: boolean; profiles: string[]; active: string; narrow: boolean; view: "chat" | "details"; chatWide?: boolean }): PaletteItem[] {
25
25
  const items: PaletteItem[] = []
26
26
  const command = (id: string, title: string, text: string, detail?: string, keys?: string) => items.push({ id, group: "Command", title, detail, keys, run: { kind: "command", text } })
27
27
  if (opts.running) items.push({ id: "stop", group: "Action", title: "Stop the running request", keys: "esc", run: { kind: "action", id: "stop" } })
28
28
  items.push({ id: "refine", group: "Action", title: "Refine my request", detail: "check it against what ForgeQE knows before sending", keys: "ctrl+r", run: { kind: "action", id: "refine" } })
29
+ if (!opts.narrow) {
30
+ items.push({ id: "wide", group: "Action", title: opts.chatWide ? "Split view: chat and panels" : "Wide chat: the conversation across the screen", run: { kind: "action", id: "wide" } })
31
+ if (!opts.chatWide) items.push({ id: "panels", group: "Action", title: "Move to the panels: plan, activity, files", keys: "shift+tab", run: { kind: "action", id: "panels" } })
32
+ }
29
33
  if (opts.narrow) items.push({ id: "view", group: "Action", title: opts.view === "chat" ? "Show the details view" : "Back to the chat", keys: "ctrl+o", run: { kind: "action", id: opts.view === "chat" ? "details" : "chat" } })
30
34
  command("features", "What ForgeQE can do here", "/features", "and what each feature still needs")
31
35
  command("auth", "Sign in to a system", "/auth", "Azure DevOps, GitHub, Jira, Confluence, TestRail, Zephyr, qTest, Xray")
@@ -31,9 +31,11 @@ export type Produced = {
31
31
  runs: { runDir: string; status: RunStatus; files: string[] }[]
32
32
  suites: { html: string; junit: string }[]
33
33
  tests: { runDir: string; result?: CodeResult; error?: string }[]
34
+ /** Reports write_report saved: the page to open. */
35
+ reports: { title: string; html: string }[]
34
36
  }
35
37
 
36
- export const emptyProduced = (): Produced => ({ runs: [], suites: [], tests: [] })
38
+ export const emptyProduced = (): Produced => ({ runs: [], suites: [], tests: [], reports: [] })
37
39
 
38
40
  /** Whether passed runs become tests this session, and why not when they don't. */
39
41
  export type TestsSetting = { on: boolean; why: string }
@@ -325,6 +327,80 @@ function replyStyle(): SyntaxStyle {
325
327
  return style
326
328
  }
327
329
 
330
+ /** A reply cut into what reads better laid out than wrapped: headings, list items with their depth, and the rest. */
331
+ export type ReplyBlock =
332
+ | { kind: "heading"; text: string }
333
+ | { kind: "item"; depth: number; mark: string; text: string }
334
+ | { kind: "markdown"; text: string }
335
+
336
+ const ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
337
+
338
+ export function replyBlocks(md: string): ReplyBlock[] {
339
+ const blocks: ReplyBlock[] = []
340
+ let rest: string[] = []
341
+ let fence = false
342
+ const flush = () => {
343
+ const text = rest.join("\n").trim()
344
+ if (text) blocks.push({ kind: "markdown", text })
345
+ rest = []
346
+ }
347
+ for (const line of md.replace(/\r/g, "").split("\n")) {
348
+ if (/^\s*(```|~~~)/.test(line)) fence = !fence
349
+ const last = blocks.at(-1)
350
+ if (fence || /^\s*(```|~~~)/.test(line) || /^\s*\|/.test(line)) {
351
+ rest.push(line)
352
+ continue
353
+ }
354
+ const heading = /^#{1,6}\s+(.*)$/.exec(line)
355
+ const item = ITEM.exec(line)
356
+ if (heading) {
357
+ flush()
358
+ blocks.push({ kind: "heading", text: heading[1]!.replace(/\*\*/g, "").trim() })
359
+ } else if (item) {
360
+ flush()
361
+ const depth = Math.min(3, Math.floor(item[1]!.replace(/\t/g, " ").length / 2))
362
+ const ordered = /\d/.test(item[2]!)
363
+ blocks.push({ kind: "item", depth, mark: ordered ? item[2]!.replace(")", ".") : depth ? "◦" : "•", text: item[3]! })
364
+ } else if (line.trim() && /^\s+\S/.test(line) && last?.kind === "item" && !rest.length) {
365
+ // A list item's continuation line: it belongs to the item, under its text.
366
+ last.text += ` ${line.trim()}`
367
+ } else if (!line.trim()) {
368
+ rest.push(line)
369
+ } else rest.push(line)
370
+ }
371
+ flush()
372
+ return blocks
373
+ }
374
+
375
+ /** The agent's reply: headings with room above, list items with their marks in a column so wrapped lines hang under the text. */
376
+ function ReplyBody({ text }: { text: string }) {
377
+ const blocks = replyBlocks(text)
378
+ return (
379
+ <box style={{ flexDirection: "column", flexShrink: 0 }}>
380
+ {blocks.map((b, i) =>
381
+ b.kind === "heading" ? (
382
+ <text key={i} fg={theme.accent} style={{ marginTop: i ? 1 : 0, flexShrink: 0 }}>
383
+ <strong>{b.text}</strong>
384
+ </text>
385
+ ) : b.kind === "item" ? (
386
+ <box key={i} style={{ flexDirection: "row", flexShrink: 0, paddingLeft: b.depth * 2, marginTop: blocks[i - 1]?.kind === "markdown" ? 1 : 0 }}>
387
+ <text fg={theme.accent} style={{ width: b.mark.length + 1, flexShrink: 0 }}>
388
+ {b.mark}
389
+ </text>
390
+ <box style={{ flexGrow: 1, flexShrink: 1 }}>
391
+ <markdown content={b.text} syntaxStyle={replyStyle()} fg={theme.text} conceal />
392
+ </box>
393
+ </box>
394
+ ) : (
395
+ <box key={i} style={{ marginTop: i ? 1 : 0, flexShrink: 0 }}>
396
+ <markdown content={b.text} syntaxStyle={replyStyle()} fg={theme.text} conceal tableOptions={{ widthMode: "content", wrapMode: "word" }} />
397
+ </box>
398
+ ),
399
+ )}
400
+ </box>
401
+ )
402
+ }
403
+
328
404
  export function TranscriptLine({ line }: { line: Line }) {
329
405
  if (line.kind === "card" && line.card) return <ResultCard card={line.card} />
330
406
  if (line.kind === "summary" && line.summary) return <SummaryCard summary={line.summary} />
@@ -338,7 +414,7 @@ export function TranscriptLine({ line }: { line: Line }) {
338
414
  customBorderChars={BAR}
339
415
  style={{ backgroundColor: theme.panel, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1, marginTop: 1, marginBottom: 1, flexShrink: 0 }}
340
416
  >
341
- <markdown content={line.text} syntaxStyle={replyStyle()} fg={theme.text} conceal tableOptions={{ widthMode: "content", wrapMode: "word" }} />
417
+ <ReplyBody text={line.text} />
342
418
  </box>
343
419
  )
344
420
  }