@capagents/forgeqe 1.0.9 → 1.1.1
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 +50 -19
- package/docs/user-guide.html +49 -19
- package/forgeqe.example.yaml +1 -1
- package/package.json +1 -1
- package/prompts/brain.md +17 -3
- package/prompts/refine.md +16 -8
- 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/server.ts +11 -1
- package/src/mcp/tools.ts +25 -2
- package/src/refine.ts +30 -14
- package/src/tui/App.tsx +126 -58
- package/src/tui/components.tsx +47 -55
- package/src/tui/landing.ts +101 -28
- package/src/tui/next.ts +1 -1
- package/src/tui/palette.tsx +1 -0
- package/src/tui/refine-panel.tsx +32 -7
- package/src/tui/shortcuts.tsx +1 -1
- package/src/tui/transcript.tsx +155 -150
package/src/tui/components.tsx
CHANGED
|
@@ -122,64 +122,56 @@ export function Prompt(props: PromptProps) {
|
|
|
122
122
|
props.onLines?.(needed)
|
|
123
123
|
setLines(needed)
|
|
124
124
|
}
|
|
125
|
+
// A rounded box with ">" before the text, as in Claude Code; the border lights up while it has the keys.
|
|
125
126
|
return (
|
|
126
|
-
<box
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
>
|
|
141
|
-
{props.masked !== undefined ? (
|
|
142
|
-
<text style={{ height: 1 }}>
|
|
143
|
-
{props.masked ? <span fg={theme.text}>{"•".repeat(Math.min(props.masked, Math.max(1, props.width - 8)))}</span> : <span fg={theme.faint}>{props.placeholder}</span>}
|
|
144
|
-
</text>
|
|
145
|
-
) : (
|
|
146
|
-
<textarea
|
|
147
|
-
key={props.inputKey}
|
|
148
|
-
ref={ref}
|
|
149
|
-
placeholder={props.placeholder}
|
|
150
|
-
focused={props.focused !== false}
|
|
151
|
-
initialValue={props.initialValue || ""}
|
|
152
|
-
wrapMode="word"
|
|
153
|
-
keyBindings={PROMPT_KEYS}
|
|
154
|
-
onSubmit={() => props.onSubmit(ref.current?.plainText ?? "")}
|
|
155
|
-
onContentChange={fit}
|
|
156
|
-
style={{ height: rows }}
|
|
157
|
-
backgroundColor={theme.panel}
|
|
158
|
-
focusedBackgroundColor={theme.panel}
|
|
159
|
-
textColor={theme.text}
|
|
160
|
-
focusedTextColor={theme.text}
|
|
161
|
-
placeholderColor={theme.faint}
|
|
162
|
-
/>
|
|
163
|
-
)}
|
|
164
|
-
{props.compact ? null : (
|
|
165
|
-
<box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
|
|
166
|
-
<text>
|
|
167
|
-
<span fg={theme.muted}>{"Model "}</span>
|
|
168
|
-
<span fg={theme.text}>{props.model || "…"}</span>
|
|
169
|
-
</text>
|
|
170
|
-
<text>
|
|
171
|
-
{props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
|
|
172
|
-
{props.right ? <span fg={theme.faint}>{" · "}</span> : null}
|
|
173
|
-
<span fg={theme.muted}>{"profile "}</span>
|
|
174
|
-
<span fg={theme.text}>{props.profile || "none"}</span>
|
|
127
|
+
<box
|
|
128
|
+
border
|
|
129
|
+
borderStyle="rounded"
|
|
130
|
+
borderColor={props.focused === false ? theme.border : theme.muted}
|
|
131
|
+
style={{ flexDirection: "column", width: props.width, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}
|
|
132
|
+
>
|
|
133
|
+
<box style={{ flexDirection: "row" }}>
|
|
134
|
+
<text fg={props.modeColor} style={{ width: 2, flexShrink: 0 }}>
|
|
135
|
+
{">"}
|
|
136
|
+
</text>
|
|
137
|
+
<box style={{ flexDirection: "column", flexGrow: 1, flexShrink: 1 }}>
|
|
138
|
+
{props.masked !== undefined ? (
|
|
139
|
+
<text style={{ height: 1 }}>
|
|
140
|
+
{props.masked ? <span fg={theme.text}>{"•".repeat(Math.min(props.masked, Math.max(1, props.width - 8)))}</span> : <span fg={theme.faint}>{props.placeholder}</span>}
|
|
175
141
|
</text>
|
|
176
|
-
|
|
177
|
-
|
|
142
|
+
) : (
|
|
143
|
+
<textarea
|
|
144
|
+
key={props.inputKey}
|
|
145
|
+
ref={ref}
|
|
146
|
+
placeholder={props.placeholder}
|
|
147
|
+
focused={props.focused !== false}
|
|
148
|
+
initialValue={props.initialValue || ""}
|
|
149
|
+
wrapMode="word"
|
|
150
|
+
keyBindings={PROMPT_KEYS}
|
|
151
|
+
onSubmit={() => props.onSubmit(ref.current?.plainText ?? "")}
|
|
152
|
+
onContentChange={fit}
|
|
153
|
+
style={{ height: rows }}
|
|
154
|
+
textColor={theme.text}
|
|
155
|
+
focusedTextColor={theme.text}
|
|
156
|
+
placeholderColor={theme.faint}
|
|
157
|
+
/>
|
|
158
|
+
)}
|
|
159
|
+
</box>
|
|
178
160
|
</box>
|
|
179
|
-
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
161
|
+
{props.compact ? null : (
|
|
162
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
|
|
163
|
+
<text>
|
|
164
|
+
<span fg={theme.muted}>{"Model "}</span>
|
|
165
|
+
<span fg={theme.text}>{props.model || "…"}</span>
|
|
166
|
+
</text>
|
|
167
|
+
<text>
|
|
168
|
+
{props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
|
|
169
|
+
{props.right ? <span fg={theme.faint}>{" · "}</span> : null}
|
|
170
|
+
<span fg={theme.muted}>{"profile "}</span>
|
|
171
|
+
<span fg={theme.text}>{props.profile || "none"}</span>
|
|
172
|
+
</text>
|
|
173
|
+
</box>
|
|
174
|
+
)}
|
|
183
175
|
</box>
|
|
184
176
|
)
|
|
185
177
|
}
|
package/src/tui/landing.ts
CHANGED
|
@@ -11,32 +11,84 @@ import { allStatuses, configuredSystems, SYSTEM_LABEL, type Probes, MACHINE } fr
|
|
|
11
11
|
* in the folder, so it changes as the setup changes.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
/** Files a stack is read from, at the top and one or two folders down (tests/Shop.Tests/Shop.Tests.csproj). */
|
|
15
|
+
function projectFiles(cwd: string, match: RegExp, depth = 2): string[] {
|
|
16
|
+
const out: string[] = []
|
|
17
|
+
const walk = (dir: string, level: number) => {
|
|
18
|
+
let names: string[] = []
|
|
19
|
+
try {
|
|
20
|
+
names = readdirSync(dir)
|
|
21
|
+
} catch {
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
for (const n of names) {
|
|
25
|
+
if (n.startsWith(".") || n === "node_modules" || n === "bin" || n === "obj") continue
|
|
26
|
+
const full = path.join(dir, n)
|
|
27
|
+
if (match.test(n)) out.push(full)
|
|
28
|
+
else if (level < depth) {
|
|
29
|
+
try {
|
|
30
|
+
if (statSync(full).isDirectory()) walk(full, level + 1)
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
if (out.length > 20) return
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
walk(cwd, 0)
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const read = (file: string) => {
|
|
17
41
|
try {
|
|
18
|
-
|
|
42
|
+
return readFileSync(file, "utf8")
|
|
19
43
|
} catch {
|
|
20
|
-
return
|
|
44
|
+
return ""
|
|
21
45
|
}
|
|
22
|
-
const has = (re: RegExp) => names.some((n) => re.test(n))
|
|
23
|
-
const out: string[] = []
|
|
24
|
-
if (has(/^playwright\.config\.(ts|js|mjs|cjs)$/)) out.push("Playwright Test")
|
|
25
|
-
if (has(/^cypress\.config\.(ts|js|mjs|cjs)$/)) out.push("Cypress")
|
|
26
|
-
if (has(/\.sln$/) || has(/\.csproj$/)) out.push(".NET")
|
|
27
|
-
if (has(/^pom\.xml$/)) out.push("Maven")
|
|
28
|
-
if (has(/^build\.gradle(\.kts)?$/)) out.push("Gradle")
|
|
29
|
-
if (has(/^(pytest\.ini|conftest\.py)$/) || (has(/^pyproject\.toml$/) && !out.length)) out.push("Python")
|
|
30
|
-
if (has(/^package\.json$/) && !out.some((s) => s === "Playwright Test" || s === "Cypress")) out.push("Node")
|
|
31
|
-
return out.slice(0, 2)
|
|
32
46
|
}
|
|
33
47
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
48
|
+
/**
|
|
49
|
+
* "C# · NUnit · Playwright": the language, test framework, and browser library the project's own files declare
|
|
50
|
+
* (project files, package.json, Python requirements). Read from the files, never guessed from folder names.
|
|
51
|
+
*/
|
|
52
|
+
export function projectStack(cwd: string): string[] {
|
|
53
|
+
const csproj = projectFiles(cwd, /\.csproj$/).map(read).join("\n")
|
|
54
|
+
if (csproj) {
|
|
55
|
+
const framework = /PackageReference Include="NUnit"/i.test(csproj) ? "NUnit" : /PackageReference Include="xunit/i.test(csproj) ? "xUnit" : /PackageReference Include="MSTest/i.test(csproj) ? "MSTest" : ""
|
|
56
|
+
const browser = /Microsoft\.Playwright/i.test(csproj) ? "Playwright" : /Selenium\.WebDriver/i.test(csproj) ? "Selenium" : /Reqnroll|SpecFlow/i.test(csproj) ? "BDD" : ""
|
|
57
|
+
return ["C#", framework, browser].filter(Boolean)
|
|
58
|
+
}
|
|
59
|
+
const pkg = read(path.join(cwd, "package.json"))
|
|
60
|
+
if (pkg) {
|
|
61
|
+
const lang = existsSync(path.join(cwd, "tsconfig.json")) ? "TypeScript" : "JavaScript"
|
|
62
|
+
const runner = /"@playwright\/test"/.test(pkg) ? "Playwright Test" : /"cypress"/.test(pkg) ? "Cypress" : /"webdriverio"|"@wdio\//.test(pkg) ? "WebdriverIO" : /"vitest"/.test(pkg) ? "Vitest" : /"jest"/.test(pkg) ? "Jest" : ""
|
|
63
|
+
return [lang, runner].filter(Boolean)
|
|
64
|
+
}
|
|
65
|
+
const py = ["requirements.txt", "pyproject.toml", "setup.cfg"].map((f) => read(path.join(cwd, f))).join("\n")
|
|
66
|
+
if (py.trim() || existsSync(path.join(cwd, "pytest.ini")) || existsSync(path.join(cwd, "conftest.py"))) {
|
|
67
|
+
return ["Python", /pytest/i.test(py) || existsSync(path.join(cwd, "pytest.ini")) ? "pytest" : "", /playwright/i.test(py) ? "Playwright" : /selenium/i.test(py) ? "Selenium" : ""].filter(Boolean)
|
|
68
|
+
}
|
|
69
|
+
const java = [read(path.join(cwd, "pom.xml")), read(path.join(cwd, "build.gradle")), read(path.join(cwd, "build.gradle.kts"))].join("\n")
|
|
70
|
+
if (java.trim()) return ["Java", /junit-jupiter|junit/i.test(java) ? "JUnit" : /testng/i.test(java) ? "TestNG" : "", /playwright/i.test(java) ? "Playwright" : /selenium/i.test(java) ? "Selenium" : ""].filter(Boolean)
|
|
71
|
+
return []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type Fact = { text: string; tone: "name" | "plain" | "warn" }
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The project line: the folder, its stack, each system ForgeQE works with here and how it is signed in ("Azure DevOps
|
|
78
|
+
* (PAT)"), and the model; or, where nothing is set up, that this folder has no forgeqe.yaml.
|
|
79
|
+
*/
|
|
80
|
+
export function projectLine(cfg: AppConfig | null, cwd: string, opts: { model: string; signIns?: Record<string, string> }): Fact[] {
|
|
81
|
+
const facts: Fact[] = [{ text: path.basename(cwd), tone: "name" }, ...projectStack(cwd).map((t) => ({ text: t, tone: "plain" as const }))]
|
|
82
|
+
if (!cfg) return facts
|
|
83
|
+
if (!cfg.configPath) return [...facts, { text: "no forgeqe.yaml here", tone: "warn" }]
|
|
84
|
+
const systems = [...configuredSystems(cfg)].filter((s) => s !== "xray" || !configuredSystems(cfg).has("jira"))
|
|
85
|
+
for (const s of systems) {
|
|
86
|
+
const how = opts.signIns?.[SYSTEM_LABEL[s]]
|
|
87
|
+
facts.push({ text: how ? `${SYSTEM_LABEL[s]} (${how})` : SYSTEM_LABEL[s], tone: "plain" })
|
|
88
|
+
}
|
|
89
|
+
const live = cfg.llm.provider !== "none" && cfg.llm.provider !== "mock"
|
|
90
|
+
facts.push(live && opts.model ? { text: opts.model, tone: "plain" } : { text: "no model", tone: "warn" })
|
|
91
|
+
return facts
|
|
40
92
|
}
|
|
41
93
|
|
|
42
94
|
const ALL_SYSTEMS = Object.values(SYSTEM_LABEL)
|
|
@@ -61,8 +113,9 @@ const START_LABEL: Record<string, string> = {
|
|
|
61
113
|
"browser-goals": "Try a flow in the browser",
|
|
62
114
|
}
|
|
63
115
|
|
|
64
|
-
export function startActions(cfg: AppConfig | null, limit = 4, probes: Probes = MACHINE): StartAction[] {
|
|
65
|
-
|
|
116
|
+
export function startActions(cfg: AppConfig | null, limit = 4, probes: Probes = MACHINE, hasRuns = true): StartAction[] {
|
|
117
|
+
// Without a live model nothing on the list can run: the start screen offers Get started instead.
|
|
118
|
+
if (!cfg || cfg.llm.provider === "none" || cfg.llm.provider === "mock") return []
|
|
66
119
|
const statuses = allStatuses(cfg, probes)
|
|
67
120
|
const systems = [...configuredSystems(cfg)].map((x) => SYSTEM_LABEL[x])
|
|
68
121
|
const out: StartAction[] = []
|
|
@@ -70,9 +123,12 @@ export function startActions(cfg: AppConfig | null, limit = 4, probes: Probes =
|
|
|
70
123
|
const c = CAPABILITIES.find((x) => x.id === id)
|
|
71
124
|
if (!c || statuses.get(id)?.state === "needs_setup") continue
|
|
72
125
|
// An example in the words of this setup: one that names a system that is set up, else one that names none.
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
126
|
+
// Not an example that builds on a run when there is none to build on.
|
|
127
|
+
const usable = c.examples.filter((e) => hasRuns || !/\blast run\b|\bthe run\b|that run/i.test(e))
|
|
128
|
+
if (!usable.length) continue
|
|
129
|
+
const named = usable.find((e) => systems.some((n) => e.includes(n)))
|
|
130
|
+
const neutral = usable.find((e) => !ALL_SYSTEMS.some((n) => e.includes(n)))
|
|
131
|
+
const example = named || neutral || usable[0]!
|
|
76
132
|
out.push({ id, label: START_LABEL[id] || c.title, detail: example, request: example })
|
|
77
133
|
if (out.length === limit) break
|
|
78
134
|
}
|
|
@@ -91,9 +147,11 @@ export function recentWork(outDir: string, limit = 3): RecentItem[] {
|
|
|
91
147
|
if (!statSync(dir).isDirectory()) continue
|
|
92
148
|
const when = statSync(dir).mtime
|
|
93
149
|
if (existsSync(path.join(dir, "run.json"))) {
|
|
94
|
-
|
|
150
|
+
// The engine writes success; ForgeQE's own records write status. Either says how the run ended.
|
|
151
|
+
const run = JSON.parse(readFileSync(path.join(dir, "run.json"), "utf8")) as { goal?: string; status?: string; success?: boolean }
|
|
95
152
|
const report = path.join(dir, "report.html")
|
|
96
|
-
|
|
153
|
+
const status = run.status || (run.success === true ? "passed" : run.success === false ? "fail" : undefined)
|
|
154
|
+
items.push({ kind: "run", title: String(run.goal || name), dir, open: existsSync(report) ? report : dir, when, ...(status ? { status } : {}) })
|
|
97
155
|
} else if (existsSync(path.join(dir, "report.md"))) {
|
|
98
156
|
const first = readFileSync(path.join(dir, "report.md"), "utf8").split("\n")[0] || ""
|
|
99
157
|
const html = path.join(dir, "report.html")
|
|
@@ -126,3 +184,18 @@ export function setupSentence(missing: string[], room: number): string {
|
|
|
126
184
|
const named = `${listWords(missing)} ${missing.length === 1 ? "is" : "are"} not set up`
|
|
127
185
|
return named.length <= room ? named : `${missing.length} ${missing.length === 1 ? "item is" : "items are"} not set up`
|
|
128
186
|
}
|
|
187
|
+
|
|
188
|
+
/** What to do first where little is set up: each step runs a command or says how, in the order they are needed. */
|
|
189
|
+
export type GetStarted = { id: string; label: string; detail: string; command?: string; fix?: string }
|
|
190
|
+
|
|
191
|
+
export function getStarted(cfg: AppConfig | null, setup: { id: string; done: boolean; fix?: string; command?: string }[]): GetStarted[] {
|
|
192
|
+
if (!cfg) return []
|
|
193
|
+
const out: GetStarted[] = []
|
|
194
|
+
if (!cfg.configPath) out.push({ id: "init", label: "Set up ForgeQE in this folder", detail: "writes forgeqe.yaml, llms.json, prompts, skills", command: "/init" })
|
|
195
|
+
const missing = (id: string) => setup.find((i) => i.id === id && !i.done)
|
|
196
|
+
const model = missing("model")
|
|
197
|
+
if (model) out.push({ id: "model", label: "Add a model", detail: "an API key or Microsoft Entra ID, in llms.json", fix: model.fix })
|
|
198
|
+
const system = missing("system")
|
|
199
|
+
if (system) out.push({ id: "system", label: "Sign in to Azure DevOps or Jira", detail: "test cases, stories, pipelines", command: "/auth" })
|
|
200
|
+
return out.slice(0, 4)
|
|
201
|
+
}
|
package/src/tui/next.ts
CHANGED
|
@@ -26,7 +26,7 @@ export function nextSteps(p: Produced, opts: { testsOn: boolean; followUps?: Fol
|
|
|
26
26
|
steps.push({ label: "Rerun the cases that failed", run: { kind: "insert", text: "Run the cases that failed in the last suite again" } })
|
|
27
27
|
} else if (run) {
|
|
28
28
|
const report = path.join(run.runDir, "report.html")
|
|
29
|
-
if (run.status === "complete" && !p.tests.some((t) => t.runDir === run.runDir)) steps.push({ label: "Write a test for this run", run: { kind: "insert", text: "Write a test for the last run" } })
|
|
29
|
+
if (opts.testsOn && run.status === "complete" && !p.tests.some((t) => t.runDir === run.runDir)) steps.push({ label: "Write a test for this run", run: { kind: "insert", text: "Write a test for the last run" } })
|
|
30
30
|
if (run.status !== "complete") {
|
|
31
31
|
steps.push({ label: "Why did it fail?", run: { kind: "insert", text: "Why did the last run fail, and what should change?" } })
|
|
32
32
|
steps.push({ label: "Run it again, visible", run: { kind: "insert", text: "Run the last goal again in a visible browser" } })
|
package/src/tui/palette.tsx
CHANGED
|
@@ -34,6 +34,7 @@ export function paletteItems(opts: { running: boolean; headed: boolean; detailsF
|
|
|
34
34
|
items.push({ id: "shortcuts", group: "Action", title: "Keyboard shortcuts", keys: "?", run: { kind: "action", id: "shortcuts" } })
|
|
35
35
|
command("features", "What ForgeQE can do here", "/features", "and what each feature still needs")
|
|
36
36
|
command("auth", "Sign in to a system", "/auth", "Azure DevOps, GitHub, Jira, Confluence, TestRail, Zephyr, qTest, Xray")
|
|
37
|
+
command("init", "Set up ForgeQE in this folder", "/init", "forgeqe.yaml, llms.json, prompts, skills, knowledge")
|
|
37
38
|
command("auth-status", "What is signed in", "/auth status")
|
|
38
39
|
command("headed", opts.headed ? "Hide the browser window" : "Show the browser window", `/headed ${opts.headed ? "off" : "on"}`)
|
|
39
40
|
command("details", opts.detailsFull ? "Show progress only" : "Show everything the agents do", `/details ${opts.detailsFull ? "off" : "on"}`)
|
package/src/tui/refine-panel.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Refined } from "../refine.ts"
|
|
2
2
|
import { Spinner } from "./components.tsx"
|
|
3
|
-
import { theme } from "./theme.ts"
|
|
3
|
+
import { formatDuration, formatTokens, theme } from "./theme.ts"
|
|
4
4
|
|
|
5
5
|
/** Refine's panel, in the prompt's place: working, the result with what was checked, or why it could not refine. */
|
|
6
6
|
export type RefineState =
|
|
7
7
|
| { phase: "working"; original: string }
|
|
8
|
-
|
|
8
|
+
/** `tokens` and `ms`: what Refine's own model session cost, shown in the title. */
|
|
9
|
+
| { phase: "done"; original: string; result: Refined; choice: number; tokens?: number; ms?: number }
|
|
9
10
|
| { phase: "error"; original: string; message: string }
|
|
10
11
|
|
|
11
12
|
export const REFINE_CHOICES = [
|
|
@@ -18,9 +19,24 @@ export type RefineChoice = (typeof REFINE_CHOICES)[number]["id"]
|
|
|
18
19
|
|
|
19
20
|
const COLOR = theme.memory
|
|
20
21
|
|
|
22
|
+
/** A fact as written in context and in grounded, compared without case, spaces, or a closing full stop. */
|
|
23
|
+
const norm = (s: string) => s.trim().replace(/\.$/, "").replace(/\s+/g, " ").toLowerCase()
|
|
24
|
+
|
|
25
|
+
/** The choice the panel starts on: Edit while Refine still has a question, so an unclear request is not sent. */
|
|
26
|
+
export function initialChoice(missing: string[]): number {
|
|
27
|
+
return REFINE_CHOICES.findIndex((c) => c.id === (missing.length ? "edit" : "use"))
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
export function RefinePanel(props: { state: RefineState; width: number; onChoose: (id: RefineChoice) => void; onHover: (i: number) => void }) {
|
|
22
31
|
const { state, width } = props
|
|
23
|
-
const
|
|
32
|
+
const cost = state.phase === "done" && state.tokens ? ` · ${formatTokens(state.tokens)} tokens${state.ms ? ` · ${formatDuration(state.ms)}` : ""}` : ""
|
|
33
|
+
// How much of the request keeps the ASD-STE100 rules, as Refine's prompt asks.
|
|
34
|
+
const ste = state.phase === "done" && state.result.ste !== undefined ? ` · STE ${Math.round(state.result.ste * 100)}%` : ""
|
|
35
|
+
const title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : ` ✦ Refined request${ste}${cost} `
|
|
36
|
+
// Facts Refine checked that are also context lines are marked there, not repeated under Checked.
|
|
37
|
+
const contextLines = state.phase === "done" ? state.result.refined.split("\n").filter((l) => l.startsWith("- ")).map((l) => norm(l.slice(2))) : []
|
|
38
|
+
const inContext = new Set(contextLines)
|
|
39
|
+
const checked = new Set(state.phase === "done" ? state.result.grounded.filter((g) => g.checked).map((g) => norm(g.fact)).filter((f) => inContext.has(f)) : [])
|
|
24
40
|
const section = (label: string, color: string, mark: string, items: string[]) =>
|
|
25
41
|
items.length ? (
|
|
26
42
|
<box style={{ flexDirection: "column", marginTop: 1 }}>
|
|
@@ -49,9 +65,18 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
|
|
|
49
65
|
<text fg={theme.err}>{state.message}</text>
|
|
50
66
|
) : (
|
|
51
67
|
<>
|
|
52
|
-
{/* The request as it is sent: its labelled parts (
|
|
68
|
+
{/* The request as it is sent: its labelled parts (Role, Objective, Context, Steps, Rules, Output format, Summary) stand out. */}
|
|
53
69
|
{state.result.refined.split("\n").map((line, i) => {
|
|
54
|
-
const label = /^(
|
|
70
|
+
const label = /^(Role|Objective|Context|Steps|Rules|Output format|Summary|Goal|Result):/.exec(line)
|
|
71
|
+
// A context line Refine checked against the systems: ✓ in place of its dash, so it is not listed twice.
|
|
72
|
+
if (line.startsWith("- ") && checked.has(norm(line.slice(2)))) {
|
|
73
|
+
return (
|
|
74
|
+
<text key={i}>
|
|
75
|
+
<span fg={theme.ok}>{"✓ "}</span>
|
|
76
|
+
<span fg={theme.text}>{line.slice(2)}</span>
|
|
77
|
+
</text>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
55
80
|
return label ? (
|
|
56
81
|
<text key={i}>
|
|
57
82
|
<span fg={COLOR}>{label[0]}</span>
|
|
@@ -63,7 +88,7 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
|
|
|
63
88
|
</text>
|
|
64
89
|
)
|
|
65
90
|
})}
|
|
66
|
-
{section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked).map((g) => g.fact))}
|
|
91
|
+
{section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked && !inContext.has(norm(g.fact))).map((g) => g.fact))}
|
|
67
92
|
{section("Assumed", theme.warn, "~", [...state.result.grounded.filter((g) => !g.checked).map((g) => g.fact), ...state.result.assumed])}
|
|
68
93
|
{section("Missing — answer these in your request", theme.err, "?", state.result.missing)}
|
|
69
94
|
</>
|
|
@@ -88,7 +113,7 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
|
|
|
88
113
|
{state.phase === "done" ? (
|
|
89
114
|
<>
|
|
90
115
|
<span fg={theme.text}>{"enter"}</span>
|
|
91
|
-
<span fg={theme.muted}>{"
|
|
116
|
+
<span fg={theme.muted}>{` ${REFINE_CHOICES[state.choice]!.id === "keep" ? "keep mine" : REFINE_CHOICES[state.choice]!.id} `}</span>
|
|
92
117
|
<span fg={theme.text}>{"e"}</span>
|
|
93
118
|
<span fg={theme.muted}>{" edit "}</span>
|
|
94
119
|
<span fg={theme.text}>{"esc"}</span>
|
package/src/tui/shortcuts.tsx
CHANGED
|
@@ -17,7 +17,7 @@ export const SHORTCUTS: { group: string; keys: [string, string][] }[] = [
|
|
|
17
17
|
keys: [
|
|
18
18
|
["ctrl+p", "search: actions, commands, profiles, examples"],
|
|
19
19
|
["shift+tab", "move between the chat and the plan, activity, and files panels"],
|
|
20
|
-
["ctrl+o", "
|
|
20
|
+
["ctrl+o", "open or close the panels: plan, activity, files, context, tokens"],
|
|
21
21
|
["alt+1 to alt+4", "a start action, or a next step"],
|
|
22
22
|
],
|
|
23
23
|
},
|