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