@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/components.tsx
CHANGED
|
@@ -21,7 +21,7 @@ export function Logo() {
|
|
|
21
21
|
const name = logoRows("forge")
|
|
22
22
|
const suffix = logoRows("QE")
|
|
23
23
|
return (
|
|
24
|
-
<box style={{ flexDirection: "column", alignItems: "flex-end" }}>
|
|
24
|
+
<box style={{ flexDirection: "column", alignItems: "flex-end", flexShrink: 0 }}>
|
|
25
25
|
{name.map((row, i) => (
|
|
26
26
|
<text key={i}>
|
|
27
27
|
<span fg={theme.faint}>{row}</span>
|
|
@@ -56,6 +56,32 @@ export type PromptProps = {
|
|
|
56
56
|
focused?: boolean
|
|
57
57
|
/** Called with the text as it changes, for actions on what is being typed (Refine). */
|
|
58
58
|
onText?: (text: string) => void
|
|
59
|
+
/** Called with the rows the text needs, so the screen around the box can make room for a long request. */
|
|
60
|
+
onLines?: (lines: number) => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The rows `text` takes when word-wrapped at `width` columns, as the prompt wraps it. */
|
|
64
|
+
export function wrappedLines(text: string, width: number): number {
|
|
65
|
+
if (width < 1) return 1
|
|
66
|
+
let rows = 0
|
|
67
|
+
for (const line of text.split("\n")) {
|
|
68
|
+
let used = 0
|
|
69
|
+
let count = 1
|
|
70
|
+
for (const word of line.split(/(\s+)/)) {
|
|
71
|
+
if (!word) continue
|
|
72
|
+
if (used + word.length <= width) used += word.length
|
|
73
|
+
else if (/^\s+$/.test(word)) used = width
|
|
74
|
+
else if (word.length > width) {
|
|
75
|
+
count += Math.ceil((used + word.length) / width) - 1
|
|
76
|
+
used = (used + word.length) % width
|
|
77
|
+
} else {
|
|
78
|
+
count++
|
|
79
|
+
used = word.length
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
rows += count
|
|
83
|
+
}
|
|
84
|
+
return Math.max(1, rows)
|
|
59
85
|
}
|
|
60
86
|
|
|
61
87
|
/** Enter sends; Shift+Enter, Alt+Enter or Ctrl+J start a new line. */
|
|
@@ -70,75 +96,82 @@ const PROMPT_KEYS: KeyBinding[] = [
|
|
|
70
96
|
export function Prompt(props: PromptProps) {
|
|
71
97
|
const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
|
|
72
98
|
const ref = useRef<TextareaRenderable>(null)
|
|
73
|
-
|
|
74
|
-
|
|
99
|
+
// The rows the text needs; the box shows that many between minRows and maxRows, so it also grows when the screen
|
|
100
|
+
// around it makes more room (the start screen folds a section for a long request).
|
|
101
|
+
const [lines, setLines] = useState(1)
|
|
102
|
+
useEffect(() => setLines(1), [props.inputKey])
|
|
103
|
+
const rows = Math.min(props.maxRows, Math.max(props.minRows, lines))
|
|
104
|
+
// The text scrolled while the box was smaller: once it all fits, show it from the top.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const t = ref.current
|
|
107
|
+
if (!t || lines > rows || t.scrollY === 0) return
|
|
108
|
+
// Going to the top scrolls the view there; going back to where the cursor was keeps it there, now that all fits.
|
|
109
|
+
const at = t.cursorOffset
|
|
110
|
+
t.gotoBufferHome()
|
|
111
|
+
t.cursorOffset = at
|
|
112
|
+
}, [rows, lines])
|
|
75
113
|
// Inserted text (a completion, an example, a refined request) is continued from its end.
|
|
76
114
|
useEffect(() => {
|
|
77
115
|
if (props.initialValue) ref.current?.gotoBufferEnd()
|
|
78
116
|
}, [props.inputKey])
|
|
79
117
|
const fit = () => {
|
|
80
118
|
props.onText?.(ref.current?.plainText ?? "")
|
|
81
|
-
|
|
82
|
-
|
|
119
|
+
// virtualLineCount counts only the lines in view, so a box could never ask to grow: count the wrapped lines.
|
|
120
|
+
const t = ref.current
|
|
121
|
+
const needed = t ? wrappedLines(t.plainText, t.width || props.width - 5) : 1
|
|
122
|
+
props.onLines?.(needed)
|
|
123
|
+
setLines(needed)
|
|
83
124
|
}
|
|
125
|
+
// A rounded box with ">" before the text, as in Claude Code; the border lights up while it has the keys.
|
|
84
126
|
return (
|
|
85
|
-
<box
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
>
|
|
100
|
-
{props.masked !== undefined ? (
|
|
101
|
-
<text style={{ height: 1 }}>
|
|
102
|
-
{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>}
|
|
103
|
-
</text>
|
|
104
|
-
) : (
|
|
105
|
-
<textarea
|
|
106
|
-
key={props.inputKey}
|
|
107
|
-
ref={ref}
|
|
108
|
-
placeholder={props.placeholder}
|
|
109
|
-
focused={props.focused !== false}
|
|
110
|
-
initialValue={props.initialValue || ""}
|
|
111
|
-
wrapMode="word"
|
|
112
|
-
keyBindings={PROMPT_KEYS}
|
|
113
|
-
onSubmit={() => props.onSubmit(ref.current?.plainText ?? "")}
|
|
114
|
-
onContentChange={fit}
|
|
115
|
-
style={{ height: rows }}
|
|
116
|
-
backgroundColor={theme.panel}
|
|
117
|
-
focusedBackgroundColor={theme.panel}
|
|
118
|
-
textColor={theme.text}
|
|
119
|
-
focusedTextColor={theme.text}
|
|
120
|
-
placeholderColor={theme.faint}
|
|
121
|
-
/>
|
|
122
|
-
)}
|
|
123
|
-
{props.compact ? null : (
|
|
124
|
-
<box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
|
|
125
|
-
<text>
|
|
126
|
-
<span fg={theme.muted}>{"Model "}</span>
|
|
127
|
-
<span fg={theme.text}>{props.model || "…"}</span>
|
|
128
|
-
</text>
|
|
129
|
-
<text>
|
|
130
|
-
{props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
|
|
131
|
-
{props.right ? <span fg={theme.faint}>{" · "}</span> : null}
|
|
132
|
-
<span fg={theme.muted}>{"profile "}</span>
|
|
133
|
-
<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>}
|
|
134
141
|
</text>
|
|
135
|
-
|
|
136
|
-
|
|
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>
|
|
137
160
|
</box>
|
|
138
|
-
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
)}
|
|
142
175
|
</box>
|
|
143
176
|
)
|
|
144
177
|
}
|
|
@@ -328,9 +361,10 @@ export function ChoicePanel(props: ChoicePanelProps) {
|
|
|
328
361
|
)
|
|
329
362
|
}
|
|
330
363
|
|
|
331
|
-
|
|
364
|
+
/** `cut`: which end of `left` gives way when it is too long: the start of a path, the end of a sentence (the tip). */
|
|
365
|
+
export function Footer({ left, right, width, cut = "start" }: { left: string; right: string; width: number; cut?: "start" | "end" }) {
|
|
332
366
|
const room = Math.max(8, width - right.length - 8)
|
|
333
|
-
const shown = left.length <= room ? left : `…${left.slice(left.length - room + 1)}`
|
|
367
|
+
const shown = left.length <= room ? left : cut === "end" ? `${left.slice(0, room - 1)}…` : `…${left.slice(left.length - room + 1)}`
|
|
334
368
|
return (
|
|
335
369
|
<box
|
|
336
370
|
style={{ flexDirection: "row", justifyContent: "space-between", paddingLeft: 2, paddingRight: 2, height: 1, flexShrink: 0 }}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { AppConfig } from "../config.ts"
|
|
5
|
+
import { CAPABILITIES } from "../capabilities/registry.ts"
|
|
6
|
+
import { allStatuses, configuredSystems, SYSTEM_LABEL, type Probes, MACHINE } from "../capabilities/status.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the start screen shows before the first request: the project it works on, the actions that work in this setup,
|
|
10
|
+
* and the reports and runs to pick up again. Everything comes from the config, the capability registry, and the files
|
|
11
|
+
* in the folder, so it changes as the setup changes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
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) => {
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(file, "utf8")
|
|
43
|
+
} catch {
|
|
44
|
+
return ""
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
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
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ALL_SYSTEMS = Object.values(SYSTEM_LABEL)
|
|
95
|
+
|
|
96
|
+
export type StartAction = { id: string; label: string; detail: string; request: string }
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The first things worth asking, in this order, among the capabilities that work in this setup: work from the systems
|
|
100
|
+
* first, then what a team asks for most. Each puts its example request in the prompt to adjust.
|
|
101
|
+
*/
|
|
102
|
+
const START_ORDER = ["run-test-cases", "release-readiness", "reports", "exploratory-testing", "write-test-cases", "pr-review", "api-testing", "find-run-tests", "browser-goals"]
|
|
103
|
+
|
|
104
|
+
const START_LABEL: Record<string, string> = {
|
|
105
|
+
"run-test-cases": "Run test cases",
|
|
106
|
+
"release-readiness": "Check release readiness",
|
|
107
|
+
reports: "Write a report to share",
|
|
108
|
+
"exploratory-testing": "Explore a story for bugs",
|
|
109
|
+
"write-test-cases": "Write test cases from a story",
|
|
110
|
+
"pr-review": "Review a pull request",
|
|
111
|
+
"api-testing": "Test an API",
|
|
112
|
+
"find-run-tests": "Run the repository's tests",
|
|
113
|
+
"browser-goals": "Try a flow in the browser",
|
|
114
|
+
}
|
|
115
|
+
|
|
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 []
|
|
119
|
+
const statuses = allStatuses(cfg, probes)
|
|
120
|
+
const systems = [...configuredSystems(cfg)].map((x) => SYSTEM_LABEL[x])
|
|
121
|
+
const out: StartAction[] = []
|
|
122
|
+
for (const id of START_ORDER) {
|
|
123
|
+
const c = CAPABILITIES.find((x) => x.id === id)
|
|
124
|
+
if (!c || statuses.get(id)?.state === "needs_setup") continue
|
|
125
|
+
// An example in the words of this setup: one that names a system that is set up, else one that names none.
|
|
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]!
|
|
132
|
+
out.push({ id, label: START_LABEL[id] || c.title, detail: example, request: example })
|
|
133
|
+
if (out.length === limit) break
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type RecentItem = { kind: "report" | "run"; title: string; dir: string; open: string; when: Date; status?: string }
|
|
139
|
+
|
|
140
|
+
/** The newest reports and runs in the reports folder: what a returning user picks up again. */
|
|
141
|
+
export function recentWork(outDir: string, limit = 3): RecentItem[] {
|
|
142
|
+
if (!existsSync(outDir)) return []
|
|
143
|
+
const items: RecentItem[] = []
|
|
144
|
+
for (const name of readdirSync(outDir)) {
|
|
145
|
+
const dir = path.join(outDir, name)
|
|
146
|
+
try {
|
|
147
|
+
if (!statSync(dir).isDirectory()) continue
|
|
148
|
+
const when = statSync(dir).mtime
|
|
149
|
+
if (existsSync(path.join(dir, "run.json"))) {
|
|
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 }
|
|
152
|
+
const report = path.join(dir, "report.html")
|
|
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 } : {}) })
|
|
155
|
+
} else if (existsSync(path.join(dir, "report.md"))) {
|
|
156
|
+
const first = readFileSync(path.join(dir, "report.md"), "utf8").split("\n")[0] || ""
|
|
157
|
+
const html = path.join(dir, "report.html")
|
|
158
|
+
items.push({ kind: "report", title: first.replace(/^#\s*/, "").trim() || name, dir, open: existsSync(html) ? html : path.join(dir, "report.md"), when })
|
|
159
|
+
}
|
|
160
|
+
} catch {}
|
|
161
|
+
}
|
|
162
|
+
return items.sort((a, b) => b.when.getTime() - a.when.getTime()).slice(0, limit)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** "just now", "12 min ago", "3 h ago", "yesterday", "4 days ago", then the date. */
|
|
166
|
+
export function ago(when: Date, now = new Date()): string {
|
|
167
|
+
const s = Math.max(0, (now.getTime() - when.getTime()) / 1000)
|
|
168
|
+
if (s < 60) return "just now"
|
|
169
|
+
if (s < 3600) return `${Math.floor(s / 60)} min ago`
|
|
170
|
+
if (s < 86400) return `${Math.floor(s / 3600)} h ago`
|
|
171
|
+
if (s < 2 * 86400) return "yesterday"
|
|
172
|
+
if (s < 7 * 86400) return `${Math.floor(s / 86400)} days ago`
|
|
173
|
+
return when.toISOString().slice(0, 10)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** "Domain", "Domain and Notifications", "Model, Domain, and Notifications". */
|
|
177
|
+
export function listWords(words: string[]): string {
|
|
178
|
+
if (words.length < 3) return words.join(" and ")
|
|
179
|
+
return `${words.slice(0, -1).join(", ")}, and ${words.at(-1)}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** "Domain and Notifications are not set up", or, when the names do not fit in `room`, "5 items are not set up". */
|
|
183
|
+
export function setupSentence(missing: string[], room: number): string {
|
|
184
|
+
const named = `${listWords(missing)} ${missing.length === 1 ? "is" : "are"} not set up`
|
|
185
|
+
return named.length <= room ? named : `${missing.length} ${missing.length === 1 ? "item is" : "items are"} not set up`
|
|
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
|
@@ -15,7 +15,7 @@ export type PaletteRun =
|
|
|
15
15
|
/** Put in the prompt to edit and send: an example request. */
|
|
16
16
|
| { kind: "insert"; text: string }
|
|
17
17
|
/** Something the shell does itself. */
|
|
18
|
-
| { kind: "action"; id: "refine" | "stop" | "details" | "chat" | "wide" | "panels" }
|
|
18
|
+
| { kind: "action"; id: "refine" | "stop" | "details" | "chat" | "wide" | "panels" | "shortcuts" }
|
|
19
19
|
|
|
20
20
|
export type PaletteItem = { id: string; group: PaletteGroup; title: string; detail?: string; keys?: string; run: PaletteRun }
|
|
21
21
|
|
|
@@ -31,8 +31,10 @@ export function paletteItems(opts: { running: boolean; headed: boolean; detailsF
|
|
|
31
31
|
if (!opts.chatWide) items.push({ id: "panels", group: "Action", title: "Move to the panels: plan, activity, files", keys: "shift+tab", run: { kind: "action", id: "panels" } })
|
|
32
32
|
}
|
|
33
33
|
if (opts.narrow) items.push({ id: "view", group: "Action", title: opts.view === "chat" ? "Show the details view" : "Back to the chat", keys: "ctrl+o", run: { kind: "action", id: opts.view === "chat" ? "details" : "chat" } })
|
|
34
|
+
items.push({ id: "shortcuts", group: "Action", title: "Keyboard shortcuts", keys: "?", run: { kind: "action", id: "shortcuts" } })
|
|
34
35
|
command("features", "What ForgeQE can do here", "/features", "and what each feature still needs")
|
|
35
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")
|
|
36
38
|
command("auth-status", "What is signed in", "/auth status")
|
|
37
39
|
command("headed", opts.headed ? "Hide the browser window" : "Show the browser window", `/headed ${opts.headed ? "off" : "on"}`)
|
|
38
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
|
@@ -18,9 +18,21 @@ export type RefineChoice = (typeof REFINE_CHOICES)[number]["id"]
|
|
|
18
18
|
|
|
19
19
|
const COLOR = theme.memory
|
|
20
20
|
|
|
21
|
+
/** A fact as written in context and in grounded, compared without case, spaces, or a closing full stop. */
|
|
22
|
+
const norm = (s: string) => s.trim().replace(/\.$/, "").replace(/\s+/g, " ").toLowerCase()
|
|
23
|
+
|
|
24
|
+
/** The choice the panel starts on: Edit while Refine still has a question, so an unclear request is not sent. */
|
|
25
|
+
export function initialChoice(missing: string[]): number {
|
|
26
|
+
return REFINE_CHOICES.findIndex((c) => c.id === (missing.length ? "edit" : "use"))
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
export function RefinePanel(props: { state: RefineState; width: number; onChoose: (id: RefineChoice) => void; onHover: (i: number) => void }) {
|
|
22
30
|
const { state, width } = props
|
|
23
31
|
const title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : " ✦ Refined request "
|
|
32
|
+
// Facts Refine checked that are also context lines are marked there, not repeated under Checked.
|
|
33
|
+
const contextLines = state.phase === "done" ? state.result.refined.split("\n").filter((l) => l.startsWith("- ")).map((l) => norm(l.slice(2))) : []
|
|
34
|
+
const inContext = new Set(contextLines)
|
|
35
|
+
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
36
|
const section = (label: string, color: string, mark: string, items: string[]) =>
|
|
25
37
|
items.length ? (
|
|
26
38
|
<box style={{ flexDirection: "column", marginTop: 1 }}>
|
|
@@ -52,6 +64,15 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
|
|
|
52
64
|
{/* The request as it is sent: its labelled parts (Goal, Context, Steps, Rules, Result) stand out. */}
|
|
53
65
|
{state.result.refined.split("\n").map((line, i) => {
|
|
54
66
|
const label = /^(Goal|Context|Steps|Rules|Result):/.exec(line)
|
|
67
|
+
// A context line Refine checked against the systems: ✓ in place of its dash, so it is not listed twice.
|
|
68
|
+
if (line.startsWith("- ") && checked.has(norm(line.slice(2)))) {
|
|
69
|
+
return (
|
|
70
|
+
<text key={i}>
|
|
71
|
+
<span fg={theme.ok}>{"✓ "}</span>
|
|
72
|
+
<span fg={theme.text}>{line.slice(2)}</span>
|
|
73
|
+
</text>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
55
76
|
return label ? (
|
|
56
77
|
<text key={i}>
|
|
57
78
|
<span fg={COLOR}>{label[0]}</span>
|
|
@@ -63,7 +84,7 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
|
|
|
63
84
|
</text>
|
|
64
85
|
)
|
|
65
86
|
})}
|
|
66
|
-
{section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked).map((g) => g.fact))}
|
|
87
|
+
{section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked && !inContext.has(norm(g.fact))).map((g) => g.fact))}
|
|
67
88
|
{section("Assumed", theme.warn, "~", [...state.result.grounded.filter((g) => !g.checked).map((g) => g.fact), ...state.result.assumed])}
|
|
68
89
|
{section("Missing — answer these in your request", theme.err, "?", state.result.missing)}
|
|
69
90
|
</>
|
|
@@ -88,7 +109,7 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
|
|
|
88
109
|
{state.phase === "done" ? (
|
|
89
110
|
<>
|
|
90
111
|
<span fg={theme.text}>{"enter"}</span>
|
|
91
|
-
<span fg={theme.muted}>{"
|
|
112
|
+
<span fg={theme.muted}>{` ${REFINE_CHOICES[state.choice]!.id === "keep" ? "keep mine" : REFINE_CHOICES[state.choice]!.id} `}</span>
|
|
92
113
|
<span fg={theme.text}>{"e"}</span>
|
|
93
114
|
<span fg={theme.muted}>{" edit "}</span>
|
|
94
115
|
<span fg={theme.text}>{"esc"}</span>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { theme } from "./theme.ts"
|
|
2
|
+
|
|
3
|
+
/** Every key the shell answers to, in one panel (? on an empty prompt); esc or ? closes it. */
|
|
4
|
+
export const SHORTCUTS: { group: string; keys: [string, string][] }[] = [
|
|
5
|
+
{
|
|
6
|
+
group: "Prompt",
|
|
7
|
+
keys: [
|
|
8
|
+
["enter", "send the request"],
|
|
9
|
+
["shift+enter, ctrl+j", "new line"],
|
|
10
|
+
["ctrl+r", "refine the request before it is sent"],
|
|
11
|
+
["/ @", "commands; runs, files, and work items to reference"],
|
|
12
|
+
["tab", "complete a suggestion, or the next model profile"],
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
group: "Find and move",
|
|
17
|
+
keys: [
|
|
18
|
+
["ctrl+p", "search: actions, commands, profiles, examples"],
|
|
19
|
+
["shift+tab", "move between the chat and the plan, activity, and files panels"],
|
|
20
|
+
["ctrl+o", "open or close the panels: plan, activity, files, context, tokens"],
|
|
21
|
+
["alt+1 to alt+4", "a start action, or a next step"],
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
group: "Run",
|
|
26
|
+
keys: [
|
|
27
|
+
["esc", "stop the request (asks first)"],
|
|
28
|
+
["ctrl+c", "close ForgeQE (asks first; twice closes)"],
|
|
29
|
+
["drag", "copy the selected text"],
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export function ShortcutsPanel(props: { width: number; wide: boolean; onClose: () => void }) {
|
|
35
|
+
const boxWidth = Math.min(84, props.width - 8)
|
|
36
|
+
const keyWidth = 22
|
|
37
|
+
return (
|
|
38
|
+
<box
|
|
39
|
+
position="absolute"
|
|
40
|
+
zIndex={100}
|
|
41
|
+
border
|
|
42
|
+
borderStyle="rounded"
|
|
43
|
+
borderColor={theme.accent}
|
|
44
|
+
title=" Keyboard shortcuts "
|
|
45
|
+
titleColor={theme.accent}
|
|
46
|
+
bottomTitle=" esc close "
|
|
47
|
+
bottomTitleAlignment="right"
|
|
48
|
+
onMouseDown={props.onClose}
|
|
49
|
+
style={{ top: 2, left: Math.floor((props.width - boxWidth) / 2), width: boxWidth, flexDirection: "column", backgroundColor: theme.panel, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}
|
|
50
|
+
>
|
|
51
|
+
{SHORTCUTS.map((g) => (
|
|
52
|
+
<box key={g.group} style={{ flexDirection: "column", marginBottom: 1, flexShrink: 0 }}>
|
|
53
|
+
<text fg={theme.muted}>{g.group}</text>
|
|
54
|
+
{g.keys
|
|
55
|
+
.filter(([k]) => props.wide || k !== "shift+tab")
|
|
56
|
+
.map(([k, what]) => (
|
|
57
|
+
<text key={k}>
|
|
58
|
+
<span fg={theme.text}>{` ${k.padEnd(keyWidth)}`}</span>
|
|
59
|
+
<span fg={theme.muted}>{what}</span>
|
|
60
|
+
</text>
|
|
61
|
+
))}
|
|
62
|
+
</box>
|
|
63
|
+
))}
|
|
64
|
+
</box>
|
|
65
|
+
)
|
|
66
|
+
}
|