@capagents/forgeqe 1.0.3 → 1.0.5
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 +41 -3
- package/docs/user-guide.html +37 -4
- package/package.json +1 -1
- package/prompts/README.md +1 -0
- package/prompts/refine.md +17 -0
- package/src/capabilities/status.ts +4 -3
- package/src/refine.ts +116 -0
- package/src/tui/App.tsx +440 -31
- package/src/tui/components.tsx +29 -3
- package/src/tui/inspector.tsx +159 -89
- package/src/tui/next.ts +32 -0
- package/src/tui/palette.tsx +169 -0
- package/src/tui/refine-panel.tsx +94 -0
- package/src/tui/setup.ts +37 -0
- package/src/tui/suggest.ts +111 -0
- package/src/tui/transcript.tsx +47 -5
- package/src/usage.ts +1 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Refined } from "../refine.ts"
|
|
2
|
+
import { Spinner } from "./components.tsx"
|
|
3
|
+
import { theme } from "./theme.ts"
|
|
4
|
+
|
|
5
|
+
/** Refine's panel, in the prompt's place: working, the result with what was checked, or why it could not refine. */
|
|
6
|
+
export type RefineState =
|
|
7
|
+
| { phase: "working"; original: string }
|
|
8
|
+
| { phase: "done"; original: string; result: Refined; choice: number }
|
|
9
|
+
| { phase: "error"; original: string; message: string }
|
|
10
|
+
|
|
11
|
+
export const REFINE_CHOICES = [
|
|
12
|
+
{ id: "use", label: "Use refined", key: "enter" },
|
|
13
|
+
{ id: "edit", label: "Edit", key: "e" },
|
|
14
|
+
{ id: "keep", label: "Keep mine", key: "esc" },
|
|
15
|
+
] as const
|
|
16
|
+
|
|
17
|
+
export type RefineChoice = (typeof REFINE_CHOICES)[number]["id"]
|
|
18
|
+
|
|
19
|
+
const COLOR = theme.memory
|
|
20
|
+
|
|
21
|
+
export function RefinePanel(props: { state: RefineState; width: number; onChoose: (id: RefineChoice) => void; onHover: (i: number) => void }) {
|
|
22
|
+
const { state, width } = props
|
|
23
|
+
const title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : " ✦ Refined request "
|
|
24
|
+
const section = (label: string, color: string, mark: string, items: string[]) =>
|
|
25
|
+
items.length ? (
|
|
26
|
+
<box style={{ flexDirection: "column", marginTop: 1 }}>
|
|
27
|
+
<text fg={theme.muted}>{label}</text>
|
|
28
|
+
{items.map((it, i) => (
|
|
29
|
+
<box key={i} style={{ flexDirection: "row" }}>
|
|
30
|
+
<text fg={color} style={{ width: 2, flexShrink: 0 }}>
|
|
31
|
+
{mark}
|
|
32
|
+
</text>
|
|
33
|
+
<text fg={theme.text} style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
34
|
+
{it}
|
|
35
|
+
</text>
|
|
36
|
+
</box>
|
|
37
|
+
))}
|
|
38
|
+
</box>
|
|
39
|
+
) : null
|
|
40
|
+
return (
|
|
41
|
+
<box style={{ flexDirection: "column", width, flexShrink: 0 }}>
|
|
42
|
+
<box border borderStyle="rounded" borderColor={COLOR} title={title} titleColor={COLOR} style={{ flexDirection: "column", paddingLeft: 1, paddingRight: 1, backgroundColor: theme.panel }}>
|
|
43
|
+
{state.phase === "working" ? (
|
|
44
|
+
<box style={{ flexDirection: "row" }}>
|
|
45
|
+
<Spinner kind="dots" color={COLOR} />
|
|
46
|
+
<text fg={theme.muted}>{" Checking your request against this setup: capabilities, systems, project, recent runs…"}</text>
|
|
47
|
+
</box>
|
|
48
|
+
) : state.phase === "error" ? (
|
|
49
|
+
<text fg={theme.err}>{state.message}</text>
|
|
50
|
+
) : (
|
|
51
|
+
<>
|
|
52
|
+
<text fg={theme.text}>{state.result.refined}</text>
|
|
53
|
+
{section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked).map((g) => g.fact))}
|
|
54
|
+
{section("Assumed", theme.warn, "~", [...state.result.grounded.filter((g) => !g.checked).map((g) => g.fact), ...state.result.assumed])}
|
|
55
|
+
{section("Missing — answer these in your request", theme.err, "?", state.result.missing)}
|
|
56
|
+
</>
|
|
57
|
+
)}
|
|
58
|
+
</box>
|
|
59
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, marginTop: 0 }}>
|
|
60
|
+
{state.phase === "done" ? (
|
|
61
|
+
<box style={{ flexDirection: "row" }}>
|
|
62
|
+
{REFINE_CHOICES.map((c, i) => {
|
|
63
|
+
const active = i === state.choice
|
|
64
|
+
return (
|
|
65
|
+
<box key={c.id} onMouseDown={() => props.onChoose(c.id)} onMouseOver={() => props.onHover(i)} style={{ paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: active ? COLOR : theme.button }}>
|
|
66
|
+
<text fg={active ? theme.onButton : theme.muted}>{c.label}</text>
|
|
67
|
+
</box>
|
|
68
|
+
)
|
|
69
|
+
})}
|
|
70
|
+
</box>
|
|
71
|
+
) : (
|
|
72
|
+
<text fg={theme.faint}>{state.phase === "working" ? "" : "Your request is back in the prompt."}</text>
|
|
73
|
+
)}
|
|
74
|
+
<text>
|
|
75
|
+
{state.phase === "done" ? (
|
|
76
|
+
<>
|
|
77
|
+
<span fg={theme.text}>{"enter"}</span>
|
|
78
|
+
<span fg={theme.muted}>{" use "}</span>
|
|
79
|
+
<span fg={theme.text}>{"e"}</span>
|
|
80
|
+
<span fg={theme.muted}>{" edit "}</span>
|
|
81
|
+
<span fg={theme.text}>{"esc"}</span>
|
|
82
|
+
<span fg={theme.muted}>{" keep mine"}</span>
|
|
83
|
+
</>
|
|
84
|
+
) : (
|
|
85
|
+
<>
|
|
86
|
+
<span fg={theme.text}>{"esc"}</span>
|
|
87
|
+
<span fg={theme.muted}>{state.phase === "working" ? " cancel" : " close"}</span>
|
|
88
|
+
</>
|
|
89
|
+
)}
|
|
90
|
+
</text>
|
|
91
|
+
</box>
|
|
92
|
+
</box>
|
|
93
|
+
)
|
|
94
|
+
}
|
package/src/tui/setup.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AppConfig } from "../config.ts"
|
|
2
|
+
import { MACHINE, requirementGap, type Probes } from "../capabilities/status.ts"
|
|
3
|
+
import type { Requirement } from "../capabilities/registry.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The home screen's setup checklist: the few things most features rest on, each checked the way the capability
|
|
7
|
+
* registry checks a requirement, so the checklist and /features never disagree.
|
|
8
|
+
*/
|
|
9
|
+
export type SetupItem = {
|
|
10
|
+
id: "model" | "agent" | "system" | "domain" | "notify"
|
|
11
|
+
label: string
|
|
12
|
+
done: boolean
|
|
13
|
+
/** What is missing, and how to set it up: shown when the item is chosen. */
|
|
14
|
+
fix?: string
|
|
15
|
+
/** A command that sets it up, run when the item is chosen. */
|
|
16
|
+
command?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CHECKS: { id: SetupItem["id"]; label: string; requirement: Requirement; command?: string }[] = [
|
|
20
|
+
{ id: "model", label: "Model", requirement: { kind: "live_model" } },
|
|
21
|
+
{ id: "agent", label: "Coding agent", requirement: { kind: "coding_agent" } },
|
|
22
|
+
{ id: "system", label: "Work items", requirement: { kind: "system", anyOf: ["azure_devops", "jira", "testrail", "zephyr", "qtest", "xray"] }, command: "/auth" },
|
|
23
|
+
{ id: "domain", label: "Domain", requirement: { kind: "domain" } },
|
|
24
|
+
{ id: "notify", label: "Notifications", requirement: { kind: "notify_channels" } },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export function setupChecklist(cfg: AppConfig, probes: Probes = MACHINE): SetupItem[] {
|
|
28
|
+
return CHECKS.map((c) => {
|
|
29
|
+
const gap = requirementGap(c.requirement, cfg, probes)
|
|
30
|
+
return {
|
|
31
|
+
id: c.id,
|
|
32
|
+
label: c.label,
|
|
33
|
+
done: !gap,
|
|
34
|
+
...(gap ? { fix: `${c.label}: ForgeQE needs ${gap.missing}. To set it up, ${gap.fix}.`, ...(c.command ? { command: c.command } : {}) } : {}),
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { adoSettings } from "../cases/ado.ts"
|
|
6
|
+
import { http } from "../cases/http.ts"
|
|
7
|
+
import type { AppConfig } from "../config.ts"
|
|
8
|
+
import type { PaletteItem } from "./palette.tsx"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Suggestions while typing: "/" completes a command, "@" a reference to something real (a recent run, a file in the
|
|
12
|
+
* repository, an Azure DevOps work item looked up by its id). Nothing is suggested that does not exist.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type Suggestion = { kind: "command" | "run" | "file" | "item"; label: string; detail?: string; insert: string }
|
|
16
|
+
|
|
17
|
+
/** What is being completed: a command at the start of the prompt, or the "@" word being typed at its end. */
|
|
18
|
+
export type Trigger = { kind: "/" | "@"; token: string; start: number }
|
|
19
|
+
|
|
20
|
+
export function triggerAt(text: string): Trigger | null {
|
|
21
|
+
if (/^\/\S*$/.test(text)) return { kind: "/", token: text.slice(1), start: 0 }
|
|
22
|
+
const m = /(^|\s)@(\S*)$/.exec(text)
|
|
23
|
+
return m ? { kind: "@", token: m[2]!, start: m.index + m[1]!.length } : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The prompt with the trigger's word replaced by the completion, ready for more typing. */
|
|
27
|
+
export function complete(text: string, t: Trigger, insert: string): string {
|
|
28
|
+
return `${text.slice(0, t.start)}${insert} `
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function commandSuggestions(items: PaletteItem[], token: string): Suggestion[] {
|
|
32
|
+
const seen = new Set<string>()
|
|
33
|
+
const out: Suggestion[] = []
|
|
34
|
+
for (const item of items) {
|
|
35
|
+
if (item.run.kind !== "command" || seen.has(item.run.text)) continue
|
|
36
|
+
seen.add(item.run.text)
|
|
37
|
+
out.push({ kind: "command", label: item.run.text, detail: item.title, insert: item.run.text })
|
|
38
|
+
}
|
|
39
|
+
const t = token.toLowerCase()
|
|
40
|
+
return out.filter((s) => s.label.slice(1).toLowerCase().startsWith(t)).concat(out.filter((s) => !s.label.slice(1).toLowerCase().startsWith(t) && s.label.toLowerCase().includes(t)))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type RecentRun = { id: string; dir: string; goal: string }
|
|
44
|
+
|
|
45
|
+
/** The newest run folders (named by their start time) with their goals. */
|
|
46
|
+
export function recentRuns(outDir: string, limit = 8): RecentRun[] {
|
|
47
|
+
if (!existsSync(outDir)) return []
|
|
48
|
+
return readdirSync(outDir)
|
|
49
|
+
.filter((d) => /^\d{8}-\d{6}(-\d+)?$/.test(d))
|
|
50
|
+
.sort()
|
|
51
|
+
.reverse()
|
|
52
|
+
.slice(0, limit)
|
|
53
|
+
.map((id) => {
|
|
54
|
+
let goal = ""
|
|
55
|
+
try {
|
|
56
|
+
goal = String(JSON.parse(readFileSync(path.join(outDir, id, "run.json"), "utf8")).goal || "")
|
|
57
|
+
} catch {}
|
|
58
|
+
return { id, dir: path.join(outDir, id), goal }
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const filesCache = new Map<string, string[]>()
|
|
63
|
+
|
|
64
|
+
/** The repository's tracked files (git ls-files), once per folder; none outside a git repository. */
|
|
65
|
+
export function repoFiles(cwd: string): string[] {
|
|
66
|
+
const known = filesCache.get(cwd)
|
|
67
|
+
if (known) return known
|
|
68
|
+
const r = spawnSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 20_000_000 })
|
|
69
|
+
const files = r.status === 0 ? r.stdout.split("\n").filter(Boolean).slice(0, 20_000) : []
|
|
70
|
+
filesCache.set(cwd, files)
|
|
71
|
+
return files
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type WorkItem = { id: number; title: string; type: string; project: string }
|
|
75
|
+
|
|
76
|
+
/** What `@` can refer to for the word typed after it. */
|
|
77
|
+
export function referenceSuggestions(token: string, src: { runs: RecentRun[]; files: string[]; item?: WorkItem | null; cwd: string }): Suggestion[] {
|
|
78
|
+
const t = token.toLowerCase()
|
|
79
|
+
const out: Suggestion[] = []
|
|
80
|
+
if (/^\d+$/.test(token)) {
|
|
81
|
+
if (src.item) out.push({ kind: "item", label: `${src.item.type} ${src.item.id}`, detail: `${src.item.title} · ${src.item.project}`, insert: `Azure DevOps ${src.item.type.toLowerCase()} ${src.item.id} ("${src.item.title}")` })
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
if (!t || "run".startsWith(t) || t.startsWith("run")) {
|
|
85
|
+
const runs = src.runs.filter((r) => !t.startsWith("run") || t === "run" || r.id.includes(t.slice(3)))
|
|
86
|
+
for (const [i, r] of runs.entries()) out.push({ kind: "run", label: i === 0 ? "last run" : `run ${r.id}`, detail: r.goal || r.id, insert: `the run in ${path.relative(src.cwd, r.dir) || r.dir}` })
|
|
87
|
+
}
|
|
88
|
+
if (t.length >= 2) {
|
|
89
|
+
const byName = src.files.filter((f) => path.basename(f).toLowerCase().includes(t))
|
|
90
|
+
const byPath = src.files.filter((f) => !path.basename(f).toLowerCase().includes(t) && f.toLowerCase().includes(t))
|
|
91
|
+
for (const f of [...byName, ...byPath].slice(0, 8)) out.push({ kind: "file", label: path.basename(f), detail: path.dirname(f) === "." ? "" : path.dirname(f), insert: f })
|
|
92
|
+
}
|
|
93
|
+
return out.slice(0, 10)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const itemCache = new Map<number, WorkItem | null>()
|
|
97
|
+
|
|
98
|
+
/** An Azure DevOps work item's title, type, and project by its id; null when it does not exist or cannot be read. */
|
|
99
|
+
export async function lookupWorkItem(cfg: AppConfig, id: number): Promise<WorkItem | null> {
|
|
100
|
+
if (itemCache.has(id)) return itemCache.get(id)!
|
|
101
|
+
let item: WorkItem | null = null
|
|
102
|
+
try {
|
|
103
|
+
const { auth, org } = adoSettings(cfg.sources)
|
|
104
|
+
const { data } = await http<{ id: number; fields: Record<string, unknown> }>(`${org}/_apis/wit/workitems/${id}?fields=System.Title,System.WorkItemType,System.TeamProject&api-version=7.1`, { auth })
|
|
105
|
+
item = { id: data.id, title: String(data.fields["System.Title"] || ""), type: String(data.fields["System.WorkItemType"] || "Work item"), project: String(data.fields["System.TeamProject"] || "") }
|
|
106
|
+
} catch {
|
|
107
|
+
item = null
|
|
108
|
+
}
|
|
109
|
+
itemCache.set(id, item)
|
|
110
|
+
return item
|
|
111
|
+
}
|
package/src/tui/transcript.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
1
3
|
import path from "node:path"
|
|
2
4
|
import { SyntaxStyle } from "@opentui/core"
|
|
3
5
|
|
|
@@ -147,7 +149,7 @@ export function SummaryCard({ summary }: { summary: RunSummary }) {
|
|
|
147
149
|
{summary.savedDir ? (
|
|
148
150
|
<text>
|
|
149
151
|
<span fg={theme.faint}>{"Saved "}</span>
|
|
150
|
-
<
|
|
152
|
+
<Linked text={`${summary.savedDir}/ `} color={theme.muted} />
|
|
151
153
|
<span fg={theme.faint}>{summary.savedFiles.join(" · ")}</span>
|
|
152
154
|
</text>
|
|
153
155
|
) : null}
|
|
@@ -155,12 +157,54 @@ export function SummaryCard({ summary }: { summary: RunSummary }) {
|
|
|
155
157
|
)
|
|
156
158
|
}
|
|
157
159
|
|
|
160
|
+
/** A web address, or a path with at least one folder: what Linked turns into a link when it exists. */
|
|
161
|
+
const LINKABLE = /(https?:\/\/[^\s)"'<>]+|(?:~|\.{1,2})?\/?[\w.@-]+(?:\/[\w.@-]+)+\/?)/g
|
|
162
|
+
|
|
163
|
+
/** The target of a link: the address itself, or the file's absolute path; null for a path that does not exist. */
|
|
164
|
+
export function linkTarget(token: string, cwd = process.cwd()): string | null {
|
|
165
|
+
if (/^https?:\/\//.test(token)) return token
|
|
166
|
+
const file = token.startsWith("~/") ? path.join(os.homedir(), token.slice(2)) : path.resolve(cwd, token)
|
|
167
|
+
return existsSync(file) ? `file://${file}` : null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Text whose web addresses and existing file paths can be opened from the terminal (OSC 8 links: cmd/ctrl+click in
|
|
172
|
+
* VS Code and most terminals). A path that does not exist stays plain text, so no link leads nowhere.
|
|
173
|
+
*/
|
|
174
|
+
export function Linked({ text, color }: { text: string; color: string }) {
|
|
175
|
+
const parts: { text: string; href?: string }[] = []
|
|
176
|
+
let at = 0
|
|
177
|
+
for (const m of text.matchAll(LINKABLE)) {
|
|
178
|
+
const href = linkTarget(m[0])
|
|
179
|
+
if (!href) continue
|
|
180
|
+
if (m.index! > at) parts.push({ text: text.slice(at, m.index) })
|
|
181
|
+
parts.push({ text: m[0], href })
|
|
182
|
+
at = m.index! + m[0].length
|
|
183
|
+
}
|
|
184
|
+
if (at < text.length) parts.push({ text: text.slice(at) })
|
|
185
|
+
return (
|
|
186
|
+
<>
|
|
187
|
+
{parts.map((p, i) =>
|
|
188
|
+
p.href ? (
|
|
189
|
+
<a key={i} href={p.href} fg={theme.accent}>
|
|
190
|
+
{p.text}
|
|
191
|
+
</a>
|
|
192
|
+
) : (
|
|
193
|
+
<span key={i} fg={color}>
|
|
194
|
+
{p.text}
|
|
195
|
+
</span>
|
|
196
|
+
),
|
|
197
|
+
)}
|
|
198
|
+
</>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
158
202
|
export function Row({ label, value, color = theme.muted }: { label: string; value: string; color?: string }) {
|
|
159
203
|
if (!value) return null
|
|
160
204
|
return (
|
|
161
205
|
<text>
|
|
162
206
|
<span fg={theme.faint}>{label.padEnd(10)}</span>
|
|
163
|
-
<
|
|
207
|
+
<Linked text={value} color={color} />
|
|
164
208
|
</text>
|
|
165
209
|
)
|
|
166
210
|
}
|
|
@@ -257,9 +301,7 @@ export function ResultCard({ card }: { card: Card }) {
|
|
|
257
301
|
</box>
|
|
258
302
|
{card.body.length ? <box style={{ height: 1 }} /> : null}
|
|
259
303
|
{card.body.map((l, i) => (
|
|
260
|
-
<text key={i}
|
|
261
|
-
{l || " "}
|
|
262
|
-
</text>
|
|
304
|
+
<text key={i}>{l ? <Linked text={l} color={lineColor(l)} /> : " "}</text>
|
|
263
305
|
))}
|
|
264
306
|
</box>
|
|
265
307
|
)
|
package/src/usage.ts
CHANGED
|
@@ -98,7 +98,7 @@ export function usageLine(usage: Usage, cost?: Cost | null): string {
|
|
|
98
98
|
// The meter: every model session reports here, by role
|
|
99
99
|
// ---------------------------------------------------------------------------------------------
|
|
100
100
|
|
|
101
|
-
export type Role = "ForgeQE's agent" | "browser agent" | "Scribe" | "reviewer" | "judge"
|
|
101
|
+
export type Role = "ForgeQE's agent" | "browser agent" | "Scribe" | "reviewer" | "judge" | "refiner"
|
|
102
102
|
|
|
103
103
|
type Entry = { usage: Usage; cost?: Cost; priced: boolean }
|
|
104
104
|
|