@capagents/forgeqe 1.1.8 → 1.1.9
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 +16 -1
- package/docs/user-guide.html +15 -5
- package/package.json +1 -1
- package/src/code/exchange.ts +53 -0
- package/src/code/loop.ts +15 -3
- package/src/detail.ts +2 -1
- package/src/print.ts +9 -0
- package/src/tui/App.tsx +23 -6
- package/src/tui/components.tsx +4 -3
- package/src/tui/text.ts +16 -0
- package/src/tui/theme.ts +25 -26
- package/src/tui/transcript.tsx +22 -9
- package/src/types.ts +6 -0
package/HOW_TO_USE.md
CHANGED
|
@@ -647,7 +647,22 @@ What happens after the run passes:
|
|
|
647
647
|
4. It writes the test from the handoff and the replay script: steps that worked are ported with their locators, steps that failed are fixed first. Every locator, including those for assertions, comes from the replay script, the page snapshots, page memory, or a check on the page; steps where the browser agent ran JavaScript to look around are left out. It does not walk the flow again or build tests to explore. Once a step fails (in the replay, or in its test), ForgeQE switches on its live browser: a Chrome that Playwright drives, set up like ForgeQE's, where it looks at the failing step's page one tool call per action (open, snapshot, click, type, wait, screenshot, run JavaScript, console errors, failed requests). Until then the live browser tools are off, so no tokens go into looking at pages that work. The live browser needs Node.js (`npx`), and Microsoft's Playwright MCP server is downloaded on first use; with `live_browser: false`, or without `npx`, it looks with a small throwaway script instead. When its browser gets a different page than ForgeQE's did (an error, a challenge, a control that will not open), it compares the two browsers (user agent, `navigator.webdriver`, headless, failed requests and CORS errors, headers the framework adds, console errors) and fixes the difference in the repo's browser configuration. It never judges the site with `curl` or `webfetch`.
|
|
648
648
|
5. Scribe does not run the test itself: it may build or compile, then reports the test file and the command that runs only that test, and ForgeQE runs it. If one command it runs fails `stall_after` times in a row within its work (the same build again and again, say), ForgeQE stops that turn and sends the command and its last output back, asking it to change approach. If the same command stalls again, the job stops as a failure with that output in the notes.
|
|
649
649
|
6. ForgeQE runs that command (with `CI=1`) and checks the exit code, and it watches the processes the command starts for a browser launched by Playwright, Puppeteer, Selenium, or Cypress. On a failure it sends the command, exit code, and output back to Scribe. The output is kept short so the error is never cut off: the page dump Playwright puts into a failed assertion ("Aria snapshot:", often thousands of lines) is taken out and saved as `<run>/test-runs/attempt-N-page.aria.yml`, then the start (where the first error and its stack trace are) and the end (the summary) are sent; the full output is in `<run>/test-runs/attempt-N.log`, and the prompt names both files. A command that exits 0 without starting a browser also goes back: the test must drive the app in a browser. If the repo's browser runs on another machine (Selenium Grid, a cloud service, a container), the agent says where and the check is skipped.
|
|
650
|
-
7. A passing test then goes to a separate reviewer agent, which can read files and `git diff` but not change anything. It checks the change against the goal and the test rules: every part of the goal done through the page and asserted, generic page objects with data in the test, no values or URLs copied from the run, browser settings in the repo's configuration, no leftover files. The changed files come inline in its prompt, so it reads nothing again, and it does not ask to replace locators or texts that came from the replay or the checks, since those matched the live site. It judges only what this job changed: ForgeQE records the working tree before the job, so your uncommitted edits, your untracked files, and ForgeQE's own files (`.forgeqe/`, `forgeqe.yaml`, `llms.json`) are never part of the review. It checks the diff of every file that existed before the job: removing or rewriting an existing test, even a commented-out one, is a correctness problem. Credentials come from the environment, except a public demo login that the application itself shows to anyone (such as the logins on saucedemo.com's sign-in page), which may stay in the repo's test data. It gets the outlines of the classes the changed code uses, the repo's browser settings, and the same `code_*` tools, so it checks reuse and settings without reading those files. The code graph also tells it what the job added that it can check in the code: a new member that locates an element an existing member already locates (to reuse instead), and new XPath or position-based locators. If it finds problems, they go back to Scribe as the next attempt.
|
|
650
|
+
7. A passing test then goes to a separate reviewer agent, which can read files and `git diff` but not change anything. It checks the change against the goal and the test rules: every part of the goal done through the page and asserted, generic page objects with data in the test, no values or URLs copied from the run, browser settings in the repo's configuration, no leftover files. The changed files come inline in its prompt, so it reads nothing again, and it does not ask to replace locators or texts that came from the replay or the checks, since those matched the live site. It judges only what this job changed: ForgeQE records the working tree before the job, so your uncommitted edits, your untracked files, and ForgeQE's own files (`.forgeqe/`, `forgeqe.yaml`, `llms.json`) are never part of the review. It checks the diff of every file that existed before the job: removing or rewriting an existing test, even a commented-out one, is a correctness problem. Credentials come from the environment, except a public demo login that the application itself shows to anyone (such as the logins on saucedemo.com's sign-in page), which may stay in the repo's test data. It gets the outlines of the classes the changed code uses, the repo's browser settings, and the same `code_*` tools, so it checks reuse and settings without reading those files. The code graph also tells it what the job added that it can check in the code: a new member that locates an element an existing member already locates (to reuse instead), and new XPath or position-based locators. If it finds problems, they go back to Scribe as the next attempt. The terminal shows the exchange as it happens, in order: the reviewer's verdict with its reason and each issue (✗ correctness, ~ coding standard) with the file and the fix it asks for; then why Scribe tries again and what it is fixing (the reviewer's issues, or the test's first error); then what Scribe says it did and which files it changed; then the next verdict:
|
|
651
|
+
|
|
652
|
+
```
|
|
653
|
+
⏺ Reviewer asked for changes attempt 1 of 5 · 1 to fix, 1 coding-standard
|
|
654
|
+
⎿ The test never checks that the pie chart is shown.
|
|
655
|
+
✗ Correctness · tests/PracticeTests.cs: No assertion on the SVG pie chart.
|
|
656
|
+
Fix: Assert the chart's heading and SVG are visible.
|
|
657
|
+
~ Coding standard · tests/PracticeTests.cs: Test data is inline.
|
|
658
|
+
Fix: Move it to the page object.
|
|
659
|
+
⏺ Scribe tries again · attempt 2 of 5 the reviewer asked for changes
|
|
660
|
+
⎿ → tests/PracticeTests.cs: No assertion on the SVG pie chart. — fix: Assert the chart's heading and SVG are visible.
|
|
661
|
+
⎿ Scribe (attempt 2): Added the pie chart assertion · changed tests/PracticeTests.cs, Pages/PracticePage.cs
|
|
662
|
+
⏺ Reviewer approved the test attempt 2 of 5
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
The Activities panel has one line for each verdict and attempt, and `forgeqe` commands outside the terminal UI print the same blocks.
|
|
651
666
|
|
|
652
667
|
Scribe cannot edit ForgeQE's files or run folders, and cannot run `git clean`, `git reset --hard`, `git stash`, `git restore`, or `git checkout -- <path>`. If it still removes a file that existed before the job, or reverts your uncommitted edits, ForgeQE puts the file back after that attempt and tells the agent.
|
|
653
668
|
8. The job is done when ForgeQE's run passes with a browser and the reviewer approves. It stops as a failure after `max_attempts` attempts, when the job passes `code.max_tokens`, when the reviewer rejects a passing test twice, if the review cannot finish, when the test fails the same way three times in a row (the same output apart from numbers such as timings and line numbers; the second time, Scribe is told its change did not change the failure and to look at the failing step before changing code), or straight away on an auth, API, or context-limit error from the model.
|
package/docs/user-guide.html
CHANGED
|
@@ -1302,13 +1302,23 @@ Acceptance criteria:
|
|
|
1302
1302
|
<p>ForgeQE runs that command (with <code>CI=1</code>) and checks the exit code, and it watches the processes the command starts for a browser launched by Playwright, Puppeteer, Selenium, or Cypress. On a failure it sends the command, exit code, and output back to Scribe. The output is kept short so the error is never cut off: the page dump Playwright puts into a failed assertion ("Aria snapshot:", often thousands of lines) is taken out and saved as <code><run>/test-runs/attempt-N-page.aria.yml</code>, then the start (where the first error and its stack trace are) and the end (the summary) are sent; the full output is in <code><run>/test-runs/attempt-N.log</code>, and the prompt names both files. A command that exits 0 without starting a browser also goes back: the test must drive the app in a browser. If the repo's browser runs on another machine (Selenium Grid, a cloud service, a container), the agent says where and the check is skipped.</p>
|
|
1303
1303
|
</li>
|
|
1304
1304
|
<li>
|
|
1305
|
-
<p>A passing test then goes to a separate reviewer agent, which can read files and <code>git diff</code> but not change anything. It checks the change against the goal and the test rules: every part of the goal done through the page and asserted, generic page objects with data in the test, no values or URLs copied from the run, browser settings in the repo's configuration, no leftover files. The changed files come inline in its prompt, so it reads nothing again, and it does not ask to replace locators or texts that came from the replay or the checks, since those matched the live site. It judges only what this job changed: ForgeQE records the working tree before the job, so your uncommitted edits, your untracked files, and ForgeQE's own files (<code>.forgeqe/</code>, <code>forgeqe.yaml</code>, <code>llms.json</code>) are never part of the review. It checks the diff of every file that existed before the job: removing or rewriting an existing test, even a commented-out one, is a correctness problem. Credentials come from the environment, except a public demo login that the application itself shows to anyone (such as the logins on saucedemo.com's sign-in page), which may stay in the repo's test data. It gets the outlines of the classes the changed code uses, the repo's browser settings, and the same <code>code_*</code> tools, so it checks reuse and settings without reading those files. The code graph also tells it what the job added that it can check in the code: a new member that locates an element an existing member already locates (to reuse instead), and new XPath or position-based locators. If it finds problems, they go back to Scribe as the next attempt
|
|
1306
|
-
<p>Scribe cannot edit ForgeQE's files or run folders, and cannot run <code>git clean</code>, <code>git reset --hard</code>, <code>git stash</code>, <code>git restore</code>, or <code>git checkout -- <path></code>. If it still removes a file that existed before the job, or reverts your uncommitted edits, ForgeQE puts the file back after that attempt and tells the agent.</p>
|
|
1307
|
-
</li>
|
|
1308
|
-
<li>
|
|
1309
|
-
<p>The job is done when ForgeQE's run passes with a browser and the reviewer approves. It stops as a failure after <code>max_attempts</code> attempts, when the job passes <code>code.max_tokens</code>, when the reviewer rejects a passing test twice, if the review cannot finish, when the test fails the same way three times in a row (the same output apart from numbers such as timings and line numbers; the second time, Scribe is told its change did not change the failure and to look at the failing step before changing code), or straight away on an auth, API, or context-limit error from the model.</p>
|
|
1305
|
+
<p>A passing test then goes to a separate reviewer agent, which can read files and <code>git diff</code> but not change anything. It checks the change against the goal and the test rules: every part of the goal done through the page and asserted, generic page objects with data in the test, no values or URLs copied from the run, browser settings in the repo's configuration, no leftover files. The changed files come inline in its prompt, so it reads nothing again, and it does not ask to replace locators or texts that came from the replay or the checks, since those matched the live site. It judges only what this job changed: ForgeQE records the working tree before the job, so your uncommitted edits, your untracked files, and ForgeQE's own files (<code>.forgeqe/</code>, <code>forgeqe.yaml</code>, <code>llms.json</code>) are never part of the review. It checks the diff of every file that existed before the job: removing or rewriting an existing test, even a commented-out one, is a correctness problem. Credentials come from the environment, except a public demo login that the application itself shows to anyone (such as the logins on saucedemo.com's sign-in page), which may stay in the repo's test data. It gets the outlines of the classes the changed code uses, the repo's browser settings, and the same <code>code_*</code> tools, so it checks reuse and settings without reading those files. The code graph also tells it what the job added that it can check in the code: a new member that locates an element an existing member already locates (to reuse instead), and new XPath or position-based locators. If it finds problems, they go back to Scribe as the next attempt. The terminal shows the exchange as it happens, in order: the reviewer's verdict with its reason and each issue (✗ correctness, ~ coding standard) with the file and the fix it asks for; then why Scribe tries again and what it is fixing (the reviewer's issues, or the test's first error); then what Scribe says it did and which files it changed; then the next verdict:</p>
|
|
1310
1306
|
</li>
|
|
1311
1307
|
</ol>
|
|
1308
|
+
<pre><code>⏺ Reviewer asked for changes attempt 1 of 5 · 1 to fix, 1 coding-standard
|
|
1309
|
+
⎿ The test never checks that the pie chart is shown.
|
|
1310
|
+
✗ Correctness · tests/PracticeTests.cs: No assertion on the SVG pie chart.
|
|
1311
|
+
Fix: Assert the chart's heading and SVG are visible.
|
|
1312
|
+
~ Coding standard · tests/PracticeTests.cs: Test data is inline.
|
|
1313
|
+
Fix: Move it to the page object.
|
|
1314
|
+
⏺ Scribe tries again · attempt 2 of 5 the reviewer asked for changes
|
|
1315
|
+
⎿ → tests/PracticeTests.cs: No assertion on the SVG pie chart. — fix: Assert the chart's heading and SVG are visible.
|
|
1316
|
+
⎿ Scribe (attempt 2): Added the pie chart assertion · changed tests/PracticeTests.cs, Pages/PracticePage.cs
|
|
1317
|
+
⏺ Reviewer approved the test attempt 2 of 5
|
|
1318
|
+
</code></pre>
|
|
1319
|
+
<p>The Activities panel has one line for each verdict and attempt, and <code>forgeqe</code> commands outside the terminal UI print the same blocks.</p>
|
|
1320
|
+
<p>Scribe cannot edit ForgeQE's files or run folders, and cannot run <code>git clean</code>, <code>git reset --hard</code>, <code>git stash</code>, <code>git restore</code>, or <code>git checkout -- <path></code>. If it still removes a file that existed before the job, or reverts your uncommitted edits, ForgeQE puts the file back after that attempt and tells the agent.
|
|
1321
|
+
8. The job is done when ForgeQE's run passes with a browser and the reviewer approves. It stops as a failure after <code>max_attempts</code> attempts, when the job passes <code>code.max_tokens</code>, when the reviewer rejects a passing test twice, if the review cannot finish, when the test fails the same way three times in a row (the same output apart from numbers such as timings and line numbers; the second time, Scribe is told its change did not change the failure and to look at the failing step before changing code), or straight away on an auth, API, or context-limit error from the model.</p>
|
|
1312
1322
|
<p><code>blocked</code> is a last resort. The first time the agent reports that the app blocks it, ForgeQE sends it back to compare its browser with ForgeQE's and fix the difference; only a second blocked report, with what it compared, ends the job as blocked.</p>
|
|
1313
1323
|
<p>The transcript shows the handover and Scribe's work as it goes; files it writes or edits are marked ✎:</p>
|
|
1314
1324
|
<pre><code>┃
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capagents/forgeqe",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.9",
|
|
4
4
|
"description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Card } from "../card.ts"
|
|
2
|
+
import type { CodeEvent } from "../types.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The exchange between Scribe and the reviewer, as the shell and the CLI show it: what the reviewer found and why,
|
|
6
|
+
* why Scribe tries again and what it is fixing, and what Scribe says it did. One place, so both say the same.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
type Review = Extract<CodeEvent, { kind: "review" }>
|
|
10
|
+
type Attempt = Extract<CodeEvent, { kind: "attempt" }>
|
|
11
|
+
type Answer = Extract<CodeEvent, { kind: "answer" }>
|
|
12
|
+
|
|
13
|
+
const clip = (t: string, n: number) => {
|
|
14
|
+
const s = t.replace(/\s+/g, " ").trim()
|
|
15
|
+
return s.length <= n ? s : `${s.slice(0, n - 1)}…`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** "✗ Does not test the goal · tests/Practice.cs: the pie chart is never checked", then the fix under it. */
|
|
19
|
+
export function reviewCard(e: Review): Card {
|
|
20
|
+
const correctness = e.issues.filter((i) => i.kind !== "standards").length
|
|
21
|
+
const standards = e.issues.length - correctness
|
|
22
|
+
const count = [correctness ? `${correctness} to fix` : "", standards ? `${standards} coding-standard` : ""].filter(Boolean).join(", ")
|
|
23
|
+
return {
|
|
24
|
+
status: e.approved ? "ok" : "fail",
|
|
25
|
+
title: e.approved ? (standards ? "Reviewer approved the test, with coding-standard notes" : "Reviewer approved the test") : "Reviewer asked for changes",
|
|
26
|
+
stats: [`attempt ${e.attempt} of ${e.max}`, count].filter(Boolean).join(" · "),
|
|
27
|
+
body: [
|
|
28
|
+
...(e.summary ? [clip(e.summary, 400)] : []),
|
|
29
|
+
...(e.issues.length ? [""] : []),
|
|
30
|
+
...e.issues.flatMap((i) => [
|
|
31
|
+
// ✗ marks what makes the test wrong, ~ how it is written: the colours follow the mark.
|
|
32
|
+
`${i.kind === "standards" ? "~ Coding standard" : "✗ Correctness"}${i.file ? ` · ${i.file}` : ""}: ${clip(i.problem, 300)}`,
|
|
33
|
+
...(i.fix ? [` Fix: ${clip(i.fix, 300)}`] : []),
|
|
34
|
+
]),
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** "Scribe · attempt 2 of 5 · the reviewer asked for changes", then each thing it was asked to fix. */
|
|
40
|
+
export function attemptCard(e: Attempt): Card {
|
|
41
|
+
return {
|
|
42
|
+
status: "info",
|
|
43
|
+
title: `Scribe tries again · attempt ${e.attempt} of ${e.max}`,
|
|
44
|
+
...(e.why ? { stats: e.why } : {}),
|
|
45
|
+
body: e.fixing.map((f) => `→ ${clip(f, 300)}`),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** "Scribe (attempt 2): Moved the values into PracticePage · changed tests/Practice.cs, pages/PracticePage.cs". */
|
|
50
|
+
export function answerText(e: Answer): string {
|
|
51
|
+
const files = e.changed.length ? `changed ${e.changed.slice(0, 4).join(", ")}${e.changed.length > 4 ? ` +${e.changed.length - 4} more` : ""}` : ""
|
|
52
|
+
return `Scribe (attempt ${e.attempt}): ${[e.notes ? clip(e.notes, 300) : "", files].filter(Boolean).join(" · ")}`
|
|
53
|
+
}
|
package/src/code/loop.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
standardsFixPrompt,
|
|
39
39
|
type AgentReport,
|
|
40
40
|
type ExistingTest,
|
|
41
|
+
type ReviewIssue,
|
|
41
42
|
} from "./prompt.ts"
|
|
42
43
|
import { openReviewer, type Reviewer } from "./review.ts"
|
|
43
44
|
import { runTestCommand } from "./testrun.ts"
|
|
@@ -254,6 +255,9 @@ export async function runCodeAgent(opts: {
|
|
|
254
255
|
let spent: Usage = { input: 0, output: 0 }
|
|
255
256
|
const attemptLog: string[] = []
|
|
256
257
|
let nextTask: string | null = null
|
|
258
|
+
/** Why the next attempt runs and what it is to fix, as the shell shows it: "the reviewer asked for changes", its issues. */
|
|
259
|
+
let next: { why: string; fixing: string[] } = { why: "", fixing: [] }
|
|
260
|
+
const issueText = (i: ReviewIssue) => `${i.file ? `${i.file}: ` : ""}${i.problem}${i.fix ? ` — fix: ${i.fix}` : ""}`
|
|
257
261
|
let notesText: string | undefined = known ? notesSection(known) : undefined
|
|
258
262
|
// The repository's test setup, read by ForgeQE without a model; Scribe starts from it instead of a reading turn.
|
|
259
263
|
const repoProfile = buildProfile(repo)
|
|
@@ -374,7 +378,7 @@ export async function runCodeAgent(opts: {
|
|
|
374
378
|
session = await (opts.open || openSession)(sessionOptions())
|
|
375
379
|
result.sessionId = session.sessionId
|
|
376
380
|
saveSession()
|
|
377
|
-
emit({ type: "code", kind: "
|
|
381
|
+
emit({ type: "code", kind: "attempt", attempt, max: allowed, why: next.why, fixing: next.fixing })
|
|
378
382
|
prompt = nextAttemptPrompt({
|
|
379
383
|
handoff,
|
|
380
384
|
notes: [profileText, notesText].filter(Boolean).join("\n\n") || undefined,
|
|
@@ -417,6 +421,8 @@ export async function runCodeAgent(opts: {
|
|
|
417
421
|
break
|
|
418
422
|
}
|
|
419
423
|
if (reply.report) report = { ...report, ...reply.report }
|
|
424
|
+
// What Scribe says it did this time, so a retry is not only "Editing …" lines.
|
|
425
|
+
if (reply.report && (reply.report.notes || reply.report.changed?.length)) emit({ type: "code", kind: "answer", attempt, ...(reply.report.notes ? { notes: reply.report.notes } : {}), changed: reply.report.changed || [] })
|
|
420
426
|
result.notes = report.notes || reply.text.slice(0, 500)
|
|
421
427
|
if (reply.error && FATAL_ERRORS.some((name) => reply.error!.startsWith(name))) {
|
|
422
428
|
result.notes = reply.error
|
|
@@ -477,6 +483,7 @@ export async function runCodeAgent(opts: {
|
|
|
477
483
|
emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} exited 0 in ${secs}s but opened no browser (attempt ${attempt}/${maxAttempts})` })
|
|
478
484
|
attemptLog.push(attemptLine(attempt, "exited 0 but opened no browser", "", report.notes))
|
|
479
485
|
nextTask = noBrowserPrompt(report.command)
|
|
486
|
+
next = { why: "the test passed without opening a browser", fixing: ["Make the test drive a real browser, as the run did"] }
|
|
480
487
|
continue
|
|
481
488
|
}
|
|
482
489
|
if (run.ok) {
|
|
@@ -501,6 +508,8 @@ export async function runCodeAgent(opts: {
|
|
|
501
508
|
result.review = verdict.review
|
|
502
509
|
const correctness = verdict.review.issues.filter((i) => i.kind !== "standards")
|
|
503
510
|
const standards = verdict.review.issues.filter((i) => i.kind === "standards")
|
|
511
|
+
// The verdict as the reviewer gave it: with only coding-standard issues the test is approved and gets a tidy-up.
|
|
512
|
+
emit({ type: "code", kind: "review", attempt, max: allowed, approved: verdict.review.approved || !correctness.length, summary: verdict.review.summary, issues: verdict.review.issues })
|
|
504
513
|
// Coding standards alone never fail a test that works and tests the goal.
|
|
505
514
|
if (tidying && correctness.length) {
|
|
506
515
|
// The tidy-up changed what the test does: back to the version that passed.
|
|
@@ -515,6 +524,7 @@ export async function runCodeAgent(opts: {
|
|
|
515
524
|
emit({ type: "code", kind: "test", ok: true, attempt, text: `${report.test_file} passed and tests the goal; the reviewer found ${standards.length} coding-standard ${standards.length === 1 ? "issue" : "issues"}, so the agent fixes ${standards.length === 1 ? "it" : "them"} in one more round (the passing version is kept if that breaks it)` })
|
|
516
525
|
attemptLog.push(attemptLine(attempt, "passed and tests the goal; coding-standard issues to fix", standards.map((i) => i.problem).join(" "), report.notes))
|
|
517
526
|
nextTask = standardsFixPrompt(standards)
|
|
527
|
+
next = { why: "the reviewer approved the test; one round for its coding-standard issues", fixing: standards.map(issueText) }
|
|
518
528
|
continue
|
|
519
529
|
}
|
|
520
530
|
result.status = "passed"
|
|
@@ -526,8 +536,7 @@ export async function runCodeAgent(opts: {
|
|
|
526
536
|
emit({ type: "code", kind: "test", ok: true, attempt, text: `${report.test_file} passed and was approved (attempt ${attempt}/${allowed})${standards.length ? `; ${standards.length} coding-standard ${standards.length === 1 ? "issue" : "issues"} left in the notes` : ""}` })
|
|
527
537
|
break
|
|
528
538
|
}
|
|
529
|
-
|
|
530
|
-
emit({ type: "code", kind: "test", ok: false, attempt, text: `review found ${n} ${n === 1 ? "issue" : "issues"}: ${verdict.review.summary} (attempt ${attempt}/${maxAttempts})` })
|
|
539
|
+
// The review event above says what was found; the rejection itself is its verdict.
|
|
531
540
|
// A second rejection usually means the goal and the page disagree (a test case the page cannot satisfy), not a
|
|
532
541
|
// fix the next attempt will find: stop and report the issues instead of spending the remaining attempts.
|
|
533
542
|
if (++rejections >= MAX_REJECTIONS) {
|
|
@@ -537,6 +546,7 @@ export async function runCodeAgent(opts: {
|
|
|
537
546
|
}
|
|
538
547
|
attemptLog.push(attemptLine(attempt, "passed; the reviewer asked for changes", verdict.review.summary, report.notes))
|
|
539
548
|
nextTask = reviewFixPrompt(verdict.review.summary, verdict.review.issues)
|
|
549
|
+
next = { why: "the reviewer asked for changes", fixing: verdict.review.issues.map(issueText) }
|
|
540
550
|
continue
|
|
541
551
|
}
|
|
542
552
|
if (tidying) {
|
|
@@ -558,11 +568,13 @@ export async function runCodeAgent(opts: {
|
|
|
558
568
|
emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} failed the same way as the last attempt, ${why} (attempt ${attempt}/${maxAttempts})` })
|
|
559
569
|
attemptLog.push(attemptLine(attempt, `failed, ${why}, the same way as before`, firstError(run.output), report.notes))
|
|
560
570
|
nextTask = repeatedFailurePrompt(sameFailures + 1, report.command, run.exitCode, run.output, files) + mismatch + switchLiveOn()
|
|
571
|
+
next = { why: `the test failed the same way again (${why}); Scribe looks at the failing step first`, fixing: [firstError(run.output)].filter(Boolean) }
|
|
561
572
|
continue
|
|
562
573
|
}
|
|
563
574
|
emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} failed, ${why} (attempt ${attempt}/${maxAttempts})` })
|
|
564
575
|
attemptLog.push(attemptLine(attempt, `failed, ${why}`, firstError(run.output), report.notes))
|
|
565
576
|
nextTask = fixPrompt(attempt, report.command, run.exitCode, run.output, files) + mismatch
|
|
577
|
+
next = { why: `the test failed (${why})`, fixing: [firstError(run.output)].filter(Boolean) }
|
|
566
578
|
}
|
|
567
579
|
keepSession = result.status === "stopped" || runtimeFailed
|
|
568
580
|
if (report.test_name) result.testName = report.test_name
|
package/src/detail.ts
CHANGED
|
@@ -27,7 +27,8 @@ export function shows(event: AgentEvent | CodeEvent, detail: Detail): boolean {
|
|
|
27
27
|
case "memory":
|
|
28
28
|
return false
|
|
29
29
|
case "code":
|
|
30
|
-
|
|
30
|
+
// Milestones, and the exchange with the reviewer: why a test was rejected and what Scribe fixes.
|
|
31
|
+
if (event.kind === "info" || event.kind === "test" || event.kind === "review" || event.kind === "attempt" || event.kind === "answer") return true
|
|
31
32
|
if (event.kind === "tool") return !event.ok || WRITES_FILE.test(event.text)
|
|
32
33
|
return false
|
|
33
34
|
default:
|
package/src/print.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { cardText } from "./card.ts"
|
|
2
|
+
import { answerText, attemptCard, reviewCard } from "./code/exchange.ts"
|
|
1
3
|
import { briefText, shows, stepIntent, type Detail } from "./detail.ts"
|
|
2
4
|
import type { AgentEvent } from "./types.ts"
|
|
3
5
|
|
|
@@ -45,6 +47,13 @@ export function formatEvent(event: AgentEvent, color = true, detail: Detail = "b
|
|
|
45
47
|
return paint(c.cyan, ` ${event.text}`)
|
|
46
48
|
case "test":
|
|
47
49
|
return event.ok ? paint(c.green, `✓ ${event.text}`) : paint(c.red, `✗ ${event.text}`)
|
|
50
|
+
// The reviewer's verdict and Scribe's retries, in full: why it was rejected, and what is being fixed.
|
|
51
|
+
case "review":
|
|
52
|
+
return paint(event.approved ? c.green : c.red, cardText(reviewCard(event)))
|
|
53
|
+
case "attempt":
|
|
54
|
+
return paint(c.magenta, cardText(attemptCard(event)))
|
|
55
|
+
case "answer":
|
|
56
|
+
return paint(c.cyan, ` ${answerText(event)}`)
|
|
48
57
|
default:
|
|
49
58
|
return null
|
|
50
59
|
}
|
package/src/tui/App.tsx
CHANGED
|
@@ -30,10 +30,11 @@ import { initWorkspace } from "../init.ts"
|
|
|
30
30
|
import { ShortcutsPanel } from "./shortcuts.tsx"
|
|
31
31
|
import { ago, getStarted, projectLine, setupSentence, type Fact, type GetStarted, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
|
|
32
32
|
import { TIPS, formatDuration, formatTokens, packageVersion, shortCwd, theme } from "./theme.ts"
|
|
33
|
-
import { emptyProduced, Line, Produced, producedCard, settleCall, sinceRequest, TestsSetting, TranscriptLine, trimLines } from "./transcript.tsx"
|
|
33
|
+
import { emptyProduced, Line, LineRoom, Produced, producedCard, settleCall, sinceRequest, TestsSetting, TranscriptLine, trimLines } from "./transcript.tsx"
|
|
34
34
|
import { Dialog, dialogSpec } from "./dialogs.ts"
|
|
35
35
|
import { activityFromLog, clip, modelLabel } from "./text.ts"
|
|
36
36
|
import { featuresText } from "../capabilities/render.ts"
|
|
37
|
+
import { answerText, attemptCard, reviewCard } from "../code/exchange.ts"
|
|
37
38
|
import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
|
|
38
39
|
import { commandSuggestions, complete, lookupWorkItem, recentRuns, referenceSuggestions, repoFiles, triggerAt, type Suggestion, type WorkItem } from "./suggest.ts"
|
|
39
40
|
import { initialChoice, REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
|
|
@@ -988,6 +989,17 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
988
989
|
else if (ev.kind === "say") record("thought", ev.text, shown)
|
|
989
990
|
else if (ev.kind === "tool") pushTool(ev.text, ev.ok, ev.id, shown)
|
|
990
991
|
else if (ev.kind === "test") record(ev.ok ? "ok" : "err", ev.text, shown)
|
|
992
|
+
// The exchange with the reviewer: its verdict and Scribe's retries as cards in the conversation, each with a
|
|
993
|
+
// line in Activity, so it is clear why a test was rejected and what is being fixed.
|
|
994
|
+
else if (ev.kind === "review") {
|
|
995
|
+
const card = reviewCard(ev)
|
|
996
|
+
pushLine({ kind: "card", text: card.title, card })
|
|
997
|
+
record(ev.approved ? "ok" : "err", `Review, attempt ${ev.attempt}/${ev.max}: ${ev.approved ? "approved" : `changes needed (${ev.issues.length})`} · ${clip(ev.summary, 140)}`, false)
|
|
998
|
+
} else if (ev.kind === "attempt") {
|
|
999
|
+
const card = attemptCard(ev)
|
|
1000
|
+
pushLine({ kind: "card", text: card.title, card })
|
|
1001
|
+
record("code", `Attempt ${ev.attempt}/${ev.max}: ${ev.why}${ev.fixing.length ? ` · fixing ${ev.fixing.length}` : ""}`, false)
|
|
1002
|
+
} else if (ev.kind === "answer") record("code", answerText(ev), true)
|
|
991
1003
|
}
|
|
992
1004
|
if (aborted()) return
|
|
993
1005
|
const who = codingRef.current ? "Coding agent" : "Agent"
|
|
@@ -995,6 +1007,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
995
1007
|
else if (ev.kind === "tool") setActivity(`${who}: ${clip(ev.text, 60)}`)
|
|
996
1008
|
else if (ev.kind === "info" && ev.text.startsWith("ForgeQE runs the test")) setActivity("ForgeQE is running the new test…")
|
|
997
1009
|
else if (ev.kind === "say") setActivity(`${who} is working…`)
|
|
1010
|
+
else if (ev.kind === "review") setActivity(ev.approved ? "The reviewer approved the test" : `Scribe is fixing ${ev.issues.length} review ${ev.issues.length === 1 ? "issue" : "issues"}…`)
|
|
1011
|
+
else if (ev.kind === "attempt") setActivity(`Scribe, attempt ${ev.attempt} of ${ev.max}: ${clip(ev.why, 50)}…`)
|
|
998
1012
|
return
|
|
999
1013
|
}
|
|
1000
1014
|
case "code-start": {
|
|
@@ -1626,7 +1640,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1626
1640
|
const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
|
|
1627
1641
|
const recentRows = recent.length ? 1 + 1 + recent.length : 0
|
|
1628
1642
|
const setupRows = loading || open.length ? 2 : 0
|
|
1629
|
-
const fixed =
|
|
1643
|
+
const fixed = 4 + 2 + 1 + 3 + 1 + 1 + 1 // logo and byline, project line, gap, prompt border, buttons, footer, one row to spare
|
|
1630
1644
|
const room = height - fixed
|
|
1631
1645
|
const want = Math.max(5, Math.min(promptLines, room - 1))
|
|
1632
1646
|
// Refine or a question in the prompt's place takes the room the sections would: they are for starting, not deciding.
|
|
@@ -1661,7 +1675,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1661
1675
|
</box>
|
|
1662
1676
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1663
1677
|
<box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0 }}>
|
|
1664
|
-
{renderControls(promptWidth, "Describe what you want to test…", [5, maxRows], height -
|
|
1678
|
+
{renderControls(promptWidth, "Describe what you want to test…", [5, maxRows], height - 4 - 2 - 1 - 1 - 1)}
|
|
1665
1679
|
{showStart ? (
|
|
1666
1680
|
<box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
|
|
1667
1681
|
{heading(offerTitle)}
|
|
@@ -1813,10 +1827,12 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1813
1827
|
)
|
|
1814
1828
|
}
|
|
1815
1829
|
|
|
1816
|
-
|
|
1830
|
+
/** `room`: the columns a line of the conversation has (less the scrollbar), so long links are drawn to fit. */
|
|
1831
|
+
const transcript = (every: Line[], style: Record<string, unknown>, room: number) => {
|
|
1817
1832
|
// Quiet lines (reads, searches, calls in progress) are the Activity panel's; the conversation stays readable.
|
|
1818
1833
|
const all = every.filter((l) => !l.quiet)
|
|
1819
1834
|
return (
|
|
1835
|
+
<LineRoom.Provider value={Math.max(20, room - 1)}>
|
|
1820
1836
|
<scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
|
|
1821
1837
|
{all.map((line, i) => (
|
|
1822
1838
|
<TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
|
|
@@ -1832,6 +1848,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1832
1848
|
</box>
|
|
1833
1849
|
) : null}
|
|
1834
1850
|
</scrollbox>
|
|
1851
|
+
</LineRoom.Provider>
|
|
1835
1852
|
)
|
|
1836
1853
|
}
|
|
1837
1854
|
// A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
|
|
@@ -1847,7 +1864,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1847
1864
|
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1848
1865
|
<box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1849
1866
|
{infoPanels(leftWidth - 3)}
|
|
1850
|
-
{transcript(chatLines, {})}
|
|
1867
|
+
{transcript(chatLines, {}, leftWidth - 3)}
|
|
1851
1868
|
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
|
|
1852
1869
|
</box>
|
|
1853
1870
|
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
@@ -1883,7 +1900,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1883
1900
|
}
|
|
1884
1901
|
return (
|
|
1885
1902
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1886
|
-
{transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 })}
|
|
1903
|
+
{transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 }, width - 4)}
|
|
1887
1904
|
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>{controls(promptWidth)}</box>
|
|
1888
1905
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1889
1906
|
{paletteOverlay}
|
package/src/tui/components.tsx
CHANGED
|
@@ -18,14 +18,15 @@ export const BAR = {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export function Logo() {
|
|
21
|
-
|
|
21
|
+
// One wordmark in capitals, FORGE in the text colour and QE in the accent: one height, weight, and baseline.
|
|
22
|
+
const name = logoRows("FORGE")
|
|
22
23
|
const suffix = logoRows("QE")
|
|
23
24
|
return (
|
|
24
25
|
<box style={{ flexDirection: "column", alignItems: "flex-end", flexShrink: 0 }}>
|
|
25
26
|
{name.map((row, i) => (
|
|
26
27
|
<text key={i}>
|
|
27
|
-
<span fg={theme.
|
|
28
|
-
<span fg={theme.
|
|
28
|
+
<span fg={theme.text}>{row}</span>
|
|
29
|
+
<span fg={theme.accent}>{` ${suffix[i]}`}</span>
|
|
29
30
|
</text>
|
|
30
31
|
))}
|
|
31
32
|
<text fg={theme.muted}>by Capgemini</text>
|
package/src/tui/text.ts
CHANGED
|
@@ -31,3 +31,19 @@ export function activityFromLog(text: string): string {
|
|
|
31
31
|
if (text.startsWith("ForgeQE engine ·")) return "Starting the browser…"
|
|
32
32
|
return clip(text, 70)
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A link's text shortened to `max` columns, so it never wraps into what reads as two links. The start and the last
|
|
37
|
+
* part (the file, or the folder of a path ending in "/") are kept: ".forgeqe/reports/open-defect-rep…/report.html".
|
|
38
|
+
* The link itself keeps its whole target; only what is drawn is shorter.
|
|
39
|
+
*/
|
|
40
|
+
export function shortLink(text: string, max: number): string {
|
|
41
|
+
if (text.length <= max || max < 8) return text
|
|
42
|
+
const trimmed = text.replace(/\/$/, "")
|
|
43
|
+
const slash = trimmed.lastIndexOf("/")
|
|
44
|
+
const tail = slash > 0 ? text.slice(slash) : ""
|
|
45
|
+
// The last part, whole, when it leaves room for a useful start; otherwise both ends of the text.
|
|
46
|
+
if (tail && tail.length <= max - 10) return `${text.slice(0, max - tail.length - 1)}…${tail}`
|
|
47
|
+
const end = Math.floor((max - 1) / 2)
|
|
48
|
+
return `${text.slice(0, max - 1 - end)}…${text.slice(text.length - end)}`
|
|
49
|
+
}
|
package/src/tui/theme.ts
CHANGED
|
@@ -35,35 +35,34 @@ export function formatDuration(ms: number, precise = false): string {
|
|
|
35
35
|
return `${m}m ${String(Math.floor(s % 60)).padStart(2, "0")}s`
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
c: [" ", "█▀▀▀", "█ ", "▀▀▀▀"],
|
|
50
|
-
h: ["▄ ", "█▀▀█", "█ █", "▀ ▀"],
|
|
51
|
-
s: [" ", "█▀▀▀", "▀▀▀█", "▀▀▀▀"],
|
|
52
|
-
a: [" ", "▀▀▀█", "█▀▀█", "▀▀▀▀"],
|
|
53
|
-
f: [" ▄▀", "▀█▀", " █ ", " ▀ "],
|
|
54
|
-
g: [" ", "█▀▀█", "█ █", "▀▀▀█"],
|
|
55
|
-
Q: ["█▀▀█ ", "█ █ ", "█ █ ", "▀▀▀█▄"],
|
|
56
|
-
E: ["█▀▀▀", "█▄▄ ", "█ ", "▀▀▀▀"],
|
|
57
|
-
".": [" ", " ", " ", "▀"],
|
|
38
|
+
/**
|
|
39
|
+
* The wordmark's capitals as pixel maps, five pixels tall: every letter has the same weight, height and baseline. A
|
|
40
|
+
* sixth row, left blank, is the Q's tail. Two pixel rows go in one text row as half blocks, so it is three rows high.
|
|
41
|
+
*/
|
|
42
|
+
const LETTERS: Record<string, string[]> = {
|
|
43
|
+
F: ["####", "#...", "###.", "#...", "#..."],
|
|
44
|
+
O: ["####", "#..#", "#..#", "#..#", "####"],
|
|
45
|
+
R: ["####", "#..#", "###.", "#..#", "#..#"],
|
|
46
|
+
G: ["####", "#...", "#.##", "#..#", "####"],
|
|
47
|
+
E: ["####", "#...", "###.", "#...", "####"],
|
|
48
|
+
Q: ["####.", "#..#.", "#..#.", "#..#.", "####.", "...##"],
|
|
58
49
|
}
|
|
59
50
|
|
|
51
|
+
/** The letters of `word` (capitals; others are left out) as three rows of half blocks, one space between letters. */
|
|
60
52
|
export function logoRows(word: string): string[] {
|
|
61
|
-
const rows = ["", "", ""
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
53
|
+
const rows = ["", "", ""]
|
|
54
|
+
;[...word.toUpperCase()].filter((ch) => LETTERS[ch]).forEach((ch, index) => {
|
|
55
|
+
const px = LETTERS[ch]!
|
|
56
|
+
const at = (r: number, c: number) => px[r]?.[c] === "#"
|
|
57
|
+
for (let r = 0; r < 3; r++) {
|
|
58
|
+
let cells = ""
|
|
59
|
+
for (let c = 0; c < px[0]!.length; c++) {
|
|
60
|
+
const top = at(r * 2, c)
|
|
61
|
+
const bottom = at(r * 2 + 1, c)
|
|
62
|
+
cells += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " "
|
|
63
|
+
}
|
|
64
|
+
rows[r] += (index ? " " : "") + cells
|
|
65
|
+
}
|
|
67
66
|
})
|
|
68
67
|
return rows
|
|
69
68
|
}
|
package/src/tui/transcript.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs"
|
|
|
2
2
|
import os from "node:os"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import { SyntaxStyle } from "@opentui/core"
|
|
5
|
-
import type
|
|
5
|
+
import { createContext, useContext, type ReactNode } from "react"
|
|
6
6
|
|
|
7
7
|
import type { CodeResult, RunStatus } from "../types.ts"
|
|
8
8
|
import { CARD_MARK, type Card } from "../card.ts"
|
|
@@ -11,7 +11,7 @@ import { suiteExitCode, type SuiteResult } from "../suite/run.ts"
|
|
|
11
11
|
import { STATUS_MARK, shortPath, type RunSummary } from "../summary.ts"
|
|
12
12
|
import { BAR } from "./components.tsx"
|
|
13
13
|
import { formatDuration, formatTokens, theme } from "./theme.ts"
|
|
14
|
-
import { clip } from "./text.ts"
|
|
14
|
+
import { clip, shortLink } from "./text.ts"
|
|
15
15
|
|
|
16
16
|
/** The transcript: its lines, and the cards for a run, a suite, a tool result, and what a request produced. */
|
|
17
17
|
|
|
@@ -195,7 +195,7 @@ export function SummaryCard({ summary }: { summary: RunSummary }) {
|
|
|
195
195
|
{summary.savedDir ? (
|
|
196
196
|
<text>
|
|
197
197
|
<span fg={theme.faint}>{"Saved "}</span>
|
|
198
|
-
<Linked text={`${summary.savedDir}/ `} color={theme.muted} />
|
|
198
|
+
<Linked text={`${summary.savedDir}/ `} color={theme.muted} lead={UNDER + 10} />
|
|
199
199
|
<span fg={theme.faint}>{summary.savedFiles.join(" · ")}</span>
|
|
200
200
|
</text>
|
|
201
201
|
) : null}
|
|
@@ -218,14 +218,27 @@ export function linkTarget(token: string, cwd = process.cwd()): string | null {
|
|
|
218
218
|
* Text whose web addresses and existing file paths can be opened from the terminal (OSC 8 links: cmd/ctrl+click in
|
|
219
219
|
* VS Code and most terminals). A path that does not exist stays plain text, so no link leads nowhere.
|
|
220
220
|
*/
|
|
221
|
-
|
|
222
|
-
|
|
221
|
+
/** The columns a line of the conversation has, for links to fit in; 0 when not known (no shortening). */
|
|
222
|
+
export const LineRoom = createContext(0)
|
|
223
|
+
|
|
224
|
+
/** Columns before a card's line of text: the "⎿" gutter under the card's title. */
|
|
225
|
+
const UNDER = 4
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* `lead`: the columns before this text on its row (a gutter, a label in another span). A link longer than what is left
|
|
229
|
+
* of the row is drawn shortened, its target whole, so it moves to the next row in one piece instead of splitting.
|
|
230
|
+
*/
|
|
231
|
+
export function Linked({ text, color, lead = UNDER }: { text: string; color: string; lead?: number }) {
|
|
232
|
+
const room = useContext(LineRoom)
|
|
233
|
+
const parts: { text: string; href?: string; max?: number }[] = []
|
|
223
234
|
let at = 0
|
|
224
235
|
for (const m of text.matchAll(LINKABLE)) {
|
|
225
236
|
const href = linkTarget(m[0])
|
|
226
237
|
if (!href) continue
|
|
227
238
|
if (m.index! > at) parts.push({ text: text.slice(at, m.index) })
|
|
228
|
-
|
|
239
|
+
// Beside its label when a useful part fits there ("Page .forgeqe/…/report.html"), else alone on the next row.
|
|
240
|
+
const beside = room - lead - m.index! - 1
|
|
241
|
+
parts.push({ text: m[0], href, ...(room ? { max: beside >= 30 ? beside : room - lead - 1 } : {}) })
|
|
229
242
|
at = m.index! + m[0].length
|
|
230
243
|
}
|
|
231
244
|
if (at < text.length) parts.push({ text: text.slice(at) })
|
|
@@ -234,7 +247,7 @@ export function Linked({ text, color }: { text: string; color: string }) {
|
|
|
234
247
|
{parts.map((p, i) =>
|
|
235
248
|
p.href ? (
|
|
236
249
|
<a key={i} href={p.href} fg={theme.accent}>
|
|
237
|
-
{p.text}
|
|
250
|
+
{p.max ? shortLink(p.text, p.max) : p.text}
|
|
238
251
|
</a>
|
|
239
252
|
) : (
|
|
240
253
|
<span key={i} fg={color}>
|
|
@@ -251,7 +264,7 @@ export function Row({ label, value, color = theme.muted }: { label: string; valu
|
|
|
251
264
|
return (
|
|
252
265
|
<text>
|
|
253
266
|
<span fg={theme.faint}>{label.padEnd(10)}</span>
|
|
254
|
-
<Linked text={value} color={color} />
|
|
267
|
+
<Linked text={value} color={color} lead={UNDER + 10} />
|
|
255
268
|
</text>
|
|
256
269
|
)
|
|
257
270
|
}
|
|
@@ -307,7 +320,7 @@ export const CARD_COLOR: Record<Card["status"], string> = { ok: theme.ok, fail:
|
|
|
307
320
|
|
|
308
321
|
export function ResultCard({ card }: { card: Card }) {
|
|
309
322
|
const color = CARD_COLOR[card.status]
|
|
310
|
-
const lineColor = (l: string) => (/^\s*✓/.test(l) ? theme.ok : /^\s*[✗!]/.test(l) ? theme.err : /^\s
|
|
323
|
+
const lineColor = (l: string) => (/^\s*✓/.test(l) ? theme.ok : /^\s*[✗!]/.test(l) ? theme.err : /^\s*[?~]/.test(l) ? theme.warn : /^\s*(Fix:|→)/.test(l) ? theme.text : theme.muted)
|
|
311
324
|
// What a run used is in the footer and the reports; price talk stays out of the conversation.
|
|
312
325
|
const body = card.body.filter((l) => !/^\s*Usage\s{2,}/.test(l)).filter((l, i, all) => l || (i > 0 && i < all.length - 1))
|
|
313
326
|
return (
|
package/src/types.ts
CHANGED
|
@@ -136,6 +136,12 @@ export type CodeEvent =
|
|
|
136
136
|
| { type: "code"; kind: "usage"; usage: Usage; context?: number }
|
|
137
137
|
/** The agent's to-do list, each time it changes. */
|
|
138
138
|
| { type: "code"; kind: "plan"; items: PlanItem[] }
|
|
139
|
+
/** The reviewer's verdict on a test that passed: approved or not, why, and each issue with its file and fix. */
|
|
140
|
+
| { type: "code"; kind: "review"; attempt: number; max: number; approved: boolean; summary: string; issues: CodeReview["issues"] }
|
|
141
|
+
/** Scribe starts another attempt: why, and what it was asked to fix (the reviewer's issues, the test's error). */
|
|
142
|
+
| { type: "code"; kind: "attempt"; attempt: number; max: number; why: string; fixing: string[] }
|
|
143
|
+
/** What Scribe said it did in an attempt: its notes, and the files it changed. */
|
|
144
|
+
| { type: "code"; kind: "answer"; attempt: number; notes?: string; changed: string[] }
|
|
139
145
|
|
|
140
146
|
export type AgentEvent =
|
|
141
147
|
| { type: "log"; level: "info" | "warn" | "error"; text: string }
|