@capagents/forgeqe 1.1.5 → 1.1.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 +19 -14
- package/docs/user-guide.html +17 -14
- package/package.json +1 -1
- package/prompts/brain.md +1 -0
- package/prompts/test-agent.md +1 -0
- package/prompts/test-reviewer.md +3 -1
- package/prompts/test-rules.md +2 -0
- package/src/code/graph/graph.ts +7 -0
- package/src/code/runtime.ts +8 -1
- package/src/mcp/server.ts +15 -3
- package/src/mcp/tools.ts +12 -2
- package/src/tui/App.tsx +161 -95
- package/src/tui/components.tsx +27 -23
- package/src/tui/inspector.tsx +209 -101
- package/src/tui/landing.ts +3 -4
- package/src/tui/refine-panel.tsx +102 -52
- package/src/tui/shortcuts.tsx +2 -0
- package/src/tui/transcript.tsx +50 -2
- package/src/types.ts +3 -1
package/src/tui/refine-panel.tsx
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import type { RefObject } from "react"
|
|
3
|
+
|
|
1
4
|
import type { Refined } from "../refine.ts"
|
|
2
|
-
import { Spinner } from "./components.tsx"
|
|
5
|
+
import { Spinner, wrappedLines } from "./components.tsx"
|
|
3
6
|
import { formatDuration, formatTokens, theme } from "./theme.ts"
|
|
4
7
|
|
|
5
8
|
/** Refine's panel, in the prompt's place: working, the result with what was checked, or why it could not refine. */
|
|
@@ -27,35 +30,72 @@ export function initialChoice(missing: string[]): number {
|
|
|
27
30
|
return REFINE_CHOICES.findIndex((c) => c.id === (missing.length ? "edit" : "use"))
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
/** A row of the refined request as the panel draws it: a label in the panel's colour, a ✓ for a checked fact, or plain. */
|
|
34
|
+
type Row = { text: string; mark?: string; markColor?: string; label?: string; color?: string; heading?: boolean }
|
|
35
|
+
|
|
36
|
+
/** Everything the panel's body shows, in order: what is still missing first, since it decides whether to send. */
|
|
37
|
+
export function refineRows(r: Refined): Row[] {
|
|
38
|
+
const contextLines = r.refined.split("\n").filter((l) => l.startsWith("- ")).map((l) => norm(l.slice(2)))
|
|
39
|
+
const inContext = new Set(contextLines)
|
|
40
|
+
// Facts Refine checked that are also context lines are marked there, not repeated under Checked.
|
|
41
|
+
const checked = new Set(r.grounded.filter((g) => g.checked).map((g) => norm(g.fact)).filter((f) => inContext.has(f)))
|
|
42
|
+
const section = (label: string, mark: string, markColor: string, items: string[]): Row[] =>
|
|
43
|
+
items.length ? [{ text: label, heading: true, color: markColor === theme.err ? theme.err : theme.muted }, ...items.map((text) => ({ text, mark, markColor })), { text: "" }] : []
|
|
44
|
+
const request: Row[] = r.refined.split("\n").map((line) => {
|
|
45
|
+
if (line.startsWith("- ") && checked.has(norm(line.slice(2)))) return { text: line.slice(2), mark: "✓", markColor: theme.ok }
|
|
46
|
+
const label = /^(Role|Objective|Context|Steps|Rules|Output format|Summary|Goal|Result):/.exec(line)
|
|
47
|
+
return label ? { text: line.slice(label[0].length), label: label[0] } : { text: line }
|
|
48
|
+
})
|
|
49
|
+
return [
|
|
50
|
+
...section("Missing — answer these in your request", "?", theme.err, r.missing),
|
|
51
|
+
...request,
|
|
52
|
+
...section("Checked", "✓", theme.ok, r.grounded.filter((g) => g.checked && !inContext.has(norm(g.fact))).map((g) => g.fact)),
|
|
53
|
+
...section("Assumed", "~", theme.warn, [...r.grounded.filter((g) => !g.checked).map((g) => g.fact), ...r.assumed]),
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The screen rows `rows` take at `width` columns, wrapped as the panel wraps them. */
|
|
58
|
+
export function rowsHeight(rows: Row[], width: number): number {
|
|
59
|
+
return rows.reduce((n, r) => n + (r.text ? wrappedLines((r.label || "") + r.text, width - (r.mark ? 2 : 0)) : 1), 0)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Refine's panel, in the prompt's place. `maxHeight` is the rows it may take with its action row; a longer request
|
|
64
|
+
* scrolls inside it (↑↓ pgup pgdn, or the wheel) while the title and the actions stay on screen.
|
|
65
|
+
*/
|
|
66
|
+
export function RefinePanel(props: {
|
|
67
|
+
state: RefineState
|
|
68
|
+
width: number
|
|
69
|
+
maxHeight: number
|
|
70
|
+
scrollRef?: RefObject<ScrollBoxRenderable | null>
|
|
71
|
+
onChoose: (id: RefineChoice) => void
|
|
72
|
+
onHover: (i: number) => void
|
|
73
|
+
}) {
|
|
31
74
|
const { state, width } = props
|
|
32
75
|
const cost = state.phase === "done" && state.tokens ? ` · ${formatTokens(state.tokens)} tokens${state.ms ? ` · ${formatDuration(state.ms)}` : ""}` : ""
|
|
33
76
|
// How much of the request keeps the ASD-STE100 rules, as Refine's prompt asks.
|
|
34
77
|
const ste = state.phase === "done" && state.result.ste !== undefined ? ` · STE ${Math.round(state.result.ste * 100)}%` : ""
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
{items.map((it, i) => (
|
|
45
|
-
<box key={i} style={{ flexDirection: "row" }}>
|
|
46
|
-
<text fg={color} style={{ width: 2, flexShrink: 0 }}>
|
|
47
|
-
{mark}
|
|
48
|
-
</text>
|
|
49
|
-
<text fg={theme.text} style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
50
|
-
{it}
|
|
51
|
-
</text>
|
|
52
|
-
</box>
|
|
53
|
-
))}
|
|
54
|
-
</box>
|
|
55
|
-
) : null
|
|
78
|
+
const missing = state.phase === "done" && state.result.missing.length ? ` · ${state.result.missing.length} to answer` : ""
|
|
79
|
+
const title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : ` ✦ Refined request${missing}${ste}${cost} `
|
|
80
|
+
const rows = state.phase === "done" ? refineRows(state.result) : []
|
|
81
|
+
const inner = Math.max(10, width - 4)
|
|
82
|
+
// The border takes two rows and the action row one; the body gets the rest, and scrolls when it needs more.
|
|
83
|
+
const room = Math.max(3, props.maxHeight - 3)
|
|
84
|
+
const needed = rowsHeight(rows, inner)
|
|
85
|
+
const scrolls = needed > room
|
|
86
|
+
const bodyHeight = Math.min(needed, room)
|
|
56
87
|
return (
|
|
57
88
|
<box style={{ flexDirection: "column", width, flexShrink: 0 }}>
|
|
58
|
-
<box
|
|
89
|
+
<box
|
|
90
|
+
border
|
|
91
|
+
borderStyle="rounded"
|
|
92
|
+
borderColor={COLOR}
|
|
93
|
+
title={title}
|
|
94
|
+
titleColor={COLOR}
|
|
95
|
+
bottomTitle={scrolls ? " ↑↓ pgup pgdn scroll " : undefined}
|
|
96
|
+
bottomTitleAlignment="right"
|
|
97
|
+
style={{ flexDirection: "column", paddingLeft: 1, paddingRight: 1, backgroundColor: theme.panel, flexShrink: 0 }}
|
|
98
|
+
>
|
|
59
99
|
{state.phase === "working" ? (
|
|
60
100
|
<box style={{ flexDirection: "row" }}>
|
|
61
101
|
<Spinner kind="dots" color={COLOR} />
|
|
@@ -64,37 +104,36 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
|
|
|
64
104
|
) : state.phase === "error" ? (
|
|
65
105
|
<text fg={theme.err}>{state.message}</text>
|
|
66
106
|
) : (
|
|
67
|
-
|
|
68
|
-
{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
107
|
+
<scrollbox ref={props.scrollRef} style={{ height: bodyHeight, flexShrink: 0 }} focused={false}>
|
|
108
|
+
{rows.map((r, i) =>
|
|
109
|
+
r.heading ? (
|
|
110
|
+
<text key={i} fg={r.color}>
|
|
111
|
+
{r.text}
|
|
112
|
+
</text>
|
|
113
|
+
) : r.mark ? (
|
|
114
|
+
<box key={i} style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
115
|
+
<text fg={r.markColor} style={{ width: 2, flexShrink: 0 }}>
|
|
116
|
+
{r.mark}
|
|
117
|
+
</text>
|
|
118
|
+
<text fg={theme.text} style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
119
|
+
{r.text}
|
|
77
120
|
</text>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return label ? (
|
|
121
|
+
</box>
|
|
122
|
+
) : r.label ? (
|
|
81
123
|
<text key={i}>
|
|
82
|
-
<span fg={COLOR}>{label
|
|
83
|
-
<span fg={theme.text}>{
|
|
124
|
+
<span fg={COLOR}>{r.label}</span>
|
|
125
|
+
<span fg={theme.text}>{r.text}</span>
|
|
84
126
|
</text>
|
|
85
127
|
) : (
|
|
86
|
-
<text key={i} fg={
|
|
87
|
-
{
|
|
128
|
+
<text key={i} fg={theme.text}>
|
|
129
|
+
{r.text || " "}
|
|
88
130
|
</text>
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
{section("Assumed", theme.warn, "~", [...state.result.grounded.filter((g) => !g.checked).map((g) => g.fact), ...state.result.assumed])}
|
|
93
|
-
{section("Missing — answer these in your request", theme.err, "?", state.result.missing)}
|
|
94
|
-
</>
|
|
131
|
+
),
|
|
132
|
+
)}
|
|
133
|
+
</scrollbox>
|
|
95
134
|
)}
|
|
96
135
|
</box>
|
|
97
|
-
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1,
|
|
136
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0 }}>
|
|
98
137
|
{state.phase === "done" ? (
|
|
99
138
|
<box style={{ flexDirection: "row" }}>
|
|
100
139
|
{REFINE_CHOICES.map((c, i) => {
|
|
@@ -112,12 +151,23 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
|
|
|
112
151
|
<text>
|
|
113
152
|
{state.phase === "done" ? (
|
|
114
153
|
<>
|
|
154
|
+
<span fg={theme.text}>{"⇆"}</span>
|
|
155
|
+
<span fg={theme.muted}>{" choose "}</span>
|
|
115
156
|
<span fg={theme.text}>{"enter"}</span>
|
|
116
157
|
<span fg={theme.muted}>{` ${REFINE_CHOICES[state.choice]!.id === "keep" ? "keep mine" : REFINE_CHOICES[state.choice]!.id} `}</span>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
158
|
+
{/* Each action's key once: e and esc are not repeated when enter already does them. */}
|
|
159
|
+
{REFINE_CHOICES[state.choice]!.id !== "edit" ? (
|
|
160
|
+
<>
|
|
161
|
+
<span fg={theme.text}>{"e"}</span>
|
|
162
|
+
<span fg={theme.muted}>{" edit "}</span>
|
|
163
|
+
</>
|
|
164
|
+
) : null}
|
|
165
|
+
{REFINE_CHOICES[state.choice]!.id !== "keep" ? (
|
|
166
|
+
<>
|
|
167
|
+
<span fg={theme.text}>{"esc"}</span>
|
|
168
|
+
<span fg={theme.muted}>{" keep mine"}</span>
|
|
169
|
+
</>
|
|
170
|
+
) : null}
|
|
121
171
|
</>
|
|
122
172
|
) : (
|
|
123
173
|
<>
|
package/src/tui/shortcuts.tsx
CHANGED
|
@@ -16,6 +16,8 @@ export const SHORTCUTS: { group: string; keys: [string, string][] }[] = [
|
|
|
16
16
|
group: "Find and move",
|
|
17
17
|
keys: [
|
|
18
18
|
["ctrl+p", "search: actions, commands, profiles, examples"],
|
|
19
|
+
["pgup pgdn", "scroll the conversation (the mouse wheel works too)"],
|
|
20
|
+
["↑↓ pgup pgdn", "scroll a long refined request; ⇆ picks what to do with it"],
|
|
19
21
|
["shift+tab", "move between the chat and the plan, activity, and files panels"],
|
|
20
22
|
["ctrl+o", "open or close the panels: plan, activity, files, context, tokens"],
|
|
21
23
|
["alt+1 to alt+4", "a start action, or a next step"],
|
package/src/tui/transcript.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import { clip } from "./text.ts"
|
|
|
17
17
|
|
|
18
18
|
export type Line = {
|
|
19
19
|
id: number
|
|
20
|
-
kind: "sys" | "user" | "thought" | "timing" | "memory" | "tool" | "file" | "code" | "ok" | "err" | "summary" | "suitesummary" | "reply" | "card"
|
|
20
|
+
kind: "sys" | "user" | "thought" | "timing" | "memory" | "tool" | "file" | "code" | "ok" | "err" | "summary" | "suitesummary" | "reply" | "card" | "running" | "request" | "requestEnd"
|
|
21
21
|
text: string
|
|
22
22
|
summary?: RunSummary
|
|
23
23
|
suite?: SuiteResult
|
|
@@ -25,6 +25,47 @@ export type Line = {
|
|
|
25
25
|
outcome?: "done" | "failed" | "needs_input"
|
|
26
26
|
/** Which side of the split view shows it: the conversation, or what the agents are doing. */
|
|
27
27
|
pane?: "chat" | "activity"
|
|
28
|
+
/** Only the Activity panel shows it: detail the conversation leaves out at this level (/details), such as reads. */
|
|
29
|
+
quiet?: boolean
|
|
30
|
+
/** A tool call's id: its "running" line becomes the call's result line when the call finishes. */
|
|
31
|
+
call?: string
|
|
32
|
+
/** A request's first line in Activity ("request"): when it started. */
|
|
33
|
+
at?: number
|
|
34
|
+
/** A request's last line in Activity ("requestEnd"): how it ended. */
|
|
35
|
+
ended?: "done" | "fail" | "stopped" | "idle"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Activity lines that mark where a request starts and ends; they are not activities themselves. */
|
|
39
|
+
export const isMarker = (l: Line) => l.kind === "request" || l.kind === "requestEnd"
|
|
40
|
+
|
|
41
|
+
/** The activities since the last request started, markers left out: what the request did. */
|
|
42
|
+
export function sinceRequest(lines: Line[]): number {
|
|
43
|
+
let n = 0
|
|
44
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
45
|
+
const l = lines[i]!
|
|
46
|
+
if (l.kind === "request") break
|
|
47
|
+
if (l.pane === "activity" && !isMarker(l)) n++
|
|
48
|
+
}
|
|
49
|
+
return n
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Lines kept: the oldest quiet ones go first, so a busy run never pushes the conversation out. */
|
|
53
|
+
export const MAX_LINES = 1500
|
|
54
|
+
|
|
55
|
+
export function trimLines(lines: Line[], max = MAX_LINES): Line[] {
|
|
56
|
+
if (lines.length <= max) return lines
|
|
57
|
+
let drop = lines.length - max
|
|
58
|
+
const kept = lines.filter((l) => (drop > 0 && l.quiet ? (drop--, false) : true))
|
|
59
|
+
return kept.length > max ? kept.slice(-max) : kept
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A call has finished: its running line takes the result in place, or the result is added when there was none. */
|
|
63
|
+
export function settleCall(lines: Line[], call: string | undefined, line: Omit<Line, "id" | "call">, id: number): Line[] {
|
|
64
|
+
const at = call ? lines.findIndex((l) => l.call === call && l.kind === "running") : -1
|
|
65
|
+
if (at < 0) return [...lines, { ...line, id, ...(call ? { call } : {}) }]
|
|
66
|
+
const next = [...lines]
|
|
67
|
+
next[at] = { ...next[at]!, ...line, call }
|
|
68
|
+
return next
|
|
28
69
|
}
|
|
29
70
|
|
|
30
71
|
/** What one request produced: browser runs with their files, suites, and the tests the coding agent wrote. */
|
|
@@ -97,6 +138,9 @@ export function producedCard(p: Produced, tests: TestsSetting): Card | null {
|
|
|
97
138
|
* The conversation reads top to bottom, as in Claude Code: what you asked after ">", each reply, tool call, or result
|
|
98
139
|
* after "⏺", and what came of it indented under the call after "⎿". No boxes or backgrounds: marks and colour carry it.
|
|
99
140
|
*/
|
|
141
|
+
/** The lines a request shows in the conversation before it folds. */
|
|
142
|
+
export const USER_ROWS = 8
|
|
143
|
+
|
|
100
144
|
export const MARK = { item: "⏺", child: "⎿", user: ">" }
|
|
101
145
|
|
|
102
146
|
/** A mark in its own column and the content beside it, so wrapped lines stay under the content. */
|
|
@@ -404,9 +448,13 @@ export function TranscriptLine({ line, prev }: { line: Line; prev?: Line }) {
|
|
|
404
448
|
if (line.kind === "summary" && line.summary) return <SummaryCard summary={line.summary} />
|
|
405
449
|
if (line.kind === "suitesummary" && line.suite) return <SuiteCard suite={line.suite} />
|
|
406
450
|
if (line.kind === "user") {
|
|
451
|
+
// A long request (a refined one, a pasted spec) folds to its first lines; it was sent in full.
|
|
452
|
+
const rows = line.text.split("\n")
|
|
453
|
+
const folded = rows.length > USER_ROWS
|
|
407
454
|
return (
|
|
408
455
|
<Marked mark={MARK.user} color={theme.muted} gap>
|
|
409
|
-
<text fg={theme.muted}>{line.text}</text>
|
|
456
|
+
<text fg={theme.muted}>{folded ? rows.slice(0, USER_ROWS - 1).join("\n") : line.text}</text>
|
|
457
|
+
{folded ? <text fg={theme.faint}>{`… ${rows.length - USER_ROWS + 1} more lines, sent in full`}</text> : null}
|
|
410
458
|
</Marked>
|
|
411
459
|
)
|
|
412
460
|
}
|
package/src/types.ts
CHANGED
|
@@ -127,7 +127,9 @@ export type PlanItem = { text: string; status: "pending" | "in_progress" | "comp
|
|
|
127
127
|
export type CodeEvent =
|
|
128
128
|
| { type: "code"; kind: "info"; text: string }
|
|
129
129
|
/** A finished tool call: `tool` is its name, and `file` the file it read or changed, relative to the repository. */
|
|
130
|
-
| { type: "code"; kind: "tool"; text: string; ok: boolean; tool?: string; file?: { path: string; action: FileAction }; skill?: string }
|
|
130
|
+
| { type: "code"; kind: "tool"; text: string; ok: boolean; tool?: string; file?: { path: string; action: FileAction }; skill?: string; id?: string }
|
|
131
|
+
/** A tool call that has started and not finished yet; its `kind: "tool"` event with the same `id` follows. */
|
|
132
|
+
| { type: "code"; kind: "start"; text: string; tool: string; id: string }
|
|
131
133
|
| { type: "code"; kind: "say"; text: string }
|
|
132
134
|
| { type: "code"; kind: "test"; text: string; ok: boolean; attempt: number }
|
|
133
135
|
/** The session's usage so far; `context` is the last model call's input, how full its context window is. */
|