@capagents/forgeqe 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,7 +21,7 @@ export function Logo() {
21
21
  const name = logoRows("forge")
22
22
  const suffix = logoRows("QE")
23
23
  return (
24
- <box style={{ flexDirection: "column", alignItems: "flex-end" }}>
24
+ <box style={{ flexDirection: "column", alignItems: "flex-end", flexShrink: 0 }}>
25
25
  {name.map((row, i) => (
26
26
  <text key={i}>
27
27
  <span fg={theme.faint}>{row}</span>
@@ -56,6 +56,32 @@ export type PromptProps = {
56
56
  focused?: boolean
57
57
  /** Called with the text as it changes, for actions on what is being typed (Refine). */
58
58
  onText?: (text: string) => void
59
+ /** Called with the rows the text needs, so the screen around the box can make room for a long request. */
60
+ onLines?: (lines: number) => void
61
+ }
62
+
63
+ /** The rows `text` takes when word-wrapped at `width` columns, as the prompt wraps it. */
64
+ export function wrappedLines(text: string, width: number): number {
65
+ if (width < 1) return 1
66
+ let rows = 0
67
+ for (const line of text.split("\n")) {
68
+ let used = 0
69
+ let count = 1
70
+ for (const word of line.split(/(\s+)/)) {
71
+ if (!word) continue
72
+ if (used + word.length <= width) used += word.length
73
+ else if (/^\s+$/.test(word)) used = width
74
+ else if (word.length > width) {
75
+ count += Math.ceil((used + word.length) / width) - 1
76
+ used = (used + word.length) % width
77
+ } else {
78
+ count++
79
+ used = word.length
80
+ }
81
+ }
82
+ rows += count
83
+ }
84
+ return Math.max(1, rows)
59
85
  }
60
86
 
61
87
  /** Enter sends; Shift+Enter, Alt+Enter or Ctrl+J start a new line. */
@@ -70,75 +96,82 @@ const PROMPT_KEYS: KeyBinding[] = [
70
96
  export function Prompt(props: PromptProps) {
71
97
  const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
72
98
  const ref = useRef<TextareaRenderable>(null)
73
- const [rows, setRows] = useState(props.minRows)
74
- useEffect(() => setRows(props.minRows), [props.inputKey, props.minRows])
99
+ // The rows the text needs; the box shows that many between minRows and maxRows, so it also grows when the screen
100
+ // around it makes more room (the start screen folds a section for a long request).
101
+ const [lines, setLines] = useState(1)
102
+ useEffect(() => setLines(1), [props.inputKey])
103
+ const rows = Math.min(props.maxRows, Math.max(props.minRows, lines))
104
+ // The text scrolled while the box was smaller: once it all fits, show it from the top.
105
+ useEffect(() => {
106
+ const t = ref.current
107
+ if (!t || lines > rows || t.scrollY === 0) return
108
+ // Going to the top scrolls the view there; going back to where the cursor was keeps it there, now that all fits.
109
+ const at = t.cursorOffset
110
+ t.gotoBufferHome()
111
+ t.cursorOffset = at
112
+ }, [rows, lines])
75
113
  // Inserted text (a completion, an example, a refined request) is continued from its end.
76
114
  useEffect(() => {
77
115
  if (props.initialValue) ref.current?.gotoBufferEnd()
78
116
  }, [props.inputKey])
79
117
  const fit = () => {
80
118
  props.onText?.(ref.current?.plainText ?? "")
81
- const lines = ref.current?.virtualLineCount ?? 1
82
- setRows(Math.min(props.maxRows, Math.max(props.minRows, lines)))
119
+ // virtualLineCount counts only the lines in view, so a box could never ask to grow: count the wrapped lines.
120
+ const t = ref.current
121
+ const needed = t ? wrappedLines(t.plainText, t.width || props.width - 5) : 1
122
+ props.onLines?.(needed)
123
+ setLines(needed)
83
124
  }
125
+ // A rounded box with ">" before the text, as in Claude Code; the border lights up while it has the keys.
84
126
  return (
85
- <box style={{ flexDirection: "column", width: props.width, flexShrink: 0 }}>
86
- <box
87
- border={["left"]}
88
- borderColor={props.modeColor}
89
- customBorderChars={BAR}
90
- style={{
91
- flexDirection: "column",
92
- backgroundColor: theme.panel,
93
- paddingLeft: 2,
94
- paddingRight: 2,
95
- paddingTop: 1,
96
- paddingBottom: 1,
97
- width: props.width,
98
- }}
99
- >
100
- {props.masked !== undefined ? (
101
- <text style={{ height: 1 }}>
102
- {props.masked ? <span fg={theme.text}>{"•".repeat(Math.min(props.masked, Math.max(1, props.width - 8)))}</span> : <span fg={theme.faint}>{props.placeholder}</span>}
103
- </text>
104
- ) : (
105
- <textarea
106
- key={props.inputKey}
107
- ref={ref}
108
- placeholder={props.placeholder}
109
- focused={props.focused !== false}
110
- initialValue={props.initialValue || ""}
111
- wrapMode="word"
112
- keyBindings={PROMPT_KEYS}
113
- onSubmit={() => props.onSubmit(ref.current?.plainText ?? "")}
114
- onContentChange={fit}
115
- style={{ height: rows }}
116
- backgroundColor={theme.panel}
117
- focusedBackgroundColor={theme.panel}
118
- textColor={theme.text}
119
- focusedTextColor={theme.text}
120
- placeholderColor={theme.faint}
121
- />
122
- )}
123
- {props.compact ? null : (
124
- <box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
125
- <text>
126
- <span fg={theme.muted}>{"Model "}</span>
127
- <span fg={theme.text}>{props.model || "…"}</span>
128
- </text>
129
- <text>
130
- {props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
131
- {props.right ? <span fg={theme.faint}>{" · "}</span> : null}
132
- <span fg={theme.muted}>{"profile "}</span>
133
- <span fg={theme.text}>{props.profile || "none"}</span>
127
+ <box
128
+ border
129
+ borderStyle="rounded"
130
+ borderColor={props.focused === false ? theme.border : theme.muted}
131
+ style={{ flexDirection: "column", width: props.width, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}
132
+ >
133
+ <box style={{ flexDirection: "row" }}>
134
+ <text fg={props.modeColor} style={{ width: 2, flexShrink: 0 }}>
135
+ {">"}
136
+ </text>
137
+ <box style={{ flexDirection: "column", flexGrow: 1, flexShrink: 1 }}>
138
+ {props.masked !== undefined ? (
139
+ <text style={{ height: 1 }}>
140
+ {props.masked ? <span fg={theme.text}>{"•".repeat(Math.min(props.masked, Math.max(1, props.width - 8)))}</span> : <span fg={theme.faint}>{props.placeholder}</span>}
134
141
  </text>
135
- </box>
136
- )}
142
+ ) : (
143
+ <textarea
144
+ key={props.inputKey}
145
+ ref={ref}
146
+ placeholder={props.placeholder}
147
+ focused={props.focused !== false}
148
+ initialValue={props.initialValue || ""}
149
+ wrapMode="word"
150
+ keyBindings={PROMPT_KEYS}
151
+ onSubmit={() => props.onSubmit(ref.current?.plainText ?? "")}
152
+ onContentChange={fit}
153
+ style={{ height: rows }}
154
+ textColor={theme.text}
155
+ focusedTextColor={theme.text}
156
+ placeholderColor={theme.faint}
157
+ />
158
+ )}
159
+ </box>
137
160
  </box>
138
- <text>
139
- <span fg={props.modeColor}>{edge.slice(0, 1)}</span>
140
- <span fg={theme.panel}>{edge.slice(1)}</span>
141
- </text>
161
+ {props.compact ? null : (
162
+ <box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
163
+ <text>
164
+ <span fg={theme.muted}>{"Model "}</span>
165
+ <span fg={theme.text}>{props.model || "…"}</span>
166
+ </text>
167
+ <text>
168
+ {props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
169
+ {props.right ? <span fg={theme.faint}>{" · "}</span> : null}
170
+ <span fg={theme.muted}>{"profile "}</span>
171
+ <span fg={theme.text}>{props.profile || "none"}</span>
172
+ </text>
173
+ </box>
174
+ )}
142
175
  </box>
143
176
  )
144
177
  }
@@ -328,9 +361,10 @@ export function ChoicePanel(props: ChoicePanelProps) {
328
361
  )
329
362
  }
330
363
 
331
- export function Footer({ left, right, width }: { left: string; right: string; width: number }) {
364
+ /** `cut`: which end of `left` gives way when it is too long: the start of a path, the end of a sentence (the tip). */
365
+ export function Footer({ left, right, width, cut = "start" }: { left: string; right: string; width: number; cut?: "start" | "end" }) {
332
366
  const room = Math.max(8, width - right.length - 8)
333
- const shown = left.length <= room ? left : `…${left.slice(left.length - room + 1)}`
367
+ const shown = left.length <= room ? left : cut === "end" ? `${left.slice(0, room - 1)}…` : `…${left.slice(left.length - room + 1)}`
334
368
  return (
335
369
  <box
336
370
  style={{ flexDirection: "row", justifyContent: "space-between", paddingLeft: 2, paddingRight: 2, height: 1, flexShrink: 0 }}
@@ -0,0 +1,201 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { AppConfig } from "../config.ts"
5
+ import { CAPABILITIES } from "../capabilities/registry.ts"
6
+ import { allStatuses, configuredSystems, SYSTEM_LABEL, type Probes, MACHINE } from "../capabilities/status.ts"
7
+
8
+ /**
9
+ * What the start screen shows before the first request: the project it works on, the actions that work in this setup,
10
+ * and the reports and runs to pick up again. Everything comes from the config, the capability registry, and the files
11
+ * in the folder, so it changes as the setup changes.
12
+ */
13
+
14
+ /** Files a stack is read from, at the top and one or two folders down (tests/Shop.Tests/Shop.Tests.csproj). */
15
+ function projectFiles(cwd: string, match: RegExp, depth = 2): string[] {
16
+ const out: string[] = []
17
+ const walk = (dir: string, level: number) => {
18
+ let names: string[] = []
19
+ try {
20
+ names = readdirSync(dir)
21
+ } catch {
22
+ return
23
+ }
24
+ for (const n of names) {
25
+ if (n.startsWith(".") || n === "node_modules" || n === "bin" || n === "obj") continue
26
+ const full = path.join(dir, n)
27
+ if (match.test(n)) out.push(full)
28
+ else if (level < depth) {
29
+ try {
30
+ if (statSync(full).isDirectory()) walk(full, level + 1)
31
+ } catch {}
32
+ }
33
+ if (out.length > 20) return
34
+ }
35
+ }
36
+ walk(cwd, 0)
37
+ return out
38
+ }
39
+
40
+ const read = (file: string) => {
41
+ try {
42
+ return readFileSync(file, "utf8")
43
+ } catch {
44
+ return ""
45
+ }
46
+ }
47
+
48
+ /**
49
+ * "C# · NUnit · Playwright": the language, test framework, and browser library the project's own files declare
50
+ * (project files, package.json, Python requirements). Read from the files, never guessed from folder names.
51
+ */
52
+ export function projectStack(cwd: string): string[] {
53
+ const csproj = projectFiles(cwd, /\.csproj$/).map(read).join("\n")
54
+ if (csproj) {
55
+ const framework = /PackageReference Include="NUnit"/i.test(csproj) ? "NUnit" : /PackageReference Include="xunit/i.test(csproj) ? "xUnit" : /PackageReference Include="MSTest/i.test(csproj) ? "MSTest" : ""
56
+ const browser = /Microsoft\.Playwright/i.test(csproj) ? "Playwright" : /Selenium\.WebDriver/i.test(csproj) ? "Selenium" : /Reqnroll|SpecFlow/i.test(csproj) ? "BDD" : ""
57
+ return ["C#", framework, browser].filter(Boolean)
58
+ }
59
+ const pkg = read(path.join(cwd, "package.json"))
60
+ if (pkg) {
61
+ const lang = existsSync(path.join(cwd, "tsconfig.json")) ? "TypeScript" : "JavaScript"
62
+ const runner = /"@playwright\/test"/.test(pkg) ? "Playwright Test" : /"cypress"/.test(pkg) ? "Cypress" : /"webdriverio"|"@wdio\//.test(pkg) ? "WebdriverIO" : /"vitest"/.test(pkg) ? "Vitest" : /"jest"/.test(pkg) ? "Jest" : ""
63
+ return [lang, runner].filter(Boolean)
64
+ }
65
+ const py = ["requirements.txt", "pyproject.toml", "setup.cfg"].map((f) => read(path.join(cwd, f))).join("\n")
66
+ if (py.trim() || existsSync(path.join(cwd, "pytest.ini")) || existsSync(path.join(cwd, "conftest.py"))) {
67
+ return ["Python", /pytest/i.test(py) || existsSync(path.join(cwd, "pytest.ini")) ? "pytest" : "", /playwright/i.test(py) ? "Playwright" : /selenium/i.test(py) ? "Selenium" : ""].filter(Boolean)
68
+ }
69
+ const java = [read(path.join(cwd, "pom.xml")), read(path.join(cwd, "build.gradle")), read(path.join(cwd, "build.gradle.kts"))].join("\n")
70
+ if (java.trim()) return ["Java", /junit-jupiter|junit/i.test(java) ? "JUnit" : /testng/i.test(java) ? "TestNG" : "", /playwright/i.test(java) ? "Playwright" : /selenium/i.test(java) ? "Selenium" : ""].filter(Boolean)
71
+ return []
72
+ }
73
+
74
+ export type Fact = { text: string; tone: "name" | "plain" | "warn" }
75
+
76
+ /**
77
+ * The project line: the folder, its stack, each system ForgeQE works with here and how it is signed in ("Azure DevOps
78
+ * (PAT)"), and the model; or, where nothing is set up, that this folder has no forgeqe.yaml.
79
+ */
80
+ export function projectLine(cfg: AppConfig | null, cwd: string, opts: { model: string; signIns?: Record<string, string> }): Fact[] {
81
+ const facts: Fact[] = [{ text: path.basename(cwd), tone: "name" }, ...projectStack(cwd).map((t) => ({ text: t, tone: "plain" as const }))]
82
+ if (!cfg) return facts
83
+ if (!cfg.configPath) return [...facts, { text: "no forgeqe.yaml here", tone: "warn" }]
84
+ const systems = [...configuredSystems(cfg)].filter((s) => s !== "xray" || !configuredSystems(cfg).has("jira"))
85
+ for (const s of systems) {
86
+ const how = opts.signIns?.[SYSTEM_LABEL[s]]
87
+ facts.push({ text: how ? `${SYSTEM_LABEL[s]} (${how})` : SYSTEM_LABEL[s], tone: "plain" })
88
+ }
89
+ const live = cfg.llm.provider !== "none" && cfg.llm.provider !== "mock"
90
+ facts.push(live && opts.model ? { text: opts.model, tone: "plain" } : { text: "no model", tone: "warn" })
91
+ return facts
92
+ }
93
+
94
+ const ALL_SYSTEMS = Object.values(SYSTEM_LABEL)
95
+
96
+ export type StartAction = { id: string; label: string; detail: string; request: string }
97
+
98
+ /**
99
+ * The first things worth asking, in this order, among the capabilities that work in this setup: work from the systems
100
+ * first, then what a team asks for most. Each puts its example request in the prompt to adjust.
101
+ */
102
+ const START_ORDER = ["run-test-cases", "release-readiness", "reports", "exploratory-testing", "write-test-cases", "pr-review", "api-testing", "find-run-tests", "browser-goals"]
103
+
104
+ const START_LABEL: Record<string, string> = {
105
+ "run-test-cases": "Run test cases",
106
+ "release-readiness": "Check release readiness",
107
+ reports: "Write a report to share",
108
+ "exploratory-testing": "Explore a story for bugs",
109
+ "write-test-cases": "Write test cases from a story",
110
+ "pr-review": "Review a pull request",
111
+ "api-testing": "Test an API",
112
+ "find-run-tests": "Run the repository's tests",
113
+ "browser-goals": "Try a flow in the browser",
114
+ }
115
+
116
+ export function startActions(cfg: AppConfig | null, limit = 4, probes: Probes = MACHINE, hasRuns = true): StartAction[] {
117
+ // Without a live model nothing on the list can run: the start screen offers Get started instead.
118
+ if (!cfg || cfg.llm.provider === "none" || cfg.llm.provider === "mock") return []
119
+ const statuses = allStatuses(cfg, probes)
120
+ const systems = [...configuredSystems(cfg)].map((x) => SYSTEM_LABEL[x])
121
+ const out: StartAction[] = []
122
+ for (const id of START_ORDER) {
123
+ const c = CAPABILITIES.find((x) => x.id === id)
124
+ if (!c || statuses.get(id)?.state === "needs_setup") continue
125
+ // An example in the words of this setup: one that names a system that is set up, else one that names none.
126
+ // Not an example that builds on a run when there is none to build on.
127
+ const usable = c.examples.filter((e) => hasRuns || !/\blast run\b|\bthe run\b|that run/i.test(e))
128
+ if (!usable.length) continue
129
+ const named = usable.find((e) => systems.some((n) => e.includes(n)))
130
+ const neutral = usable.find((e) => !ALL_SYSTEMS.some((n) => e.includes(n)))
131
+ const example = named || neutral || usable[0]!
132
+ out.push({ id, label: START_LABEL[id] || c.title, detail: example, request: example })
133
+ if (out.length === limit) break
134
+ }
135
+ return out
136
+ }
137
+
138
+ export type RecentItem = { kind: "report" | "run"; title: string; dir: string; open: string; when: Date; status?: string }
139
+
140
+ /** The newest reports and runs in the reports folder: what a returning user picks up again. */
141
+ export function recentWork(outDir: string, limit = 3): RecentItem[] {
142
+ if (!existsSync(outDir)) return []
143
+ const items: RecentItem[] = []
144
+ for (const name of readdirSync(outDir)) {
145
+ const dir = path.join(outDir, name)
146
+ try {
147
+ if (!statSync(dir).isDirectory()) continue
148
+ const when = statSync(dir).mtime
149
+ if (existsSync(path.join(dir, "run.json"))) {
150
+ // The engine writes success; ForgeQE's own records write status. Either says how the run ended.
151
+ const run = JSON.parse(readFileSync(path.join(dir, "run.json"), "utf8")) as { goal?: string; status?: string; success?: boolean }
152
+ const report = path.join(dir, "report.html")
153
+ const status = run.status || (run.success === true ? "passed" : run.success === false ? "fail" : undefined)
154
+ items.push({ kind: "run", title: String(run.goal || name), dir, open: existsSync(report) ? report : dir, when, ...(status ? { status } : {}) })
155
+ } else if (existsSync(path.join(dir, "report.md"))) {
156
+ const first = readFileSync(path.join(dir, "report.md"), "utf8").split("\n")[0] || ""
157
+ const html = path.join(dir, "report.html")
158
+ items.push({ kind: "report", title: first.replace(/^#\s*/, "").trim() || name, dir, open: existsSync(html) ? html : path.join(dir, "report.md"), when })
159
+ }
160
+ } catch {}
161
+ }
162
+ return items.sort((a, b) => b.when.getTime() - a.when.getTime()).slice(0, limit)
163
+ }
164
+
165
+ /** "just now", "12 min ago", "3 h ago", "yesterday", "4 days ago", then the date. */
166
+ export function ago(when: Date, now = new Date()): string {
167
+ const s = Math.max(0, (now.getTime() - when.getTime()) / 1000)
168
+ if (s < 60) return "just now"
169
+ if (s < 3600) return `${Math.floor(s / 60)} min ago`
170
+ if (s < 86400) return `${Math.floor(s / 3600)} h ago`
171
+ if (s < 2 * 86400) return "yesterday"
172
+ if (s < 7 * 86400) return `${Math.floor(s / 86400)} days ago`
173
+ return when.toISOString().slice(0, 10)
174
+ }
175
+
176
+ /** "Domain", "Domain and Notifications", "Model, Domain, and Notifications". */
177
+ export function listWords(words: string[]): string {
178
+ if (words.length < 3) return words.join(" and ")
179
+ return `${words.slice(0, -1).join(", ")}, and ${words.at(-1)}`
180
+ }
181
+
182
+ /** "Domain and Notifications are not set up", or, when the names do not fit in `room`, "5 items are not set up". */
183
+ export function setupSentence(missing: string[], room: number): string {
184
+ const named = `${listWords(missing)} ${missing.length === 1 ? "is" : "are"} not set up`
185
+ return named.length <= room ? named : `${missing.length} ${missing.length === 1 ? "item is" : "items are"} not set up`
186
+ }
187
+
188
+ /** What to do first where little is set up: each step runs a command or says how, in the order they are needed. */
189
+ export type GetStarted = { id: string; label: string; detail: string; command?: string; fix?: string }
190
+
191
+ export function getStarted(cfg: AppConfig | null, setup: { id: string; done: boolean; fix?: string; command?: string }[]): GetStarted[] {
192
+ if (!cfg) return []
193
+ const out: GetStarted[] = []
194
+ if (!cfg.configPath) out.push({ id: "init", label: "Set up ForgeQE in this folder", detail: "writes forgeqe.yaml, llms.json, prompts, skills", command: "/init" })
195
+ const missing = (id: string) => setup.find((i) => i.id === id && !i.done)
196
+ const model = missing("model")
197
+ if (model) out.push({ id: "model", label: "Add a model", detail: "an API key or Microsoft Entra ID, in llms.json", fix: model.fix })
198
+ const system = missing("system")
199
+ if (system) out.push({ id: "system", label: "Sign in to Azure DevOps or Jira", detail: "test cases, stories, pipelines", command: "/auth" })
200
+ return out.slice(0, 4)
201
+ }
package/src/tui/next.ts CHANGED
@@ -26,7 +26,7 @@ export function nextSteps(p: Produced, opts: { testsOn: boolean; followUps?: Fol
26
26
  steps.push({ label: "Rerun the cases that failed", run: { kind: "insert", text: "Run the cases that failed in the last suite again" } })
27
27
  } else if (run) {
28
28
  const report = path.join(run.runDir, "report.html")
29
- if (run.status === "complete" && !p.tests.some((t) => t.runDir === run.runDir)) steps.push({ label: "Write a test for this run", run: { kind: "insert", text: "Write a test for the last run" } })
29
+ if (opts.testsOn && run.status === "complete" && !p.tests.some((t) => t.runDir === run.runDir)) steps.push({ label: "Write a test for this run", run: { kind: "insert", text: "Write a test for the last run" } })
30
30
  if (run.status !== "complete") {
31
31
  steps.push({ label: "Why did it fail?", run: { kind: "insert", text: "Why did the last run fail, and what should change?" } })
32
32
  steps.push({ label: "Run it again, visible", run: { kind: "insert", text: "Run the last goal again in a visible browser" } })
@@ -15,7 +15,7 @@ export type PaletteRun =
15
15
  /** Put in the prompt to edit and send: an example request. */
16
16
  | { kind: "insert"; text: string }
17
17
  /** Something the shell does itself. */
18
- | { kind: "action"; id: "refine" | "stop" | "details" | "chat" | "wide" | "panels" }
18
+ | { kind: "action"; id: "refine" | "stop" | "details" | "chat" | "wide" | "panels" | "shortcuts" }
19
19
 
20
20
  export type PaletteItem = { id: string; group: PaletteGroup; title: string; detail?: string; keys?: string; run: PaletteRun }
21
21
 
@@ -31,8 +31,10 @@ export function paletteItems(opts: { running: boolean; headed: boolean; detailsF
31
31
  if (!opts.chatWide) items.push({ id: "panels", group: "Action", title: "Move to the panels: plan, activity, files", keys: "shift+tab", run: { kind: "action", id: "panels" } })
32
32
  }
33
33
  if (opts.narrow) items.push({ id: "view", group: "Action", title: opts.view === "chat" ? "Show the details view" : "Back to the chat", keys: "ctrl+o", run: { kind: "action", id: opts.view === "chat" ? "details" : "chat" } })
34
+ items.push({ id: "shortcuts", group: "Action", title: "Keyboard shortcuts", keys: "?", run: { kind: "action", id: "shortcuts" } })
34
35
  command("features", "What ForgeQE can do here", "/features", "and what each feature still needs")
35
36
  command("auth", "Sign in to a system", "/auth", "Azure DevOps, GitHub, Jira, Confluence, TestRail, Zephyr, qTest, Xray")
37
+ command("init", "Set up ForgeQE in this folder", "/init", "forgeqe.yaml, llms.json, prompts, skills, knowledge")
36
38
  command("auth-status", "What is signed in", "/auth status")
37
39
  command("headed", opts.headed ? "Hide the browser window" : "Show the browser window", `/headed ${opts.headed ? "off" : "on"}`)
38
40
  command("details", opts.detailsFull ? "Show progress only" : "Show everything the agents do", `/details ${opts.detailsFull ? "off" : "on"}`)
@@ -18,9 +18,21 @@ export type RefineChoice = (typeof REFINE_CHOICES)[number]["id"]
18
18
 
19
19
  const COLOR = theme.memory
20
20
 
21
+ /** A fact as written in context and in grounded, compared without case, spaces, or a closing full stop. */
22
+ const norm = (s: string) => s.trim().replace(/\.$/, "").replace(/\s+/g, " ").toLowerCase()
23
+
24
+ /** The choice the panel starts on: Edit while Refine still has a question, so an unclear request is not sent. */
25
+ export function initialChoice(missing: string[]): number {
26
+ return REFINE_CHOICES.findIndex((c) => c.id === (missing.length ? "edit" : "use"))
27
+ }
28
+
21
29
  export function RefinePanel(props: { state: RefineState; width: number; onChoose: (id: RefineChoice) => void; onHover: (i: number) => void }) {
22
30
  const { state, width } = props
23
31
  const title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : " ✦ Refined request "
32
+ // Facts Refine checked that are also context lines are marked there, not repeated under Checked.
33
+ const contextLines = state.phase === "done" ? state.result.refined.split("\n").filter((l) => l.startsWith("- ")).map((l) => norm(l.slice(2))) : []
34
+ const inContext = new Set(contextLines)
35
+ const checked = new Set(state.phase === "done" ? state.result.grounded.filter((g) => g.checked).map((g) => norm(g.fact)).filter((f) => inContext.has(f)) : [])
24
36
  const section = (label: string, color: string, mark: string, items: string[]) =>
25
37
  items.length ? (
26
38
  <box style={{ flexDirection: "column", marginTop: 1 }}>
@@ -52,6 +64,15 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
52
64
  {/* The request as it is sent: its labelled parts (Goal, Context, Steps, Rules, Result) stand out. */}
53
65
  {state.result.refined.split("\n").map((line, i) => {
54
66
  const label = /^(Goal|Context|Steps|Rules|Result):/.exec(line)
67
+ // A context line Refine checked against the systems: ✓ in place of its dash, so it is not listed twice.
68
+ if (line.startsWith("- ") && checked.has(norm(line.slice(2)))) {
69
+ return (
70
+ <text key={i}>
71
+ <span fg={theme.ok}>{"✓ "}</span>
72
+ <span fg={theme.text}>{line.slice(2)}</span>
73
+ </text>
74
+ )
75
+ }
55
76
  return label ? (
56
77
  <text key={i}>
57
78
  <span fg={COLOR}>{label[0]}</span>
@@ -63,7 +84,7 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
63
84
  </text>
64
85
  )
65
86
  })}
66
- {section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked).map((g) => g.fact))}
87
+ {section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked && !inContext.has(norm(g.fact))).map((g) => g.fact))}
67
88
  {section("Assumed", theme.warn, "~", [...state.result.grounded.filter((g) => !g.checked).map((g) => g.fact), ...state.result.assumed])}
68
89
  {section("Missing — answer these in your request", theme.err, "?", state.result.missing)}
69
90
  </>
@@ -88,7 +109,7 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
88
109
  {state.phase === "done" ? (
89
110
  <>
90
111
  <span fg={theme.text}>{"enter"}</span>
91
- <span fg={theme.muted}>{" use "}</span>
112
+ <span fg={theme.muted}>{` ${REFINE_CHOICES[state.choice]!.id === "keep" ? "keep mine" : REFINE_CHOICES[state.choice]!.id} `}</span>
92
113
  <span fg={theme.text}>{"e"}</span>
93
114
  <span fg={theme.muted}>{" edit "}</span>
94
115
  <span fg={theme.text}>{"esc"}</span>
@@ -0,0 +1,66 @@
1
+ import { theme } from "./theme.ts"
2
+
3
+ /** Every key the shell answers to, in one panel (? on an empty prompt); esc or ? closes it. */
4
+ export const SHORTCUTS: { group: string; keys: [string, string][] }[] = [
5
+ {
6
+ group: "Prompt",
7
+ keys: [
8
+ ["enter", "send the request"],
9
+ ["shift+enter, ctrl+j", "new line"],
10
+ ["ctrl+r", "refine the request before it is sent"],
11
+ ["/ @", "commands; runs, files, and work items to reference"],
12
+ ["tab", "complete a suggestion, or the next model profile"],
13
+ ],
14
+ },
15
+ {
16
+ group: "Find and move",
17
+ keys: [
18
+ ["ctrl+p", "search: actions, commands, profiles, examples"],
19
+ ["shift+tab", "move between the chat and the plan, activity, and files panels"],
20
+ ["ctrl+o", "open or close the panels: plan, activity, files, context, tokens"],
21
+ ["alt+1 to alt+4", "a start action, or a next step"],
22
+ ],
23
+ },
24
+ {
25
+ group: "Run",
26
+ keys: [
27
+ ["esc", "stop the request (asks first)"],
28
+ ["ctrl+c", "close ForgeQE (asks first; twice closes)"],
29
+ ["drag", "copy the selected text"],
30
+ ],
31
+ },
32
+ ]
33
+
34
+ export function ShortcutsPanel(props: { width: number; wide: boolean; onClose: () => void }) {
35
+ const boxWidth = Math.min(84, props.width - 8)
36
+ const keyWidth = 22
37
+ return (
38
+ <box
39
+ position="absolute"
40
+ zIndex={100}
41
+ border
42
+ borderStyle="rounded"
43
+ borderColor={theme.accent}
44
+ title=" Keyboard shortcuts "
45
+ titleColor={theme.accent}
46
+ bottomTitle=" esc close "
47
+ bottomTitleAlignment="right"
48
+ onMouseDown={props.onClose}
49
+ style={{ top: 2, left: Math.floor((props.width - boxWidth) / 2), width: boxWidth, flexDirection: "column", backgroundColor: theme.panel, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}
50
+ >
51
+ {SHORTCUTS.map((g) => (
52
+ <box key={g.group} style={{ flexDirection: "column", marginBottom: 1, flexShrink: 0 }}>
53
+ <text fg={theme.muted}>{g.group}</text>
54
+ {g.keys
55
+ .filter(([k]) => props.wide || k !== "shift+tab")
56
+ .map(([k, what]) => (
57
+ <text key={k}>
58
+ <span fg={theme.text}>{` ${k.padEnd(keyWidth)}`}</span>
59
+ <span fg={theme.muted}>{what}</span>
60
+ </text>
61
+ ))}
62
+ </box>
63
+ ))}
64
+ </box>
65
+ )
66
+ }