@capagents/forgeqe 0.0.0-stage → 1.0.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 +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
/** The system clipboard command for this machine, tried in order; the first one that runs wins. */
|
|
4
|
+
function nativeCommands(): string[][] {
|
|
5
|
+
if (process.platform === "darwin") return [["pbcopy"]]
|
|
6
|
+
if (process.platform === "win32") return [["clip"]]
|
|
7
|
+
const wayland = process.env.WAYLAND_DISPLAY ? [["wl-copy"]] : []
|
|
8
|
+
return [...wayland, ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Put text on the clipboard two ways: OSC 52 through the terminal (works over SSH and in VS Code, iTerm2, kitty,
|
|
13
|
+
* WezTerm, Windows Terminal) and the system command (works in terminals without OSC 52, such as macOS Terminal).
|
|
14
|
+
*/
|
|
15
|
+
export function copyText(text: string, osc52?: (text: string) => boolean): boolean {
|
|
16
|
+
let copied = false
|
|
17
|
+
try {
|
|
18
|
+
copied = osc52?.(text) === true
|
|
19
|
+
} catch {}
|
|
20
|
+
for (const [cmd, ...args] of nativeCommands()) {
|
|
21
|
+
try {
|
|
22
|
+
const r = spawnSync(cmd, args, { input: text, stdio: ["pipe", "ignore", "ignore"], timeout: 2000 })
|
|
23
|
+
if (r.status === 0) return true
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
return copied
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Selected transcript text as the user wants it pasted: the card bars and the padding the layout adds removed. */
|
|
30
|
+
export function cleanSelection(text: string): string {
|
|
31
|
+
return text
|
|
32
|
+
.split("\n")
|
|
33
|
+
.map((line) => line.replace(/^\s*[┃╹]\s?/, "").trimEnd())
|
|
34
|
+
.join("\n")
|
|
35
|
+
.replace(/^\n+|\n+$/g, "")
|
|
36
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { KeyBinding, TextareaRenderable } from "@opentui/core"
|
|
2
|
+
import { useEffect, useRef, useState, type ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import { logoRows, theme } from "./theme.ts"
|
|
5
|
+
|
|
6
|
+
export const BAR = {
|
|
7
|
+
topLeft: "┃",
|
|
8
|
+
topRight: " ",
|
|
9
|
+
bottomLeft: "┃",
|
|
10
|
+
bottomRight: " ",
|
|
11
|
+
horizontal: " ",
|
|
12
|
+
vertical: "┃",
|
|
13
|
+
topT: " ",
|
|
14
|
+
bottomT: " ",
|
|
15
|
+
leftT: "┃",
|
|
16
|
+
rightT: " ",
|
|
17
|
+
cross: " ",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Logo() {
|
|
21
|
+
const name = logoRows("forge")
|
|
22
|
+
const suffix = logoRows("QE")
|
|
23
|
+
return (
|
|
24
|
+
<box style={{ flexDirection: "column", alignItems: "flex-end" }}>
|
|
25
|
+
{name.map((row, i) => (
|
|
26
|
+
<text key={i}>
|
|
27
|
+
<span fg={theme.faint}>{row}</span>
|
|
28
|
+
<span fg={theme.text}>{` ${suffix[i]}`}</span>
|
|
29
|
+
</text>
|
|
30
|
+
))}
|
|
31
|
+
<text fg={theme.muted}>by Capgemini</text>
|
|
32
|
+
</box>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type PromptProps = {
|
|
37
|
+
width: number
|
|
38
|
+
inputKey: number
|
|
39
|
+
onSubmit: (value: string) => void
|
|
40
|
+
/** Text rows the box shows when empty; it grows with the text up to maxRows, then scrolls. */
|
|
41
|
+
minRows: number
|
|
42
|
+
maxRows: number
|
|
43
|
+
placeholder: string
|
|
44
|
+
modeColor: string
|
|
45
|
+
profile: string
|
|
46
|
+
model: string
|
|
47
|
+
right?: string
|
|
48
|
+
rightColor?: string
|
|
49
|
+
/** A secret being entered: the box shows one dot per character instead of a text field, so the value is never drawn. */
|
|
50
|
+
masked?: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Enter sends; Shift+Enter, Alt+Enter or Ctrl+J start a new line. */
|
|
54
|
+
const PROMPT_KEYS: KeyBinding[] = [
|
|
55
|
+
{ name: "return", action: "submit" },
|
|
56
|
+
{ name: "kpenter", action: "submit" },
|
|
57
|
+
{ name: "return", shift: true, action: "newline" },
|
|
58
|
+
{ name: "return", meta: true, action: "newline" },
|
|
59
|
+
{ name: "linefeed", action: "newline" },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
export function Prompt(props: PromptProps) {
|
|
63
|
+
const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
|
|
64
|
+
const ref = useRef<TextareaRenderable>(null)
|
|
65
|
+
const [rows, setRows] = useState(props.minRows)
|
|
66
|
+
useEffect(() => setRows(props.minRows), [props.inputKey, props.minRows])
|
|
67
|
+
const fit = () => {
|
|
68
|
+
const lines = ref.current?.virtualLineCount ?? 1
|
|
69
|
+
setRows(Math.min(props.maxRows, Math.max(props.minRows, lines)))
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<box style={{ flexDirection: "column", width: props.width, flexShrink: 0 }}>
|
|
73
|
+
<box
|
|
74
|
+
border={["left"]}
|
|
75
|
+
borderColor={props.modeColor}
|
|
76
|
+
customBorderChars={BAR}
|
|
77
|
+
style={{
|
|
78
|
+
flexDirection: "column",
|
|
79
|
+
backgroundColor: theme.panel,
|
|
80
|
+
paddingLeft: 2,
|
|
81
|
+
paddingRight: 2,
|
|
82
|
+
paddingTop: 1,
|
|
83
|
+
paddingBottom: 1,
|
|
84
|
+
width: props.width,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{props.masked !== undefined ? (
|
|
88
|
+
<text style={{ height: 1 }}>
|
|
89
|
+
{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>}
|
|
90
|
+
</text>
|
|
91
|
+
) : (
|
|
92
|
+
<textarea
|
|
93
|
+
key={props.inputKey}
|
|
94
|
+
ref={ref}
|
|
95
|
+
placeholder={props.placeholder}
|
|
96
|
+
focused
|
|
97
|
+
wrapMode="word"
|
|
98
|
+
keyBindings={PROMPT_KEYS}
|
|
99
|
+
onSubmit={() => props.onSubmit(ref.current?.plainText ?? "")}
|
|
100
|
+
onContentChange={fit}
|
|
101
|
+
style={{ height: rows }}
|
|
102
|
+
backgroundColor={theme.panel}
|
|
103
|
+
focusedBackgroundColor={theme.panel}
|
|
104
|
+
textColor={theme.text}
|
|
105
|
+
focusedTextColor={theme.text}
|
|
106
|
+
placeholderColor={theme.faint}
|
|
107
|
+
/>
|
|
108
|
+
)}
|
|
109
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
|
|
110
|
+
<text>
|
|
111
|
+
<span fg={theme.muted}>{"Model "}</span>
|
|
112
|
+
<span fg={theme.text}>{props.model || "…"}</span>
|
|
113
|
+
</text>
|
|
114
|
+
<text>
|
|
115
|
+
{props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
|
|
116
|
+
{props.right ? <span fg={theme.faint}>{" · "}</span> : null}
|
|
117
|
+
<span fg={theme.muted}>{"profile "}</span>
|
|
118
|
+
<span fg={theme.text}>{props.profile || "none"}</span>
|
|
119
|
+
</text>
|
|
120
|
+
</box>
|
|
121
|
+
</box>
|
|
122
|
+
<text>
|
|
123
|
+
<span fg={props.modeColor}>{edge.slice(0, 1)}</span>
|
|
124
|
+
<span fg={theme.panel}>{edge.slice(1)}</span>
|
|
125
|
+
</text>
|
|
126
|
+
</box>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
131
|
+
const BAR_CELLS = 8
|
|
132
|
+
const TRAIL = [theme.accent, "#3a78c2", "#27507f"]
|
|
133
|
+
|
|
134
|
+
/** Animates on its own timer so only the spinner re-renders. `bar` is a sweeping dot bar, `dots` a braille spinner. */
|
|
135
|
+
export function Spinner({ kind = "bar", color = theme.warn }: { kind?: "bar" | "dots"; color?: string }) {
|
|
136
|
+
const [frame, setFrame] = useState(0)
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const timer = setInterval(() => setFrame((f) => f + 1), kind === "bar" ? 90 : 80)
|
|
139
|
+
return () => clearInterval(timer)
|
|
140
|
+
}, [kind])
|
|
141
|
+
if (kind === "dots") return <text fg={color}>{BRAILLE[frame % BRAILLE.length]}</text>
|
|
142
|
+
const period = BAR_CELLS * 2 - 2
|
|
143
|
+
const p = frame % period
|
|
144
|
+
const head = p < BAR_CELLS ? p : period - p
|
|
145
|
+
const behind = p < BAR_CELLS ? -1 : 1
|
|
146
|
+
return (
|
|
147
|
+
<text>
|
|
148
|
+
{Array.from({ length: BAR_CELLS }, (_, i) => {
|
|
149
|
+
const age = (i - head) * behind
|
|
150
|
+
const lit = age >= 0 && age < TRAIL.length
|
|
151
|
+
return (
|
|
152
|
+
<span key={i} fg={lit ? TRAIL[age] : theme.faint}>
|
|
153
|
+
{lit ? "■" : "·"}
|
|
154
|
+
</span>
|
|
155
|
+
)
|
|
156
|
+
})}
|
|
157
|
+
</text>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type Hint = { key: string; label: string; onPress?: () => void }
|
|
162
|
+
|
|
163
|
+
export function Hints({ width, items, left }: { width: number; items: Hint[]; left?: ReactNode }) {
|
|
164
|
+
return (
|
|
165
|
+
<box style={{ width, height: 1, flexShrink: 0, flexDirection: "row", justifyContent: "space-between" }}>
|
|
166
|
+
<box style={{ flexDirection: "row" }}>{left}</box>
|
|
167
|
+
<box style={{ flexDirection: "row" }}>
|
|
168
|
+
{items.map((item, i) => (
|
|
169
|
+
<text key={`${item.key}-${item.label}`} onMouseDown={item.onPress}>
|
|
170
|
+
<span fg={theme.text}>{`${i ? " " : ""}${item.key}`}</span>
|
|
171
|
+
<span fg={item.onPress ? theme.muted : theme.faint}>{item.label ? ` ${item.label}` : ""}</span>
|
|
172
|
+
</text>
|
|
173
|
+
))}
|
|
174
|
+
</box>
|
|
175
|
+
</box>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type Choice = { id: string; label: string }
|
|
180
|
+
|
|
181
|
+
export type ChoicePanelProps = {
|
|
182
|
+
width: number
|
|
183
|
+
title: string
|
|
184
|
+
detail: string
|
|
185
|
+
color: string
|
|
186
|
+
options: Choice[]
|
|
187
|
+
selected: number
|
|
188
|
+
onChoose: (id: string) => void
|
|
189
|
+
onHover: (index: number) => void
|
|
190
|
+
/** One option per line, for many or long options. */
|
|
191
|
+
vertical?: boolean
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Question with buttons, as the agent runtime shows for permissions: arrows or tab move, enter confirms, click works too. */
|
|
195
|
+
export function ChoicePanel(props: ChoicePanelProps) {
|
|
196
|
+
const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
|
|
197
|
+
return (
|
|
198
|
+
<box style={{ flexDirection: "column", width: props.width, flexShrink: 0 }}>
|
|
199
|
+
<box
|
|
200
|
+
border={["left"]}
|
|
201
|
+
borderColor={props.color}
|
|
202
|
+
customBorderChars={BAR}
|
|
203
|
+
style={{
|
|
204
|
+
flexDirection: "column",
|
|
205
|
+
backgroundColor: theme.panel,
|
|
206
|
+
paddingLeft: 2,
|
|
207
|
+
paddingRight: 2,
|
|
208
|
+
paddingTop: 1,
|
|
209
|
+
paddingBottom: 1,
|
|
210
|
+
width: props.width,
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
<text>
|
|
214
|
+
<span fg={props.color}>{"△ "}</span>
|
|
215
|
+
<span fg={theme.text}>{props.title}</span>
|
|
216
|
+
</text>
|
|
217
|
+
{props.detail ? <text fg={theme.muted}>{` ${props.detail}`}</text> : null}
|
|
218
|
+
<box style={{ flexDirection: props.vertical ? "column" : "row", justifyContent: "space-between", marginTop: 1 }}>
|
|
219
|
+
<box style={{ flexDirection: props.vertical ? "column" : "row", alignItems: "flex-start" }}>
|
|
220
|
+
{props.options.map((option, i) => {
|
|
221
|
+
const active = i === props.selected
|
|
222
|
+
return (
|
|
223
|
+
<box
|
|
224
|
+
key={option.id}
|
|
225
|
+
onMouseDown={() => props.onChoose(option.id)}
|
|
226
|
+
onMouseOver={() => props.onHover(i)}
|
|
227
|
+
style={{ paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: active ? props.color : theme.button }}
|
|
228
|
+
>
|
|
229
|
+
<text fg={active ? theme.onButton : theme.muted}>{option.label}</text>
|
|
230
|
+
</box>
|
|
231
|
+
)
|
|
232
|
+
})}
|
|
233
|
+
</box>
|
|
234
|
+
<text style={{ marginTop: props.vertical ? 1 : 0 }}>
|
|
235
|
+
<span fg={theme.text}>{props.vertical ? "↑↓" : "⇆"}</span>
|
|
236
|
+
<span fg={theme.muted}>{" select "}</span>
|
|
237
|
+
<span fg={theme.text}>{"enter"}</span>
|
|
238
|
+
<span fg={theme.muted}>{" confirm "}</span>
|
|
239
|
+
<span fg={theme.text}>{"esc"}</span>
|
|
240
|
+
<span fg={theme.muted}>{" cancel"}</span>
|
|
241
|
+
</text>
|
|
242
|
+
</box>
|
|
243
|
+
</box>
|
|
244
|
+
<text>
|
|
245
|
+
<span fg={props.color}>{edge.slice(0, 1)}</span>
|
|
246
|
+
<span fg={theme.panel}>{edge.slice(1)}</span>
|
|
247
|
+
</text>
|
|
248
|
+
</box>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function Footer({ left, right, width }: { left: string; right: string; width: number }) {
|
|
253
|
+
const room = Math.max(8, width - right.length - 8)
|
|
254
|
+
const shown = left.length <= room ? left : `…${left.slice(left.length - room + 1)}`
|
|
255
|
+
return (
|
|
256
|
+
<box
|
|
257
|
+
style={{ flexDirection: "row", justifyContent: "space-between", paddingLeft: 2, paddingRight: 2, height: 1, flexShrink: 0 }}
|
|
258
|
+
>
|
|
259
|
+
<text fg={theme.muted}>{shown}</text>
|
|
260
|
+
<text fg={theme.muted}>{right}</text>
|
|
261
|
+
</box>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AskQuestion } from "../mcp/tools.ts"
|
|
2
|
+
import type { Choice } from "./components.tsx"
|
|
3
|
+
import { theme } from "./theme.ts"
|
|
4
|
+
|
|
5
|
+
/** The choice dialogs: stop, quit, wait for the user, and the agent's questions. */
|
|
6
|
+
|
|
7
|
+
export type Dialog = "stop" | "quit" | "wait" | "ask"
|
|
8
|
+
|
|
9
|
+
export function dialogSpec(
|
|
10
|
+
dialog: Dialog,
|
|
11
|
+
running: boolean,
|
|
12
|
+
outDir: string,
|
|
13
|
+
agent = false,
|
|
14
|
+
waitText = "",
|
|
15
|
+
ask?: AskQuestion,
|
|
16
|
+
): { title: string; detail: string; color: string; options: Choice[] } {
|
|
17
|
+
if (dialog === "ask" && ask) {
|
|
18
|
+
return { title: ask.title, detail: ask.detail || "", color: theme.accent, options: ask.options.map((o) => ({ id: `ask:${o.id}`, label: o.label })) }
|
|
19
|
+
}
|
|
20
|
+
if (dialog === "wait") {
|
|
21
|
+
return {
|
|
22
|
+
title: "Your turn",
|
|
23
|
+
detail: waitText,
|
|
24
|
+
color: theme.accent,
|
|
25
|
+
options: [
|
|
26
|
+
{ id: "wait-done", label: "Done, continue" },
|
|
27
|
+
{ id: "wait-cancel", label: "Cancel" },
|
|
28
|
+
],
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (dialog === "stop") {
|
|
32
|
+
return {
|
|
33
|
+
title: agent ? "Stop ForgeQE's agent?" : "Stop the running agent?",
|
|
34
|
+
detail: agent
|
|
35
|
+
? "Any browser run stops after its current action and keeps its steps. Files the agent already changed stay."
|
|
36
|
+
: "The browser stops after the current action. Steps recorded so far are still saved.",
|
|
37
|
+
color: theme.warn,
|
|
38
|
+
options: [
|
|
39
|
+
{ id: "stop", label: "Stop run" },
|
|
40
|
+
{ id: "stop-close", label: "Stop and close ForgeQE" },
|
|
41
|
+
{ id: "cancel", label: "Keep running" },
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (running) {
|
|
46
|
+
return {
|
|
47
|
+
title: "Close ForgeQE?",
|
|
48
|
+
detail: "A run is in progress. It stops first and its files are saved.",
|
|
49
|
+
color: theme.err,
|
|
50
|
+
options: [
|
|
51
|
+
{ id: "stop-close", label: "Stop and close" },
|
|
52
|
+
{ id: "cancel", label: "Cancel" },
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
title: "Close ForgeQE?",
|
|
58
|
+
detail: `Runs are saved in ${outDir}/. Press ctrl+c again to close straight away.`,
|
|
59
|
+
color: theme.err,
|
|
60
|
+
options: [
|
|
61
|
+
{ id: "close", label: "Close" },
|
|
62
|
+
{ id: "cancel", label: "Cancel" },
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/tui/main.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createCliRenderer } from "@opentui/core"
|
|
2
|
+
import { createRoot } from "@opentui/react"
|
|
3
|
+
|
|
4
|
+
import { App, type TuiOptions } from "./App.tsx"
|
|
5
|
+
|
|
6
|
+
export async function startTui(options: TuiOptions): Promise<void> {
|
|
7
|
+
// Ctrl-C opens the close prompt; a second Ctrl-C quits.
|
|
8
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false })
|
|
9
|
+
await new Promise<void>((resolve, reject) => {
|
|
10
|
+
renderer.on("destroy", () => resolve())
|
|
11
|
+
try {
|
|
12
|
+
createRoot(renderer).render(<App options={options} />)
|
|
13
|
+
} catch (err) {
|
|
14
|
+
renderer.destroy()
|
|
15
|
+
reject(err)
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type { TuiOptions }
|
package/src/tui/text.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AppConfig } from "../config.ts"
|
|
2
|
+
|
|
3
|
+
/** Short text for the shell: clipped lines, the model label, hosts, and the activity line from engine logs. */
|
|
4
|
+
|
|
5
|
+
export function clip(text: string, n = 72): string {
|
|
6
|
+
const s = text.replace(/\s+/g, " ").trim()
|
|
7
|
+
return s.length <= n ? s : `${s.slice(0, n - 1)}…`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function modelLabel(cfg: AppConfig): string {
|
|
11
|
+
if (cfg.llm.provider === "none") return "none set up"
|
|
12
|
+
if (cfg.llm.provider === "mock") return "offline"
|
|
13
|
+
return `${cfg.llm.provider}/${cfg.llm.deployment || cfg.llm.model}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function hostOf(url: string): string {
|
|
17
|
+
try {
|
|
18
|
+
return new URL(url.includes("://") ? url : `https://${url}`).hostname
|
|
19
|
+
} catch {
|
|
20
|
+
return url
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function activityFromLog(text: string): string {
|
|
25
|
+
if (text.startsWith("$ ") || /^Setting up the ForgeQE engine/.test(text)) return "Installing the ForgeQE engine (one time)…"
|
|
26
|
+
if (text.startsWith("ForgeQE engine ·")) return "Starting the browser…"
|
|
27
|
+
return clip(text, 70)
|
|
28
|
+
}
|
package/src/tui/theme.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
export const theme = {
|
|
6
|
+
accent: "#4ea1ff",
|
|
7
|
+
panel: "#161b22",
|
|
8
|
+
text: "#e6edf3",
|
|
9
|
+
muted: "#8b949e",
|
|
10
|
+
faint: "#6e7681",
|
|
11
|
+
ok: "#9ece6a",
|
|
12
|
+
warn: "#e0af68",
|
|
13
|
+
err: "#f7768e",
|
|
14
|
+
thought: "#a5b4fc",
|
|
15
|
+
memory: "#c792ea",
|
|
16
|
+
button: "#21262d",
|
|
17
|
+
onButton: "#0d1117",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 950 → "950", 47_100 → "47.1K", 2_300_000 → "2.3M". */
|
|
21
|
+
export function formatTokens(n: number): string {
|
|
22
|
+
if (n < 1000) return String(n)
|
|
23
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`
|
|
24
|
+
return `${(n / 1_000_000).toFixed(1)}M`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whole seconds for clocks; `precise` keeps one decimal under 10s for per-step timings. */
|
|
28
|
+
export function formatDuration(ms: number, precise = false): string {
|
|
29
|
+
const s = Math.max(0, ms) / 1000
|
|
30
|
+
if (precise && s < 10) return `${s.toFixed(1)}s`
|
|
31
|
+
if (s < 60) return `${Math.floor(s)}s`
|
|
32
|
+
const m = Math.floor(s / 60)
|
|
33
|
+
return `${m}m ${String(Math.floor(s % 60)).padStart(2, "0")}s`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Four rows: ascenders, then a three-row x-height. Capitals use all four. */
|
|
37
|
+
const GLYPHS: Record<string, [string, string, string, string]> = {
|
|
38
|
+
w: [" ", "█ █", "█ █ █", "▀▀▀▀▀"],
|
|
39
|
+
e: [" ", "█▀▀█", "█▀▀▀", "▀▀▀▀"],
|
|
40
|
+
b: ["▄ ", "█▀▀█", "█ █", "▀▀▀▀"],
|
|
41
|
+
p: [" ", "█▀▀█", "█ █", "█▀▀▀"],
|
|
42
|
+
i: [" ", "▀", "█", "▀"],
|
|
43
|
+
l: ["▄", "█", "█", "▀"],
|
|
44
|
+
o: [" ", "█▀▀█", "█ █", "▀▀▀▀"],
|
|
45
|
+
t: [" ▄ ", "▀█▀▀", " █ ", " ▀▀▀"],
|
|
46
|
+
r: [" ", "█▀▀", "█ ", "▀ "],
|
|
47
|
+
c: [" ", "█▀▀▀", "█ ", "▀▀▀▀"],
|
|
48
|
+
h: ["▄ ", "█▀▀█", "█ █", "▀ ▀"],
|
|
49
|
+
s: [" ", "█▀▀▀", "▀▀▀█", "▀▀▀▀"],
|
|
50
|
+
a: [" ", "▀▀▀█", "█▀▀█", "▀▀▀▀"],
|
|
51
|
+
f: [" ▄▀", "▀█▀", " █ ", " ▀ "],
|
|
52
|
+
g: [" ", "█▀▀█", "█ █", "▀▀▀█"],
|
|
53
|
+
Q: ["█▀▀█ ", "█ █ ", "█ █ ", "▀▀▀█▄"],
|
|
54
|
+
E: ["█▀▀▀", "█▄▄ ", "█ ", "▀▀▀▀"],
|
|
55
|
+
".": [" ", " ", " ", "▀"],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function logoRows(word: string): string[] {
|
|
59
|
+
const rows = ["", "", "", ""]
|
|
60
|
+
const letters = [...word]
|
|
61
|
+
letters.forEach((ch, index) => {
|
|
62
|
+
const glyph = GLYPHS[ch] ?? GLYPHS[ch.toLowerCase()]
|
|
63
|
+
if (!glyph) return
|
|
64
|
+
for (let r = 0; r < 4; r++) rows[r] += (index ? " " : "") + glyph[r]
|
|
65
|
+
})
|
|
66
|
+
return rows
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const EXAMPLES = [
|
|
70
|
+
"Go to booking.com and search for hotels in Mumbai",
|
|
71
|
+
"Run SHOP-12 and SHOP-14 from Jira",
|
|
72
|
+
"Run the test cases in smoke.yaml, two browsers at once",
|
|
73
|
+
"Write a Playwright test for the last run",
|
|
74
|
+
"Add a GitHub Actions workflow that runs smoke.yaml on every PR",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
export const TIPS = [
|
|
78
|
+
"Just say what you want; ForgeQE works out whether it's a goal, test cases, a test, or a pipeline",
|
|
79
|
+
"Press tab to switch LLM profile",
|
|
80
|
+
"Use /headed on to watch the agent drive Chrome",
|
|
81
|
+
"A passed run becomes a test in your repo's own language and framework",
|
|
82
|
+
"Set allowed_hosts in forgeqe.yaml to keep runs on one domain",
|
|
83
|
+
"Re-running a goal replays it from page memory, mostly without the LLM",
|
|
84
|
+
"Ask what page memory knows about a site to see its locators and confidence",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
export function pick<T>(items: T[]): T {
|
|
88
|
+
return items[Math.floor(Math.random() * items.length)]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function shortCwd(): string {
|
|
92
|
+
const cwd = process.cwd()
|
|
93
|
+
const home = os.homedir()
|
|
94
|
+
return cwd === home || cwd.startsWith(home + path.sep) ? `~${cwd.slice(home.length)}` : cwd
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function packageVersion(): string {
|
|
98
|
+
try {
|
|
99
|
+
const file = path.resolve(import.meta.dir, "..", "..", "package.json")
|
|
100
|
+
return String(JSON.parse(readFileSync(file, "utf8")).version || "")
|
|
101
|
+
} catch {
|
|
102
|
+
return ""
|
|
103
|
+
}
|
|
104
|
+
}
|