@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 +20 -1
- package/docs/user-guide.html +27 -2
- package/package.json +1 -1
- package/prompts/brain.md +4 -1
- package/src/brain/index.ts +4 -3
- package/src/brain/prompt.ts +34 -2
- package/src/capabilities/registry.ts +12 -0
- package/src/mcp/reporttools.ts +101 -0
- package/src/mcp/tools.ts +4 -0
- package/src/tui/App.tsx +60 -41
- package/src/tui/components.tsx +49 -0
- package/src/tui/inspector.tsx +40 -6
- package/src/tui/next.ts +12 -1
- package/src/tui/palette.tsx +6 -2
- package/src/tui/transcript.tsx +78 -2
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
|
|
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
|
|-----|--------|
|
package/docs/user-guide.html
CHANGED
|
@@ -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 ("rr" finds <em>Refine my request</em>, "auth" 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
|
|
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: "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 <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 ("post the report to the release channel") 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
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:
|
|
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.
|
package/src/brain/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/brain/prompt.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
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, "<")
|
|
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
|
|
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
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
{
|
|
1558
|
-
|
|
1559
|
-
|
|
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}
|
package/src/tui/components.tsx
CHANGED
|
@@ -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 = {
|
package/src/tui/inspector.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/tui/palette.tsx
CHANGED
|
@@ -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")
|
package/src/tui/transcript.tsx
CHANGED
|
@@ -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
|
-
<
|
|
417
|
+
<ReplyBody text={line.text} />
|
|
342
418
|
</box>
|
|
343
419
|
)
|
|
344
420
|
}
|