@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.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +757 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,983 @@
1
+ import type { ScrollBoxRenderable, Selection } from "@opentui/core"
2
+ import { useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react"
3
+ import path from "node:path"
4
+ import { useEffect, useRef, useState } from "react"
5
+
6
+ import { NO_MODEL_SET_UP } from "../llm.ts"
7
+ import type { CodeEvent, ReplayMode } from "../types.ts"
8
+ import { brainUnavailable, carryOver, openBrain, sessionSetup, type Brain, type BrainOptions } from "../brain/index.ts"
9
+ import { INSTALL_HINT } from "../code/runtime.ts"
10
+ import { briefText, detailFrom, shows, showsAgentTool, stepIntent, type Detail } from "../detail.ts"
11
+ import { stepVerdictLines } from "../cases/types.ts"
12
+ import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
13
+ import { authStatus, openInBrowser, signIn, signOut, type AuthIO } from "../auth/signin.ts"
14
+ import { HELP, parseCommand } from "../commands.ts"
15
+ import { loadConfig, selectProfile, type AppConfig } from "../config.ts"
16
+ import { TOOLS, type AskQuestion, type ToolEvent } from "../mcp/tools.ts"
17
+ import type { executeRun } from "../run.ts"
18
+ import { bundleLogs } from "../sessionLog.ts"
19
+ import { findSkill, loadSkills } from "../skills/registry.ts"
20
+ import { skillDetail, skillsOverview } from "../skills/view.ts"
21
+ import { counts } from "../suite/report.ts"
22
+ import { buildSummary, shortPath } from "../summary.ts"
23
+ import { meterSnapshot, requestLine } from "../usage.ts"
24
+ import { cleanSelection, copyText } from "./clipboard.ts"
25
+ import { ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Hint } from "./components.tsx"
26
+ import { EXAMPLES, TIPS, formatDuration, formatTokens, packageVersion, pick, shortCwd, theme } from "./theme.ts"
27
+ import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
28
+ import { Dialog, dialogSpec } from "./dialogs.ts"
29
+ import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
30
+ import { featuresText } from "../capabilities/render.ts"
31
+
32
+ export type TuiOptions = {
33
+ configPath?: string
34
+ profile?: string
35
+ /** Start URL pinned for browser runs. */
36
+ url?: string
37
+ /** First message, sent as if typed. */
38
+ goal?: string
39
+ headed?: boolean
40
+ replay?: ReplayMode
41
+ memory?: boolean
42
+ /** Repo the agent works in and writes tests for; null: no tests unless asked; undefined: code.repo. */
43
+ repo?: string | null
44
+ /** Replaces executeRun; tests use it to hold a run open. */
45
+ runner?: typeof executeRun
46
+ /** Replaces openBrain in tests. */
47
+ brain?: (o: BrainOptions) => Promise<Brain>
48
+ /** Replaces the clipboard write in tests. */
49
+ copy?: (text: string) => boolean
50
+ /** Show everything the agents do (their reasoning, every file read, engine logs); /details toggles it. */
51
+ verbose?: boolean
52
+ /** Replaces opening a page in the browser (sign-in) in tests. */
53
+ openUrl?: (url: string) => void
54
+ }
55
+
56
+ const VERSION = packageVersion()
57
+
58
+ export function App({ options }: { options: TuiOptions }) {
59
+ const renderer = useRenderer()
60
+ const { width, height } = useTerminalDimensions()
61
+ const scrollRef = useRef<ScrollBoxRenderable>(null)
62
+ const submitRef = useRef<(value: string) => void>(() => {})
63
+ const configRef = useRef<AppConfig | null>(null)
64
+ const abortRef = useRef<AbortController | null>(null)
65
+ const brainRef = useRef<Brain | null>(null)
66
+ /** The conversation so far, carried into a new agent session when the profile changes. */
67
+ const convoRef = useRef<{ user: string; reply: string }[]>([])
68
+ const idRef = useRef(1)
69
+ const booted = useRef(false)
70
+ const closeAfterRun = useRef(false)
71
+ const markRef = useRef(0)
72
+ const stepRef = useRef(0)
73
+ const replayingRef = useRef(false)
74
+ const usedRef = useRef(new Map<string, number>())
75
+ const directNoted = useRef(false)
76
+ const thinkingRef = useRef(false)
77
+ const runIndex = useRef(0)
78
+ const producedRef = useRef<Produced>(emptyProduced())
79
+ const testsRef = useRef<TestsSetting>({ on: false, why: "" })
80
+ const codingRef = useRef(false)
81
+ const codeRunDir = useRef("")
82
+
83
+ const [lines, setLines] = useState<Line[]>([])
84
+ const [headed, setHeaded] = useState(Boolean(options.headed))
85
+ const [maxSteps, setMaxSteps] = useState(40)
86
+ const [profile, setProfile] = useState(options.profile || "")
87
+ const [model, setModel] = useState("")
88
+ const [status, setStatus] = useState("idle")
89
+ const [step, setStep] = useState(0)
90
+ const [tokens, setTokens] = useState(0)
91
+ const [tick, setTick] = useState(0)
92
+ const [inputKey, setInputKey] = useState(0)
93
+ const [activity, setActivity] = useState("")
94
+ const [lastStats, setLastStats] = useState("")
95
+ const [dialog, setDialog] = useState<Dialog | null>(null)
96
+ const [choice, setChoice] = useState(0)
97
+ const [thinking, setThinking] = useState(false)
98
+ const [browsing, setBrowsing] = useState(false)
99
+ const [coding, setCoding] = useState(false)
100
+ const [suiteProgress, setSuiteProgress] = useState<{ done: number; total: number } | null>(null)
101
+ const [copied, setCopied] = useState("")
102
+ const [example] = useState(() => pick(EXAMPLES))
103
+ const [tip] = useState(() => pick(TIPS))
104
+ const started = useRef<number | null>(null)
105
+
106
+ const detailRef = useRef<Detail>(detailFrom(options.verbose))
107
+ const headedRef = useRef(headed)
108
+ const stepsRef = useRef(maxSteps)
109
+ const dialogRef = useRef(dialog)
110
+ const choiceRef = useRef(choice)
111
+ const waitRef = useRef<{ text: string; resolve: (ok: boolean) => void } | null>(null)
112
+ const askRef = useRef<{ q: AskQuestion; resolve: (id: string | null) => void } | null>(null)
113
+ /** A line the sign-in is waiting for (a URL, an email, a token); the next Enter answers it instead of the agent. */
114
+ const priceNotesRef = useRef<string[]>([])
115
+ const entryRef = useRef<{ label: string; secret: boolean; initial?: string; resolve: (v: string | null) => void } | null>(null)
116
+ const [entry, setEntry] = useState<{ label: string; secret: boolean; initial?: string } | null>(null)
117
+ /** What has been typed or pasted into a secret entry; only its length is ever rendered. */
118
+ const secretRef = useRef("")
119
+ const [secretLength, setSecretLength] = useState(0)
120
+ headedRef.current = headed
121
+ stepsRef.current = maxSteps
122
+ dialogRef.current = dialog
123
+ choiceRef.current = choice
124
+ thinkingRef.current = thinking
125
+
126
+ const push = (kind: Line["kind"], text: string) => {
127
+ const parts = kind === "user" || kind === "reply" ? [text] : text.split("\n")
128
+ setLines((prev) => {
129
+ const next = [...prev]
130
+ for (const part of parts) next.push({ id: idRef.current++, kind, text: part })
131
+ return next.slice(-300)
132
+ })
133
+ }
134
+ const pushLine = (line: Omit<Line, "id">) => setLines((prev) => [...prev, { ...line, id: idRef.current++ }].slice(-300))
135
+ /** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
136
+ const pushTool = (text: string, ok: boolean) => push(!ok ? "err" : /^(Writing|Editing) /.test(text) ? "file" : "tool", text)
137
+ const pushProduced = () => {
138
+ const card = producedCard(producedRef.current, testsRef.current)
139
+ if (card) pushLine({ kind: "card", text: card.title, card })
140
+ }
141
+
142
+ const useTokens = (key: string, n: number) => {
143
+ usedRef.current.set(key, n)
144
+ setTokens([...usedRef.current.values()].reduce((a, b) => a + b, 0))
145
+ }
146
+
147
+ useEffect(() => {
148
+ if (status !== "running") return
149
+ const timer = setInterval(() => setTick((n) => n + 1), 1000)
150
+ return () => clearInterval(timer)
151
+ }, [status])
152
+
153
+ // The terminal hands mouse drags to the TUI, so text selected with the mouse is copied here when the drag ends.
154
+ useEffect(() => {
155
+ let timer: ReturnType<typeof setTimeout> | undefined
156
+ const onSelection = (selection: Selection | null) => {
157
+ const text = cleanSelection(selection?.getSelectedText() || "")
158
+ if (!text.trim()) return
159
+ const ok = (options.copy || ((t: string) => copyText(t, (x) => renderer.copyToClipboardOSC52(x))))(text)
160
+ setCopied(ok ? `copied ${text.length} ${text.length === 1 ? "character" : "characters"}` : "could not reach the clipboard")
161
+ clearTimeout(timer)
162
+ timer = setTimeout(() => setCopied(""), 2500)
163
+ }
164
+ renderer.on("selection", onSelection)
165
+ return () => {
166
+ clearTimeout(timer)
167
+ renderer.off("selection", onSelection)
168
+ }
169
+ }, [renderer])
170
+
171
+ const closeBrain = () => {
172
+ const brain = brainRef.current
173
+ brainRef.current = null
174
+ void brain?.close().catch(() => {})
175
+ }
176
+
177
+ const quit = () => {
178
+ abortRef.current?.abort()
179
+ closeBrain()
180
+ renderer.destroy()
181
+ }
182
+
183
+ const outDirLabel = () => path.relative(process.cwd(), configRef.current?.export.outDir || "out") || "."
184
+
185
+ const settleWait = (ok: boolean) => {
186
+ const w = waitRef.current
187
+ waitRef.current = null
188
+ w?.resolve(ok)
189
+ }
190
+
191
+ const settleAsk = (id: string | null) => {
192
+ const a = askRef.current
193
+ askRef.current = null
194
+ a?.resolve(id)
195
+ }
196
+
197
+ const openDialog = (next: Dialog) => {
198
+ if (next !== "wait") settleWait(false)
199
+ if (next !== "ask") settleAsk(null)
200
+ setChoice(0)
201
+ setDialog(next)
202
+ }
203
+
204
+ const closeDialog = () => {
205
+ if (dialogRef.current === "wait") settleWait(false)
206
+ if (dialogRef.current === "ask") settleAsk(null)
207
+ setDialog(null)
208
+ }
209
+
210
+ /** A tool needs the user to do something by hand (sign in in the browser); resolves when they continue or cancel. */
211
+ const waitForUser = (prompt: string) =>
212
+ new Promise<boolean>((resolve) => {
213
+ settleWait(false)
214
+ waitRef.current = { text: prompt, resolve }
215
+ push("sys", prompt)
216
+ openDialog("wait")
217
+ })
218
+
219
+ /** A tool asks the user to pick an option (a sprint, whether to run tests); resolves with its id, or null on cancel. */
220
+ const ask = (q: AskQuestion) =>
221
+ new Promise<string | null>((resolve) => {
222
+ settleAsk(null)
223
+ push("sys", [q.title, ...(q.lines || [])].join("\n"))
224
+ openDialog("ask")
225
+ askRef.current = { q, resolve }
226
+ })
227
+
228
+ const settleEntry = (value: string | null) => {
229
+ const e = entryRef.current
230
+ entryRef.current = null
231
+ secretRef.current = ""
232
+ setSecretLength(0)
233
+ setEntry(null)
234
+ e?.resolve(value)
235
+ }
236
+
237
+ const setSecret = (value: string) => {
238
+ secretRef.current = value
239
+ setSecretLength(value.length)
240
+ }
241
+
242
+ /** The sign-in asks for a line of text in the prompt box; a secret is typed unseen and never sent to the agent. */
243
+ const input: AuthIO["input"] = (q) =>
244
+ new Promise((resolve) => {
245
+ settleEntry(null)
246
+ const e = { label: q.label, secret: Boolean(q.secret), initial: q.initial }
247
+ entryRef.current = { ...e, resolve }
248
+ setEntry(e)
249
+ setInputKey((k) => k + 1)
250
+ })
251
+
252
+ const runAuth = async (action: "status" | "login" | "logout", system?: string) => {
253
+ const cfg = configRef.current || undefined
254
+ if (action === "status") {
255
+ push("sys", authStatus(cfg).join("\n"))
256
+ return
257
+ }
258
+ if (action === "logout") {
259
+ push("sys", await signOut(system!))
260
+ reloadAfterAuth()
261
+ return
262
+ }
263
+ const controller = begin("Signing in…")
264
+ controller.signal.addEventListener("abort", () => settleEntry(null), { once: true })
265
+ let finalStatus = "fail"
266
+ try {
267
+ const res = await signIn(
268
+ {
269
+ say: (text, kind) => push(kind === "ok" ? "ok" : kind === "err" ? "err" : "sys", text),
270
+ input,
271
+ choose: ask,
272
+ openUrl: (url) => {
273
+ push("sys", `Opening ${url}`)
274
+ ;(options.openUrl || openInBrowser)(url)
275
+ },
276
+ signal: controller.signal,
277
+ },
278
+ system,
279
+ cfg,
280
+ )
281
+ finalStatus = res.ok ? "done" : controller.signal.aborted ? "stopped" : res.message === "Cancelled." ? "idle" : "fail"
282
+ if (res.ok) reloadAfterAuth()
283
+ } finally {
284
+ settleEntry(null)
285
+ finish(finalStatus)
286
+ }
287
+ }
288
+
289
+ /** New credentials: read the config again (a system may be set up now) and restart the agent so its MCP servers use them. */
290
+ const reloadAfterAuth = () => {
291
+ try {
292
+ const cfg = loadConfig({ configPath: options.configPath })
293
+ configRef.current = configRef.current ? selectProfile(cfg, configRef.current.activeProfile) : cfg
294
+ } catch (err) {
295
+ push("err", (err as Error).message)
296
+ }
297
+ if (brainRef.current) {
298
+ closeBrain()
299
+ push("sys", "The agent reconnects with the new sign-in on your next message and keeps the conversation.")
300
+ }
301
+ }
302
+
303
+ const requestStop = () => {
304
+ const controller = abortRef.current
305
+ if (!controller || controller.signal.aborted) return
306
+ controller.abort()
307
+ push("sys", "Stopping…")
308
+ setActivity("Stopping: closing the browser and saving files…")
309
+ }
310
+
311
+ const choose = (id: string) => {
312
+ setDialog(null)
313
+ if (id === "wait-done" || id === "wait-cancel") settleWait(id === "wait-done")
314
+ if (id.startsWith("ask:")) settleAsk(id.slice(4))
315
+ if (id === "stop") requestStop()
316
+ if (id === "close") quit()
317
+ if (id === "stop-close") {
318
+ if (!abortRef.current) {
319
+ quit()
320
+ return
321
+ }
322
+ closeAfterRun.current = true
323
+ requestStop()
324
+ push("sys", "ForgeQE closes once the browser has shut down.")
325
+ }
326
+ }
327
+
328
+ const applyProfile = (name: string) => {
329
+ if (!configRef.current) throw new Error("Config is not loaded.")
330
+ configRef.current = selectProfile(configRef.current, name)
331
+ setProfile(name)
332
+ setModel(modelLabel(configRef.current))
333
+ if (brainRef.current) {
334
+ closeBrain()
335
+ push("sys", "The agent switches to the new profile on your next message and keeps the conversation.")
336
+ }
337
+ }
338
+
339
+ const cycleProfile = () => {
340
+ const cfg = configRef.current
341
+ if (!cfg || abortRef.current) return
342
+ const names = Object.keys(cfg.profiles)
343
+ if (names.length < 2) return
344
+ const next = names[(names.indexOf(cfg.activeProfile) + 1) % names.length]
345
+ applyProfile(next)
346
+ }
347
+
348
+ useKeyboard((key) => {
349
+ const open = dialogRef.current
350
+ if (key.name === "c" && key.ctrl) {
351
+ if (open === "quit") quit()
352
+ else openDialog("quit")
353
+ return
354
+ }
355
+ if (open) {
356
+ const opts = dialogSpec(open, Boolean(abortRef.current), outDirLabel(), thinkingRef.current, waitRef.current?.text, askRef.current?.q).options
357
+ const n = opts.length
358
+ if (key.name === "left" || key.name === "up" || (key.name === "tab" && key.shift)) setChoice((c) => (c - 1 + n) % n)
359
+ else if (key.name === "right" || key.name === "down" || key.name === "tab") setChoice((c) => (c + 1) % n)
360
+ else if (key.name === "return" || key.name === "enter") choose(opts[Math.min(choiceRef.current, n - 1)].id)
361
+ else if (key.name === "escape") closeDialog()
362
+ return
363
+ }
364
+ if (key.name === "escape" && entryRef.current) {
365
+ push("sys", "Cancelled.")
366
+ settleEntry(null)
367
+ return
368
+ }
369
+ if (entryRef.current?.secret) {
370
+ if (key.name === "return" || key.name === "enter" || key.name === "kpenter") submitRef.current(secretRef.current)
371
+ else if (key.name === "backspace") setSecret(secretRef.current.slice(0, -1))
372
+ else if (key.name === "u" && key.ctrl) setSecret("")
373
+ else if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence >= " ") setSecret(secretRef.current + key.sequence)
374
+ return
375
+ }
376
+ if (key.name === "tab" && !key.shift) cycleProfile()
377
+ if (key.name === "p" && key.ctrl) push("sys", HELP)
378
+ if (key.name === "escape" && abortRef.current) openDialog("stop")
379
+ })
380
+
381
+ usePaste((event) => {
382
+ if (!entryRef.current?.secret) return
383
+ event.preventDefault()
384
+ setSecret(secretRef.current + new TextDecoder().decode(event.bytes).replace(/[\r\n]+/g, "").trim())
385
+ })
386
+
387
+ /** Browser runs and suites report here, whether the agent started them or the text went straight to the browser. */
388
+ const onToolEvent = (e: ToolEvent, agent: boolean) => {
389
+ const aborted = () => Boolean(abortRef.current?.signal.aborted)
390
+ const cfg = configRef.current
391
+ switch (e.type) {
392
+ case "run-start":
393
+ runIndex.current++
394
+ stepRef.current = 0
395
+ markRef.current = Date.now()
396
+ replayingRef.current = false
397
+ setStep(0)
398
+ setBrowsing(true)
399
+ if (agent) push("code", `Browser: ${clip(e.goal, 110)}`)
400
+ if (!aborted()) setActivity(cfg?.llm.provider === "mock" ? "Running the offline demo…" : "Starting the browser…")
401
+ return
402
+ case "run-event": {
403
+ const event = e.event
404
+ if (event.type === "step") {
405
+ stepRef.current = event.step
406
+ setStep(event.step)
407
+ replayingRef.current = event.source === "memory"
408
+ if (!replayingRef.current && detailRef.current === "full") push("timing", `Thought: ${formatDuration(Date.now() - markRef.current, true)}`)
409
+ else if (!aborted()) setActivity(`Replaying step ${event.step} from memory…`)
410
+ }
411
+ if (event.type === "memory") {
412
+ if (shows(event, detailRef.current)) push("memory", event.text)
413
+ markRef.current = Date.now()
414
+ }
415
+ if (event.type === "usage") useTokens(`run-${runIndex.current}`, event.usage.input + event.usage.output)
416
+ if (event.type === "log") {
417
+ if (shows(event, detailRef.current)) push(event.level === "error" ? "err" : "sys", event.text)
418
+ if (!aborted()) setActivity(activityFromLog(event.text))
419
+ }
420
+ if (event.type === "thought" && !replayingRef.current) {
421
+ if (shows(event, detailRef.current)) push("thought", stepIntent(event.step, event.text, detailRef.current))
422
+ if (!aborted()) setActivity(`Acting in the browser (step ${stepRef.current})…`)
423
+ }
424
+ if (event.type === "outcome") {
425
+ markRef.current = Date.now()
426
+ if (!aborted()) setActivity(replayingRef.current ? "Replaying from memory…" : `Thinking about step ${stepRef.current + 1}…`)
427
+ }
428
+ if (event.type === "outcome") push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
429
+ return
430
+ }
431
+ case "run-done": {
432
+ const r = e.result
433
+ const summary = buildSummary({
434
+ status: r?.status || "fail",
435
+ text: r?.summary || e.error || "",
436
+ finalUrl: r?.finalUrl || "",
437
+ files: e.files,
438
+ memory: r?.memory,
439
+ usage: r?.usage,
440
+ cost: r?.cost,
441
+ outcome: r?.outcome,
442
+ failedChecks: r?.failedChecks,
443
+ quality: r?.quality,
444
+ budgetExceeded: r?.budgetExceeded,
445
+ ms: e.ms,
446
+ tokens: r ? r.usage.input + r.usage.output : 0,
447
+ steps: stepRef.current,
448
+ })
449
+ pushLine({ kind: "summary", text: summary.text, summary })
450
+ producedRef.current.runs.push({ runDir: e.runDir, status: r?.status || "fail", files: e.files })
451
+ setLastStats(summary.stats)
452
+ setBrowsing(false)
453
+ if (agent && !aborted()) setActivity("The agent is reading the result…")
454
+ return
455
+ }
456
+ case "suite-start":
457
+ push("sys", `${e.cases.length} test case${e.cases.length === 1 ? "" : "s"}${e.parallel > 1 ? `, ${e.parallel} at a time` : ""}`)
458
+ setSuiteProgress({ done: 0, total: e.cases.length })
459
+ return
460
+ case "suite-event": {
461
+ const s = e.event
462
+ const tag = (id: string) => ((configRef.current?.suite.parallel || 1) > 1 ? `[${id}] ` : "")
463
+ if (s.type === "case-start") {
464
+ push("code", `${s.index + 1}/${s.total} ${s.case.id} ${s.case.title}`)
465
+ // What was read from the test management system: where the case lives and the steps the browser will follow.
466
+ const steps = s.case.steps
467
+ const shown = detailRef.current === "full" ? steps : steps.slice(0, 15)
468
+ const caseLines = [
469
+ ...(s.case.url ? [`Source ${s.case.url}`] : []),
470
+ ...shown.map((st, i) => `${String(i + 1).padStart(2)}. ${clip(st.action.replace(/\s+/g, " "), 110)}${st.expected ? ` → ${clip(st.expected.replace(/\s+/g, " "), 80)}` : ""}`),
471
+ ...(steps.length > shown.length ? [` and ${steps.length - shown.length} more steps`] : []),
472
+ ...(!steps.length && s.case.description ? [clip(s.case.description.replace(/\s+/g, " "), 200)] : []),
473
+ ]
474
+ if (caseLines.length) push("sys", caseLines.map((l) => ` ${tag(s.case.id)}${l}`).join("\n"))
475
+ if (!aborted()) setActivity(`Running ${s.case.id}…`)
476
+ } else if (s.type === "case-done") {
477
+ setSuiteProgress((p) => (p ? { ...p, done: p.done + 1 } : p))
478
+ const r = s.result
479
+ push(r.status === "passed" ? "ok" : "err", `${r.case.id} ${r.status} in ${formatDuration(r.ms)}${r.failure ? ` · ${FAILURE_LABEL[r.failure.type]}` : ""}`)
480
+ if (r.failure) push("sys", ` ${tag(r.case.id)}${clip(r.failure.reason, 200)} Next: ${FAILURE_ACTION[r.failure.type]}.`)
481
+ // Each step of the case with its verdict, and what the page showed for a step that did not pass.
482
+ for (const line of stepVerdictLines(r)) push(line.startsWith("✓") ? "ok" : line.startsWith("✗") ? "err" : "sys", ` ${tag(r.case.id)}${clip(line, 200)}`)
483
+ } else if (s.type === "writeback" && !aborted()) setActivity("Posting results…")
484
+ else if (s.type === "case-event") {
485
+ const ev = s.event
486
+ const p = tag(s.case.id)
487
+ if (ev.type === "usage") useTokens(`case-${s.index}`, ev.usage.input + ev.usage.output)
488
+ else if (!shows(ev, detailRef.current)) {
489
+ if (ev.type === "log" && !aborted()) setActivity(`${p}${activityFromLog(ev.text)}`)
490
+ } else if (ev.type === "log") push(ev.level === "error" ? "err" : "sys", p + ev.text)
491
+ else if (ev.type === "memory") push("memory", p + ev.text)
492
+ else if (ev.type === "thought") push("thought", p + stepIntent(ev.step, ev.text, detailRef.current))
493
+ else if (ev.type === "outcome") push(ev.ok ? "ok" : "err", p + briefText(ev.text, detailRef.current))
494
+ }
495
+ return
496
+ }
497
+ case "suite-done": {
498
+ pushLine({ kind: "suitesummary", text: e.suite.name, suite: e.suite })
499
+ producedRef.current.suites.push({ html: e.suite.files.html, junit: e.suite.files.junit })
500
+ const c = counts(e.suite.results)
501
+ setLastStats(`${c.passed}/${c.total} passed · ${formatDuration(e.suite.ms)}`)
502
+ setSuiteProgress(null)
503
+ if (agent && !aborted()) setActivity("The agent is reading the results…")
504
+ return
505
+ }
506
+ case "info":
507
+ push(e.ok === undefined ? "code" : e.ok ? "ok" : "err", e.text)
508
+ return
509
+ case "agent": {
510
+ const ev = e.event
511
+ if (ev.kind === "usage") useTokens(`agent-tool-${runIndex.current}`, ev.usage.input + ev.usage.output)
512
+ // Hidden lines (reads, searches, the agent's narration) still show on the activity line below.
513
+ else if (shows(ev, detailRef.current)) {
514
+ if (ev.kind === "info") push("code", ev.text)
515
+ else if (ev.kind === "say") push("thought", ev.text)
516
+ else if (ev.kind === "tool") pushTool(ev.text, ev.ok)
517
+ else if (ev.kind === "test") push(ev.ok ? "ok" : "err", ev.text)
518
+ }
519
+ if (aborted()) return
520
+ const who = codingRef.current ? "Coding agent" : "Agent"
521
+ if (ev.kind === "tool") setActivity(`${who}: ${clip(ev.text, 60)}`)
522
+ else if (ev.kind === "info" && ev.text.startsWith("ForgeQE runs the test")) setActivity("ForgeQE is running the new test…")
523
+ else if (ev.kind === "say") setActivity(`${who} is working…`)
524
+ return
525
+ }
526
+ case "code-start": {
527
+ codingRef.current = true
528
+ codeRunDir.current = e.runDir
529
+ setCoding(true)
530
+ const show = shortPath
531
+ pushLine({
532
+ kind: "card",
533
+ text: "Handing over to the coding agent",
534
+ card: {
535
+ status: "info",
536
+ title: "Handing over to the coding agent",
537
+ stats: "browser agent → coding agent",
538
+ body: [
539
+ ...(e.goal ? [`Flow ${clip(e.goal, 100)}`] : []),
540
+ `Run ${show(e.runDir)}/`,
541
+ `Repo ${show(e.repo)}`,
542
+ "It reads the run's steps, locators and page memory, studies the repo's tests, and writes a test.",
543
+ "ForgeQE then runs that test itself until it passes.",
544
+ ],
545
+ },
546
+ })
547
+ if (!aborted()) setActivity("Coding agent: reading the handoff…")
548
+ return
549
+ }
550
+ case "code-done":
551
+ codingRef.current = false
552
+ setCoding(false)
553
+ producedRef.current.tests.push({ runDir: codeRunDir.current, result: e.result, error: e.error })
554
+ if (e.error) push("err", `Coding agent: ${e.error}`)
555
+ if (agent && !aborted()) setActivity("The agent is reading the result…")
556
+ return
557
+ case "card":
558
+ pushLine({ kind: "card", text: e.card.title, card: e.card })
559
+ return
560
+ }
561
+ }
562
+
563
+ const onBrainEvent = (e: CodeEvent) => {
564
+ if (e.kind === "usage") {
565
+ useTokens("agent", e.usage.input + e.usage.output)
566
+ return
567
+ }
568
+ // ForgeQE's own agent: its tool calls (Azure DevOps and other MCP servers, commands) and what it says between them,
569
+ // one line each in brief; only its file reads and searches are left to /details.
570
+ if (e.kind === "tool" && showsAgentTool(e.text, e.ok, detailRef.current)) pushTool(e.text, e.ok)
571
+ else if (e.kind === "say") push("thought", briefText(e.text, detailRef.current, 200))
572
+ else if (shows(e, detailRef.current)) {
573
+ if (e.kind === "info") push("code", e.text)
574
+ if (e.kind === "tool") pushTool(e.text, e.ok)
575
+ if (e.kind === "test") push(e.ok ? "ok" : "err", e.text)
576
+ }
577
+ if (abortRef.current?.signal.aborted) return
578
+ if (e.kind === "tool") setActivity(`Agent: ${clip(e.text, 60)}`)
579
+ else if (e.kind === "say") setActivity("The agent is working…")
580
+ }
581
+
582
+ const toolContext = (agent: boolean, signal?: AbortSignal) => ({
583
+ headed: headedRef.current,
584
+ maxSteps: stepsRef.current,
585
+ replay: options.replay,
586
+ memory: options.memory,
587
+ url: options.url,
588
+ signal,
589
+ execute: options.runner,
590
+ emit: (e: ToolEvent) => onToolEvent(e, agent),
591
+ session: "shell" as const,
592
+ writeTests: testsRef.current.on,
593
+ waitForUser,
594
+ ask,
595
+ })
596
+
597
+ const begin = (activityText: string) => {
598
+ const controller = new AbortController()
599
+ abortRef.current = controller
600
+ started.current = Date.now()
601
+ usedRef.current = new Map()
602
+ producedRef.current = emptyProduced()
603
+ setTokens(0)
604
+ setStatus("running")
605
+ setActivity(activityText)
606
+ return controller
607
+ }
608
+
609
+ const finish = (finalStatus: string) => {
610
+ setStatus(finalStatus)
611
+ setActivity("")
612
+ setThinking(false)
613
+ setBrowsing(false)
614
+ codingRef.current = false
615
+ setCoding(false)
616
+ setSuiteProgress(null)
617
+ abortRef.current = null
618
+ started.current = null
619
+ setChoice(0)
620
+ setDialog((d) => (d === "stop" ? null : d))
621
+ if (closeAfterRun.current) quit()
622
+ }
623
+
624
+ /** No agent available: the text is the browser goal. */
625
+ const runDirect = async (goal: string, cfg: AppConfig, why: string) => {
626
+ if (!directNoted.current) {
627
+ directNoted.current = true
628
+ push("sys", `ForgeQE's agent is off (${why}), so the text runs as a browser goal.${why.includes("agent runtime") ? ` ${INSTALL_HINT}` : " Press Tab for a live profile."} /features lists what ForgeQE can do.`)
629
+ }
630
+ const controller = begin(cfg.llm.provider === "mock" ? "Running the offline demo…" : "Starting…")
631
+ testsRef.current = { on: false, why: why.includes("agent runtime") ? "the agent runtime Scribe needs is not installed" : "ForgeQE's agent is off in the offline profile" }
632
+ let finalStatus = "fail"
633
+ try {
634
+ const out = (await TOOLS[0].run({ goal }, { ...toolContext(false, controller.signal), config: cfg, cwd: process.cwd() })) as { status: string }
635
+ finalStatus = out.status === "passed" ? "done" : out.status === "stopped" ? "stopped" : "fail"
636
+ } catch {
637
+ finalStatus = controller.signal.aborted ? "stopped" : "fail"
638
+ } finally {
639
+ pushProduced()
640
+ finish(finalStatus)
641
+ }
642
+ }
643
+
644
+ const askAgent = async (text: string, cfg: AppConfig) => {
645
+ const controller = begin("Thinking…")
646
+ const meterStart = meterSnapshot()
647
+ setThinking(true)
648
+ const setup = sessionSetup(cfg, options.repo)
649
+ testsRef.current = { on: setup.writeTests, why: "the session was started with --no-code" }
650
+ let finalStatus = "fail"
651
+ let fresh = false
652
+ try {
653
+ if (!brainRef.current) {
654
+ fresh = true
655
+ setActivity("Starting ForgeQE's agent…")
656
+ const { cwd, notes } = setup
657
+ brainRef.current = await (options.brain || openBrain)({
658
+ config: cfg,
659
+ cwd,
660
+ notes,
661
+ context: () => toolContext(true, abortRef.current?.signal),
662
+ onEvent: onBrainEvent,
663
+ })
664
+ setModel(`${modelLabel(cfg)} · agent ${brainRef.current.model}`)
665
+ }
666
+ const brain = brainRef.current
667
+ const onAbort = () => void brain.abort()
668
+ controller.signal.addEventListener("abort", onAbort, { once: true })
669
+ if (!controller.signal.aborted) setActivity("Thinking…")
670
+ const answer = await brain.ask(fresh ? carryOver(convoRef.current, text) : text)
671
+ controller.signal.removeEventListener("abort", onAbort)
672
+ if (answer.outcome !== "stopped") convoRef.current.push({ user: text, reply: answer.reply })
673
+ if (answer.outcome === "stopped") {
674
+ push("sys", "Stopped.")
675
+ finalStatus = "stopped"
676
+ } else {
677
+ if (answer.reply) pushLine({ kind: "reply", text: answer.reply, outcome: answer.outcome })
678
+ if (answer.error && !answer.reply) push("err", answer.error)
679
+ finalStatus = answer.outcome === "done" ? "done" : answer.outcome === "needs_input" ? "idle" : "fail"
680
+ }
681
+ } catch (err) {
682
+ push("err", (err as Error).message)
683
+ closeBrain()
684
+ } finally {
685
+ pushProduced()
686
+ // What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
687
+ const spent = requestLine(meterStart)
688
+ if (spent) push("sys", spent)
689
+ finish(finalStatus)
690
+ }
691
+ }
692
+
693
+ const handleText = (text: string) => {
694
+ const cfg = configRef.current
695
+ if (!cfg) {
696
+ push("err", "Config failed to load.")
697
+ return
698
+ }
699
+ if (abortRef.current) {
700
+ push("err", "ForgeQE is busy. Press esc to stop it.")
701
+ return
702
+ }
703
+ if (cfg.llm.provider === "none" && !options.brain) {
704
+ push("err", NO_MODEL_SET_UP)
705
+ return
706
+ }
707
+ const why = options.brain ? null : brainUnavailable(cfg)
708
+ if (why) void runDirect(text, cfg, why)
709
+ else void askAgent(text, cfg)
710
+ }
711
+
712
+ useEffect(() => {
713
+ if (booted.current) return
714
+ booted.current = true
715
+ try {
716
+ let cfg = loadConfig({ configPath: options.configPath, profile: options.profile })
717
+ if (options.profile) cfg = selectProfile(cfg, options.profile)
718
+ configRef.current = cfg
719
+ // A price in llms.json that differs from the model's list price (probably a typo or an old price), said with the
720
+ // first message so the home screen stays as it is.
721
+ const reviewer = cfg.code.reviewProfile ? cfg.profiles[cfg.code.reviewProfile] : undefined
722
+ priceNotesRef.current = [...new Set([cfg.llm.priceWarning, reviewer?.priceWarning].filter((w): w is string => Boolean(w)))]
723
+ setProfile(cfg.activeProfile)
724
+ setModel(modelLabel(cfg))
725
+ setMaxSteps(cfg.browser.maxSteps)
726
+ stepsRef.current = cfg.browser.maxSteps
727
+ if (options.goal) {
728
+ push("user", options.goal)
729
+ handleText(options.goal)
730
+ }
731
+ } catch (err) {
732
+ push("err", (err as Error).message)
733
+ }
734
+ return () => closeBrain()
735
+ // Boot once.
736
+ // eslint-disable-next-line react-hooks/exhaustive-deps
737
+ }, [])
738
+
739
+ const onSubmit = (value: string) => {
740
+ const text = value.replace(/\t/g, "").trim()
741
+ setInputKey((k) => k + 1)
742
+ const pending = entryRef.current
743
+ if (pending) {
744
+ const answer = text || pending.initial || ""
745
+ if (!answer) return
746
+ push("user", pending.secret ? "•".repeat(Math.min(12, answer.length)) : answer)
747
+ settleEntry(answer)
748
+ return
749
+ }
750
+ if (!text) return
751
+ push("user", text)
752
+ for (const w of priceNotesRef.current.splice(0)) push("sys", `Price check: ${w}`)
753
+ const cmd = parseCommand(text)
754
+ if (cmd.type === "help") {
755
+ push("sys", HELP)
756
+ return
757
+ }
758
+ if (cmd.type === "exit") {
759
+ if (abortRef.current) openDialog("quit")
760
+ else quit()
761
+ return
762
+ }
763
+ if (cmd.type === "clear") {
764
+ setLines([])
765
+ return
766
+ }
767
+ if (cmd.type === "stop") {
768
+ if (!abortRef.current) push("sys", "Nothing is running.")
769
+ else requestStop()
770
+ return
771
+ }
772
+ if (cmd.type === "headed") {
773
+ setHeaded(cmd.value)
774
+ headedRef.current = cmd.value
775
+ push("sys", `headed ${cmd.value ? "on" : "off"}`)
776
+ return
777
+ }
778
+ if (cmd.type === "details") {
779
+ const full = cmd.value ?? detailRef.current === "brief"
780
+ detailRef.current = full ? "full" : "brief"
781
+ push("sys", full ? "details on: the agents' reasoning, every file they read, and engine logs" : "details off: progress, results, files written, and problems")
782
+ return
783
+ }
784
+ if (cmd.type === "profile") {
785
+ try {
786
+ applyProfile(cmd.name)
787
+ push("sys", `profile ${cmd.name}`)
788
+ } catch (err) {
789
+ push("err", (err as Error).message)
790
+ }
791
+ return
792
+ }
793
+ if (cmd.type === "features") {
794
+ if (configRef.current) push("sys", featuresText(configRef.current))
795
+ else push("err", "Config is not loaded.")
796
+ return
797
+ }
798
+ if (cmd.type === "skills") {
799
+ try {
800
+ const cfg = configRef.current
801
+ if (!cfg) throw new Error("Config is not loaded.")
802
+ const reg = loadSkills(cfg.skills, process.cwd())
803
+ if (!cmd.name) push("sys", skillsOverview(reg, cfg.skills, process.cwd()))
804
+ else {
805
+ const skill = findSkill(reg, cmd.name)
806
+ if (!skill) throw new Error(`No skill named "${cmd.name}". /skills lists them.`)
807
+ push("sys", skillDetail(skill, reg.disabled.includes(skill)))
808
+ }
809
+ } catch (err) {
810
+ push("err", (err as Error).message)
811
+ }
812
+ return
813
+ }
814
+ if (cmd.type === "logs") {
815
+ try {
816
+ if (!configRef.current) throw new Error("Config is not loaded.")
817
+ const out = bundleLogs(configRef.current, { shots: cmd.shots })
818
+ push("sys", [`log ${out.log}`, `runs ${out.runs}`, `archive ${out.archive}`, "Secrets are replaced in every file. Share the archive to get help with this session."].join("\n"))
819
+ } catch (err) {
820
+ push("err", (err as Error).message)
821
+ }
822
+ return
823
+ }
824
+ if (cmd.type === "auth") {
825
+ if (abortRef.current) {
826
+ push("err", "ForgeQE is busy. Press esc to stop it.")
827
+ return
828
+ }
829
+ void runAuth(cmd.action, cmd.system).catch((err) => push("err", (err as Error).message))
830
+ return
831
+ }
832
+ if (cmd.type === "unknown") {
833
+ push("err", "Usage: /headed on|off, /details on|off, /profile <name>, /auth [system], /auth logout <system>. /help lists the commands.")
834
+ return
835
+ }
836
+ handleText(cmd.text)
837
+ }
838
+ submitRef.current = onSubmit
839
+
840
+ void tick
841
+ const running = status === "running"
842
+ const elapsed = started.current != null ? formatDuration(Date.now() - started.current) : ""
843
+ const modeColor = running ? theme.warn : status === "fail" ? theme.err : theme.accent
844
+ const right = running
845
+ ? suiteProgress
846
+ ? `case ${Math.min(suiteProgress.done + 1, suiteProgress.total)}/${suiteProgress.total}`
847
+ : browsing
848
+ ? step > maxSteps
849
+ ? `step ${step} (past ${maxSteps}, still progressing)`
850
+ : `step ${step}/${maxSteps}`
851
+ : coding
852
+ ? "coding agent"
853
+ : "agent"
854
+ : status === "done" || status === "fail" || status === "stopped"
855
+ ? status
856
+ : options.url
857
+ ? hostOf(options.url)
858
+ : headed
859
+ ? "headed"
860
+ : ""
861
+ const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
862
+ const footerRight = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
863
+ const home = lines.length === 0 && !running
864
+
865
+ const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
866
+ const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
867
+ const idleHints: Hint[] = [
868
+ ...copiedHint,
869
+ ...(lastStats ? [{ key: `last run ${lastStats}`, label: "" }] : []),
870
+ { key: "tab", label: "profiles" },
871
+ { key: "ctrl+p", label: "commands" },
872
+ closeHint,
873
+ ]
874
+ const runningHints: Hint[] = [
875
+ ...copiedHint,
876
+ { key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" },
877
+ { key: "ctrl+p", label: "commands" },
878
+ closeHint,
879
+ ]
880
+ const runningLeft = (
881
+ <>
882
+ <Spinner kind="bar" />
883
+ <text onMouseDown={() => openDialog("stop")}>
884
+ <span fg={theme.text}>{" esc"}</span>
885
+ <span fg={theme.muted}>{" stop"}</span>
886
+ </text>
887
+ </>
888
+ )
889
+
890
+ const renderControls = (promptWidth: number, placeholder: string, rows: [number, number]) => {
891
+ if (dialog) {
892
+ const spec = dialogSpec(dialog, running, outDirLabel(), thinking, waitRef.current?.text, askRef.current?.q)
893
+ const wide = spec.options.reduce((n, o) => n + o.label.length + 3, 0) > promptWidth - 36
894
+ return (
895
+ <ChoicePanel
896
+ vertical={spec.options.length > 3 || wide}
897
+ width={promptWidth}
898
+ title={spec.title}
899
+ detail={spec.detail}
900
+ color={spec.color}
901
+ options={spec.options}
902
+ selected={Math.min(choice, spec.options.length - 1)}
903
+ onChoose={choose}
904
+ onHover={setChoice}
905
+ />
906
+ )
907
+ }
908
+ return (
909
+ <>
910
+ <Prompt
911
+ width={promptWidth}
912
+ inputKey={inputKey}
913
+ onSubmit={(value) => submitRef.current(value)}
914
+ minRows={entry ? 1 : rows[0]}
915
+ maxRows={entry ? 3 : rows[1]}
916
+ placeholder={entry ? `${entry.label}${entry.secret ? " (hidden as you type)" : ""}${entry.initial ? ` · Enter keeps ${entry.initial}` : ""} · esc cancels` : placeholder}
917
+ masked={entry?.secret ? secretLength : undefined}
918
+ modeColor={modeColor}
919
+ profile={profile}
920
+ model={model}
921
+ right={right}
922
+ rightColor={rightColor}
923
+ />
924
+ <Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
925
+ </>
926
+ )
927
+ }
928
+
929
+ if (home) {
930
+ const promptWidth = Math.max(40, Math.min(width - 8, 120))
931
+ return (
932
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
933
+ <box style={{ flexDirection: "column", flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
934
+ <Logo />
935
+ <box style={{ height: 2 }} />
936
+ {renderControls(promptWidth, `Describe a goal… "${example}"`, [4, Math.max(4, Math.min(12, height - 22))])}
937
+ <box style={{ height: 3 }} />
938
+ <text>
939
+ <span fg={theme.warn}>{"● "}</span>
940
+ <span fg={theme.text}>{"Tip "}</span>
941
+ <span fg={theme.muted}>{tip}</span>
942
+ </text>
943
+ </box>
944
+ <Footer left={shortCwd()} right={footerRight} width={width} />
945
+ </box>
946
+ )
947
+ }
948
+
949
+ const promptWidth = Math.max(40, width - 4)
950
+ return (
951
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
952
+ <scrollbox
953
+ ref={scrollRef}
954
+ stickyScroll
955
+ stickyStart="bottom"
956
+ style={{ flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}
957
+ focused={false}
958
+ >
959
+ {lines.map((line) => (
960
+ <TranscriptLine key={line.id} line={line} />
961
+ ))}
962
+ {running ? (
963
+ <box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
964
+ <Spinner kind="dots" />
965
+ <text fg={theme.text}>{` ${activity || "Working…"}`}</text>
966
+ </box>
967
+ ) : null}
968
+ </scrollbox>
969
+ <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
970
+ {renderControls(
971
+ promptWidth,
972
+ running
973
+ ? coding
974
+ ? "The coding agent is writing a test · esc to stop"
975
+ : `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
976
+ : "Ask ForgeQE: a goal, test cases, a test, a pipeline…",
977
+ [3, Math.max(3, Math.min(10, Math.floor(height / 3)))],
978
+ )}
979
+ </box>
980
+ <Footer left={shortCwd()} right={footerRight} width={width} />
981
+ </box>
982
+ )
983
+ }