@capagents/forgeqe 1.0.5 → 1.0.8
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 +42 -2
- package/docs/user-guide.html +45 -3
- package/package.json +1 -1
- package/prompts/README.md +6 -1
- package/prompts/api-scenarios.md +21 -10
- package/prompts/api-tests.md +55 -25
- package/prompts/brain.md +296 -135
- package/prompts/browser-capture.md +8 -1
- package/prompts/browser-case-report.md +12 -1
- package/prompts/browser-checks.md +22 -8
- package/prompts/browser-efficiency.md +1 -1
- package/prompts/browser-error-recovery.md +18 -6
- package/prompts/browser-goal-verification.md +9 -8
- package/prompts/browser-skill-select.md +1 -1
- package/prompts/domain-rule.md +16 -8
- package/prompts/eval-judge.md +15 -8
- package/prompts/explore-charters.md +19 -15
- package/prompts/find-tests.md +26 -16
- package/prompts/heal.md +32 -24
- package/prompts/pr-review.md +21 -13
- package/prompts/refine.md +30 -10
- package/prompts/skill-rule.md +1 -1
- package/prompts/test-agent.md +43 -18
- package/prompts/test-reviewer.md +37 -15
- package/prompts/test-rules.md +55 -19
- package/src/brain/index.ts +4 -3
- package/src/brain/prompt.ts +34 -2
- package/src/capabilities/registry.ts +12 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +21 -3
- package/src/mcp/reporttools.ts +101 -0
- package/src/mcp/tools.ts +4 -0
- package/src/refine.ts +53 -7
- package/src/ste.ts +184 -0
- package/src/tui/App.tsx +60 -41
- package/src/tui/components.tsx +49 -0
- package/src/tui/inspector.tsx +40 -6
- package/src/tui/next.ts +12 -1
- package/src/tui/palette.tsx +6 -2
- package/src/tui/refine-panel.tsx +14 -1
- package/src/tui/transcript.tsx +78 -2
package/src/refine.ts
CHANGED
|
@@ -6,6 +6,9 @@ import { privateEnvNames } from "./childEnv.ts"
|
|
|
6
6
|
import { MCP_NAME, TOOL_TIMEOUT_MS, localToolServer } from "./mcp/server.ts"
|
|
7
7
|
import { TOOLS, environmentNotes, type ToolContext } from "./mcp/tools.ts"
|
|
8
8
|
import { prompt } from "./prompts.ts"
|
|
9
|
+
import { CAPABILITIES } from "./capabilities/registry.ts"
|
|
10
|
+
import { allStatuses } from "./capabilities/status.ts"
|
|
11
|
+
import { domainNotes } from "./domain.ts"
|
|
9
12
|
import type { CodeEvent } from "./types.ts"
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -17,17 +20,28 @@ import type { CodeEvent } from "./types.ts"
|
|
|
17
20
|
/** The only tools the refiner may call: each reads, none changes anything or starts a run. */
|
|
18
21
|
export const REFINE_TOOLS = ["capabilities", "query_api", "find_test_cases", "list_sprints", "run_details"]
|
|
19
22
|
|
|
23
|
+
/** The refined request as a task specification: what ForgeQE's agent is asked to do, in the parts its own prompt uses. */
|
|
24
|
+
export type RefinedSpec = { goal: string; context: string[]; steps: string[]; rules: string[]; result: string }
|
|
25
|
+
|
|
20
26
|
export type Refined = {
|
|
27
|
+
/** The request that is sent: the spec written out (Goal, Context, Steps, Rules, Result). */
|
|
21
28
|
refined: string
|
|
29
|
+
spec?: RefinedSpec
|
|
22
30
|
grounded: { fact: string; checked: boolean }[]
|
|
23
31
|
assumed: string[]
|
|
24
32
|
missing: string[]
|
|
25
33
|
}
|
|
26
34
|
|
|
35
|
+
const lines = { type: "array", items: { type: "string" } } as const
|
|
36
|
+
|
|
27
37
|
export const REFINE_SCHEMA = {
|
|
28
38
|
type: "object",
|
|
29
39
|
properties: {
|
|
30
|
-
|
|
40
|
+
goal: { type: "string", description: "One instruction that starts with a verb: what the agent must do" },
|
|
41
|
+
context: { ...lines, description: "Facts the agent needs, one per line: system, project, work items, files, runs, with exact names and ids" },
|
|
42
|
+
steps: { ...lines, description: "The actions in sequence, one per step; empty for a request with one action" },
|
|
43
|
+
rules: { ...lines, description: "Limits the user gave or the setup sets, one per line" },
|
|
44
|
+
result: { type: "string", description: "What the agent must give back" },
|
|
31
45
|
grounded: {
|
|
32
46
|
type: "array",
|
|
33
47
|
items: { type: "object", properties: { fact: { type: "string" }, checked: { type: "boolean" } }, required: ["fact", "checked"] },
|
|
@@ -35,7 +49,7 @@ export const REFINE_SCHEMA = {
|
|
|
35
49
|
assumed: { type: "array", items: { type: "string" } },
|
|
36
50
|
missing: { type: "array", items: { type: "string" } },
|
|
37
51
|
},
|
|
38
|
-
required: ["
|
|
52
|
+
required: ["goal", "context", "steps", "rules", "result", "grounded", "assumed", "missing"],
|
|
39
53
|
} as const
|
|
40
54
|
|
|
41
55
|
const REFINE_AGENT = {
|
|
@@ -45,7 +59,36 @@ const REFINE_AGENT = {
|
|
|
45
59
|
return prompt("refine")
|
|
46
60
|
},
|
|
47
61
|
permission: { edit: "deny", bash: "deny", webfetch: "deny" },
|
|
48
|
-
steps:
|
|
62
|
+
steps: 12,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The spec as the request ForgeQE's agent gets: labelled parts, empty ones left out. */
|
|
66
|
+
export function composeRefined(spec: RefinedSpec): string {
|
|
67
|
+
const parts = [`Goal: ${spec.goal}`]
|
|
68
|
+
if (spec.context.length) parts.push(["Context:", ...spec.context.map((c) => `- ${c}`)].join("\n"))
|
|
69
|
+
if (spec.steps.length) parts.push(["Steps:", ...spec.steps.map((st, i) => `${i + 1}. ${st}`)].join("\n"))
|
|
70
|
+
if (spec.rules.length) parts.push(["Rules:", ...spec.rules.map((r) => `- ${r}`)].join("\n"))
|
|
71
|
+
if (spec.result) parts.push(`Result: ${spec.result}`)
|
|
72
|
+
return parts.join("\n\n")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What the refiner knows before it calls a tool: each capability, whether it works in this setup and what it still
|
|
77
|
+
* needs, and the domain. Without this it spends its few steps finding out, and refines blind.
|
|
78
|
+
*/
|
|
79
|
+
export function refineContext(config: AppConfig): string {
|
|
80
|
+
const statuses = allStatuses(config)
|
|
81
|
+
const rows = CAPABILITIES.map((c) => {
|
|
82
|
+
const st = statuses.get(c.id)!
|
|
83
|
+
const state =
|
|
84
|
+
st.state === "ready"
|
|
85
|
+
? "ready"
|
|
86
|
+
: st.state === "partial"
|
|
87
|
+
? `works; optional: ${st.gaps.map((g) => g.missing).join(", ")}`
|
|
88
|
+
: `needs setup: ${st.gaps.filter((g) => !g.optional).map((g) => g.missing).join(", ")}`
|
|
89
|
+
return `- ${c.title} (${state}). Example: "${c.examples[0] || c.title}"`
|
|
90
|
+
})
|
|
91
|
+
return ["What ForgeQE can do here:", ...rows, "", domainNotes(config)].join("\n")
|
|
49
92
|
}
|
|
50
93
|
|
|
51
94
|
export function refineRequest(text: string, recent: { user: string; reply: string }[]): string {
|
|
@@ -54,10 +97,13 @@ export function refineRequest(text: string, recent: { user: string; reply: strin
|
|
|
54
97
|
}
|
|
55
98
|
|
|
56
99
|
/** Keeps only what the schema allows, so the shell never shows a malformed answer. */
|
|
57
|
-
export function cleanRefined(r: Partial<Refined> | null | undefined, original: string): Refined {
|
|
100
|
+
export function cleanRefined(r: (Partial<Refined> & Partial<RefinedSpec>) | null | undefined, original: string): Refined {
|
|
58
101
|
const strings = (v: unknown) => (Array.isArray(v) ? v.map(String).map((s) => s.trim()).filter(Boolean) : [])
|
|
102
|
+
const goal = String(r?.goal || "").trim()
|
|
103
|
+
const spec: RefinedSpec | undefined = goal ? { goal, context: strings(r?.context), steps: strings(r?.steps), rules: strings(r?.rules), result: String(r?.result || "").trim() } : undefined
|
|
59
104
|
return {
|
|
60
|
-
refined: String(r?.refined || "").trim() || original,
|
|
105
|
+
refined: spec ? composeRefined(spec) : String(r?.refined || "").trim() || original,
|
|
106
|
+
...(spec ? { spec } : {}),
|
|
61
107
|
grounded: Array.isArray(r?.grounded) ? r!.grounded.filter((g) => g && String(g.fact || "").trim()).map((g) => ({ fact: String(g.fact).trim(), checked: g.checked === true })) : [],
|
|
62
108
|
assumed: strings(r?.assumed),
|
|
63
109
|
missing: strings(r?.missing),
|
|
@@ -96,7 +142,7 @@ export async function refine(opts: {
|
|
|
96
142
|
role: "refiner",
|
|
97
143
|
signal: opts.signal,
|
|
98
144
|
onEvent: opts.onEvent,
|
|
99
|
-
agent: { ...REFINE_AGENT, prompt: `${REFINE_AGENT.prompt}\n\nThis session:\n${environmentNotes(context())}` },
|
|
145
|
+
agent: { ...REFINE_AGENT, prompt: `${REFINE_AGENT.prompt}\n\nThis session:\n${environmentNotes(context())}\n\n${refineContext(opts.config)}` },
|
|
100
146
|
schema: REFINE_SCHEMA as unknown as Record<string, unknown>,
|
|
101
147
|
mcp: { [MCP_NAME]: tools.entry },
|
|
102
148
|
hideTool: (tool) => tool.startsWith(`${MCP_NAME}_`),
|
|
@@ -104,7 +150,7 @@ export async function refine(opts: {
|
|
|
104
150
|
})
|
|
105
151
|
try {
|
|
106
152
|
let out = await session.prompt(refineRequest(opts.text, opts.recent))
|
|
107
|
-
if (!out.report && !out.error && !opts.signal?.aborted) out = await session.prompt("Give the structured result:
|
|
153
|
+
if (!out.report && !out.error && !opts.signal?.aborted) out = await session.prompt("Give the structured result: goal, context, steps, rules, result, grounded, assumed, and missing.")
|
|
108
154
|
if (!out.report) throw new Error(out.error ? `Refine failed: ${out.error}` : "Refine gave no result.")
|
|
109
155
|
return cleanRefined(out.report, opts.text)
|
|
110
156
|
} finally {
|
package/src/ste.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How closely a prompt follows ASD-STE100 Simplified Technical English: the share of its sentences that keep the
|
|
3
|
+
* writing rules that can be checked in text. The full standard also needs its approved-word dictionary, which is
|
|
4
|
+
* licensed; WORDS holds the common words it does not approve, each with the approved word to use instead.
|
|
5
|
+
*
|
|
6
|
+
* Rules checked, per sentence (a list item is one or more sentences):
|
|
7
|
+
* - length: an instruction has 20 words or fewer; a description has 25 or fewer;
|
|
8
|
+
* - one instruction per sentence: an instruction does not join a second one with ";" or "and then";
|
|
9
|
+
* - active voice: an instruction is not passive ("is sent", "are written");
|
|
10
|
+
* - words: no word from WORDS (e.g., "ensure", "utilize", "prior to");
|
|
11
|
+
* and per paragraph: 6 sentences or fewer.
|
|
12
|
+
*
|
|
13
|
+
* Words are counted as written; text in `code`, a {{placeholder}}, and a "quoted example" each count as one word,
|
|
14
|
+
* because each is a name the reader takes in whole.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Common words ASD-STE100 does not approve, with the approved word to use instead. */
|
|
18
|
+
export const WORDS: Record<string, string> = {
|
|
19
|
+
"additional": "more",
|
|
20
|
+
"approximately": "about",
|
|
21
|
+
"assist": "help",
|
|
22
|
+
"attempt": "try",
|
|
23
|
+
"commence": "start",
|
|
24
|
+
"comprise": "contain",
|
|
25
|
+
"e.g.": "for example",
|
|
26
|
+
"ensure": "make sure",
|
|
27
|
+
"ensures": "makes sure",
|
|
28
|
+
"etc.": "a full list, or \"and other\"",
|
|
29
|
+
"i.e.": "that is",
|
|
30
|
+
"in order to": "to",
|
|
31
|
+
"obtain": "get",
|
|
32
|
+
"prior to": "before",
|
|
33
|
+
"require": "need",
|
|
34
|
+
"requires": "needs",
|
|
35
|
+
"sufficient": "enough",
|
|
36
|
+
"terminate": "stop",
|
|
37
|
+
"utilize": "use",
|
|
38
|
+
"utilise": "use",
|
|
39
|
+
"via": "through",
|
|
40
|
+
"whilst": "while",
|
|
41
|
+
"don't": "do not",
|
|
42
|
+
"doesn't": "does not",
|
|
43
|
+
"can't": "cannot",
|
|
44
|
+
"won't": "will not",
|
|
45
|
+
"isn't": "is not",
|
|
46
|
+
"aren't": "are not",
|
|
47
|
+
"shouldn't": "do not",
|
|
48
|
+
"wouldn't": "would not",
|
|
49
|
+
"it's": "it is",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Verbs a sentence starts with when it is an instruction (after a leading "If …," or "When …," clause). */
|
|
53
|
+
const VERBS = new Set(
|
|
54
|
+
(
|
|
55
|
+
"accept act add answer append apply ask assert attach avoid build call change check choose cite click close compare confirm copy count cover create " +
|
|
56
|
+
"decide delete derive describe dismiss do edit end explain fill find finish fix flag focus follow generate get give group include insert install " +
|
|
57
|
+
"join keep leave let limit link list load look make map mark mention merge move name navigate note open pass pick plan point post prefer press put " +
|
|
58
|
+
"quote raise read record refer remove rename reply report rerun resolve respond retry return review run save say scroll select send set show skip " +
|
|
59
|
+
"sort split start state stay stop summarise summarize take tell test treat trust try type update use verify wait work write never always"
|
|
60
|
+
).split(" "),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const LEAD = /^(if|when|whenever|before|after|unless|while|once|until|for|in|on|with|without|where|to)\b[^,]{0,140},\s+/i
|
|
64
|
+
const PASSIVE = /\b(is|are|was|were|be|been|being)\s+(\w+ly\s+)?(\w+ed|written|sent|given|made|done|shown|taken|known|seen|found|run|built|kept|left|set|put|read|chosen|broken|held)\b/i
|
|
65
|
+
|
|
66
|
+
export type SentenceIssue = "length" | "two instructions" | "passive" | "word"
|
|
67
|
+
|
|
68
|
+
export type SteSentence = { text: string; words: number; instruction: boolean; issues: { rule: SentenceIssue; detail: string }[] }
|
|
69
|
+
|
|
70
|
+
export type SteScore = {
|
|
71
|
+
/** Sentences that keep every rule, of all sentences, 0 to 1. */
|
|
72
|
+
score: number
|
|
73
|
+
sentences: number
|
|
74
|
+
compliant: number
|
|
75
|
+
/** Paragraphs with more than 6 sentences. */
|
|
76
|
+
longParagraphs: number
|
|
77
|
+
issues: SteSentence[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Text a reader takes in as one word: `code`, {{placeholders}}, and "quoted examples". */
|
|
81
|
+
function collapse(text: string): string {
|
|
82
|
+
return text
|
|
83
|
+
.replace(/`[^`]*`/g, "CODE")
|
|
84
|
+
.replace(/\{\{[^}]*\}\}/g, "NAME")
|
|
85
|
+
.replace(/"[^"\n]{1,200}"/g, "QUOTE")
|
|
86
|
+
.replace(/“[^”\n]{1,200}”/g, "QUOTE")
|
|
87
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function countWords(text: string): number {
|
|
91
|
+
return (collapse(text).match(/[A-Za-z0-9][\w'’./-]*/g) || []).length
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isInstruction(sentence: string): boolean {
|
|
95
|
+
const s = collapse(sentence).replace(/^[\s>*_-]+/, "").replace(LEAD, "")
|
|
96
|
+
const first = (s.match(/^[A-Za-z']+/)?.[0] || "").toLowerCase()
|
|
97
|
+
return VERBS.has(first)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Sentences of a prompt, by paragraph: list items, and sentences inside them, each one; code blocks and headings left out. */
|
|
101
|
+
const ITEM_MARK = "\u0001"
|
|
102
|
+
|
|
103
|
+
export function steParagraphs(markdown: string): string[][] {
|
|
104
|
+
const paragraphs: string[][] = []
|
|
105
|
+
let fence = false
|
|
106
|
+
let current: string[] = []
|
|
107
|
+
const end = () => {
|
|
108
|
+
if (current.length) paragraphs.push(current)
|
|
109
|
+
current = []
|
|
110
|
+
}
|
|
111
|
+
const blocks: string[] = []
|
|
112
|
+
for (const raw of markdown.replace(/\r/g, "").split("\n")) {
|
|
113
|
+
if (/^\s*(```|~~~)/.test(raw)) {
|
|
114
|
+
fence = !fence
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (fence) continue
|
|
118
|
+
const line = raw.trimEnd()
|
|
119
|
+
if (!line.trim() || /^#{1,6}\s/.test(line) || /^\s*\|/.test(line) || /^\{\{>/.test(line.trim())) {
|
|
120
|
+
if (blocks.length) blocks.push("\n")
|
|
121
|
+
blocks.push("\n")
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (/^\s*([-*+]|\d+[.)])\s+/.test(line)) blocks.push("\n", ITEM_MARK + line.replace(/^\s*([-*+]|\d+[.)])\s+/, ""))
|
|
125
|
+
else blocks.push(` ${line.trim()}`)
|
|
126
|
+
}
|
|
127
|
+
// A blank line ends a paragraph. A list is not a paragraph: each item is its own, so only prose counts toward 6.
|
|
128
|
+
for (const part of blocks.join("").split(/\n\n+/)) {
|
|
129
|
+
for (const item of part.split("\n")) {
|
|
130
|
+
if (item.startsWith(ITEM_MARK)) {
|
|
131
|
+
end()
|
|
132
|
+
current = splitSentences(item.slice(ITEM_MARK.length))
|
|
133
|
+
end()
|
|
134
|
+
} else for (const sentence of splitSentences(item)) current.push(sentence)
|
|
135
|
+
}
|
|
136
|
+
end()
|
|
137
|
+
}
|
|
138
|
+
end()
|
|
139
|
+
return paragraphs.filter((p) => p.length)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function splitSentences(text: string): string[] {
|
|
143
|
+
const t = text.trim()
|
|
144
|
+
if (!t) return []
|
|
145
|
+
// Protect what has a full stop inside: abbreviations, versions, file names, and quoted text.
|
|
146
|
+
const masked = t.replace(/"[^"]*"|`[^`]*`|\b(e\.g|i\.e|etc|vs|approx)\.|\w\.\w/g, (m) => m.replace(/[.!?]/g, "\u0000"))
|
|
147
|
+
return masked
|
|
148
|
+
.split(/(?<=[.!?])\s+(?=[A-Z"`(*])/)
|
|
149
|
+
.map((s) => s.replace(/\u0000/g, ".").trim())
|
|
150
|
+
.filter((s) => /[A-Za-z]/.test(s))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function checkSentence(text: string): SteSentence {
|
|
154
|
+
const words = countWords(text)
|
|
155
|
+
const instruction = isInstruction(text)
|
|
156
|
+
const issues: SteSentence["issues"] = []
|
|
157
|
+
const limit = instruction ? 20 : 25
|
|
158
|
+
if (words > limit) issues.push({ rule: "length", detail: `${words} words; ${instruction ? "an instruction" : "a description"} has ${limit} or fewer` })
|
|
159
|
+
const plain = collapse(text)
|
|
160
|
+
if (instruction && (/;\s+\w/.test(plain) || /,?\s+and then\s+/i.test(plain))) issues.push({ rule: "two instructions", detail: "one instruction per sentence" })
|
|
161
|
+
if (instruction && PASSIVE.test(plain)) issues.push({ rule: "passive", detail: `"${plain.match(PASSIVE)![0]}": use the active voice` })
|
|
162
|
+
const lower = ` ${plain.toLowerCase()} `
|
|
163
|
+
for (const [word, use] of Object.entries(WORDS)) {
|
|
164
|
+
const at = lower.search(new RegExp(`[^a-z]${word.replace(/[.]/g, "\\.")}[^a-z]`))
|
|
165
|
+
if (at >= 0) issues.push({ rule: "word", detail: `"${word}": use ${use}` })
|
|
166
|
+
}
|
|
167
|
+
return { text, words, instruction, issues }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function steScore(markdown: string): SteScore {
|
|
171
|
+
const paragraphs = steParagraphs(markdown)
|
|
172
|
+
const all = paragraphs.flat().map(checkSentence)
|
|
173
|
+
const compliant = all.filter((s) => !s.issues.length).length
|
|
174
|
+
return {
|
|
175
|
+
score: all.length ? compliant / all.length : 1,
|
|
176
|
+
sentences: all.length,
|
|
177
|
+
compliant,
|
|
178
|
+
longParagraphs: paragraphs.filter((p) => p.length > 6).length,
|
|
179
|
+
issues: all.filter((s) => s.issues.length),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The share of sentences every built-in prompt keeps to; a project's own prompts are scored against it too. */
|
|
184
|
+
export const STE_TARGET = 0.8
|
package/src/tui/App.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from "react"
|
|
|
7
7
|
import { NO_MODEL_SET_UP } from "../llm.ts"
|
|
8
8
|
import type { CodeEvent, ReplayMode } from "../types.ts"
|
|
9
9
|
import { brainUnavailable, carryOver, openBrain, sessionSetup, type Brain, type BrainOptions } from "../brain/index.ts"
|
|
10
|
+
import type { FollowUp } from "../brain/prompt.ts"
|
|
10
11
|
import { INSTALL_HINT } from "../code/runtime.ts"
|
|
11
12
|
import { briefText, detailFrom, shows, showsAgentTool, stepIntent, type Detail } from "../detail.ts"
|
|
12
13
|
import { stepVerdictLines } from "../cases/types.ts"
|
|
@@ -23,7 +24,7 @@ import { counts } from "../suite/report.ts"
|
|
|
23
24
|
import { buildSummary, shortPath } from "../summary.ts"
|
|
24
25
|
import { meterSnapshot, requestLine } from "../usage.ts"
|
|
25
26
|
import { cleanSelection, copyText } from "./clipboard.ts"
|
|
26
|
-
import { ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Hint } from "./components.tsx"
|
|
27
|
+
import { ButtonBar, ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Button, type Hint } from "./components.tsx"
|
|
27
28
|
import { setupChecklist, type SetupItem } from "./setup.ts"
|
|
28
29
|
import { EXAMPLES, TIPS, formatDuration, formatTokens, packageVersion, pick, shortCwd, theme } from "./theme.ts"
|
|
29
30
|
import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
nextFocus,
|
|
49
50
|
planLines,
|
|
50
51
|
PlanPanel,
|
|
52
|
+
planProgress,
|
|
51
53
|
planRows,
|
|
52
54
|
SessionPanel,
|
|
53
55
|
StatusBar,
|
|
@@ -167,6 +169,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
167
169
|
const requests = useRef(0)
|
|
168
170
|
/** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
|
|
169
171
|
const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
|
|
172
|
+
// The split view's chat at the full width, for reading a long answer; the panels come back with Split view.
|
|
173
|
+
const [chatWide, setChatWide] = useState(false)
|
|
170
174
|
const setupRef = useRef<{ key: string; items: SetupItem[] }>({ key: "", items: [] })
|
|
171
175
|
// Which panel the keys go to (shift+tab moves on), the row selected in the plan and files, and the activity filter.
|
|
172
176
|
const [nav, setNavState] = useState<{ focus: Focus; plan: number; files: number; filter: ActivityFilter }>({ focus: "chat", plan: 0, files: 0, filter: "all" })
|
|
@@ -481,6 +485,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
481
485
|
active: configRef.current?.activeProfile || "",
|
|
482
486
|
narrow: widthRef.current < SPLIT_MIN,
|
|
483
487
|
view: narrowView,
|
|
488
|
+
chatWide,
|
|
484
489
|
})
|
|
485
490
|
const paletteMatches = (query: string) => searchPalette(query, paletteAll())
|
|
486
491
|
|
|
@@ -556,6 +561,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
556
561
|
else if (run.id === "stop") openDialog("stop")
|
|
557
562
|
else if (run.id === "details" || run.id === "chat") setNarrowView(run.id)
|
|
558
563
|
else if (run.id === "refine") refineRef.current()
|
|
564
|
+
else if (run.id === "wide") setChatWide((w) => !w)
|
|
565
|
+
else if (run.id === "panels") setNav((n) => ({ ...n, focus: planLines(inspRef.current).length ? "plan" : "activity" }))
|
|
559
566
|
}
|
|
560
567
|
|
|
561
568
|
const cycleProfile = () => {
|
|
@@ -641,7 +648,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
641
648
|
return
|
|
642
649
|
}
|
|
643
650
|
// The panels: shift+tab moves focus through those on screen; a focused panel takes the arrows, enter, and esc.
|
|
644
|
-
const panes = width >= SPLIT_MIN || narrowView === "details"
|
|
651
|
+
const panes = (width >= SPLIT_MIN && !chatWide) || (width < SPLIT_MIN && narrowView === "details")
|
|
645
652
|
const insp = inspRef.current
|
|
646
653
|
const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && width >= SPLIT_MIN)
|
|
647
654
|
if (panes && key.name === "tab" && key.shift && !entryRef.current) {
|
|
@@ -916,6 +923,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
916
923
|
case "card":
|
|
917
924
|
pushLine({ kind: "card", text: e.card.title, card: e.card })
|
|
918
925
|
return
|
|
926
|
+
case "report":
|
|
927
|
+
producedRef.current.reports.push({ title: e.title, html: e.html })
|
|
928
|
+
return
|
|
919
929
|
}
|
|
920
930
|
}
|
|
921
931
|
|
|
@@ -971,8 +981,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
971
981
|
return controller
|
|
972
982
|
}
|
|
973
983
|
|
|
974
|
-
const finish = (finalStatus: string) => {
|
|
975
|
-
setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on }))
|
|
984
|
+
const finish = (finalStatus: string, followUps: FollowUp[] = []) => {
|
|
985
|
+
setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on, followUps }))
|
|
976
986
|
progressDone(inspRef.current, finalStatus === "stopped")
|
|
977
987
|
bump()
|
|
978
988
|
setStatus(finalStatus)
|
|
@@ -1016,6 +1026,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1016
1026
|
const setup = sessionSetup(cfg, options.repo)
|
|
1017
1027
|
testsRef.current = { on: setup.writeTests, why: "the session was started with --no-code" }
|
|
1018
1028
|
let finalStatus = "fail"
|
|
1029
|
+
let followUps: FollowUp[] = []
|
|
1019
1030
|
let fresh = false
|
|
1020
1031
|
try {
|
|
1021
1032
|
if (!brainRef.current) {
|
|
@@ -1043,6 +1054,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1043
1054
|
finalStatus = "stopped"
|
|
1044
1055
|
} else {
|
|
1045
1056
|
if (answer.reply) pushLine({ kind: "reply", text: answer.reply, outcome: answer.outcome })
|
|
1057
|
+
followUps = answer.next || []
|
|
1046
1058
|
if (answer.error && !answer.reply) push("err", answer.error, "chat")
|
|
1047
1059
|
finalStatus = answer.outcome === "done" ? "done" : answer.outcome === "needs_input" ? "idle" : "fail"
|
|
1048
1060
|
}
|
|
@@ -1055,7 +1067,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1055
1067
|
// The split view's session and tokens panels show this; only the one-pane view needs it in the chat.
|
|
1056
1068
|
const spent = widthRef.current < SPLIT_MIN ? requestLine(meterStart) : ""
|
|
1057
1069
|
if (spent) push("sys", spent, "chat")
|
|
1058
|
-
finish(finalStatus)
|
|
1070
|
+
finish(finalStatus, followUps)
|
|
1059
1071
|
}
|
|
1060
1072
|
}
|
|
1061
1073
|
|
|
@@ -1285,35 +1297,38 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1285
1297
|
|
|
1286
1298
|
const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
|
|
1287
1299
|
const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
|
|
1288
|
-
// The split view's right pane shows tokens, time, and the last result, so the
|
|
1300
|
+
// The split view's right pane shows tokens, time, and the last result, so the row under the prompt leaves them out.
|
|
1289
1301
|
const wide = width >= SPLIT_MIN
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
...(wide ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "", drop: 3 }, ...viewHint.map((h) => ({ ...h, drop: 2 }))]),
|
|
1306
|
-
{ key: "ctrl+p", label: "commands", drop: 1 },
|
|
1307
|
-
closeHint,
|
|
1302
|
+
const openPalette = () => {
|
|
1303
|
+
paletteRef.current = { query: "", selected: 0 }
|
|
1304
|
+
setPalette(paletteRef.current)
|
|
1305
|
+
}
|
|
1306
|
+
const focusPanels = () => setNav((n) => ({ ...n, focus: planLines(inspRef.current).length ? "plan" : "activity" }))
|
|
1307
|
+
// What can be done from here, as buttons: Refine fills with its colour once there is a request to refine.
|
|
1308
|
+
// `drop`: the order they give way on a narrow row; Refine, Search, and Stop always stay.
|
|
1309
|
+
const refineButton: Button = { icon: "✦", label: "Refine", keys: "ctrl+r", color: theme.memory, onPress: () => refineRef.current(), active: Boolean(promptText.trim()) }
|
|
1310
|
+
const searchButton: Button = { icon: "⌕", label: "Search", keys: "ctrl+p", color: theme.accent, onPress: openPalette }
|
|
1311
|
+
// The view: in the split view, Panels and Wide chat sit in the right pane's status line, where there is room;
|
|
1312
|
+
// Split view comes back here when the chat is wide, and Details on a narrow terminal.
|
|
1313
|
+
const toggleWide = () => setChatWide((w) => !w)
|
|
1314
|
+
const paneButtons: Button[] = [
|
|
1315
|
+
{ icon: "▦", label: "Panels", keys: "shift+tab", color: theme.thought, onPress: focusPanels, drop: 2 },
|
|
1316
|
+
{ icon: "⤢", label: "Wide chat", color: theme.muted, onPress: toggleWide, drop: 1 },
|
|
1308
1317
|
]
|
|
1318
|
+
const viewButtons: Button[] = wide
|
|
1319
|
+
? chatWide
|
|
1320
|
+
? [{ icon: "◧", label: "Split view", color: theme.thought, onPress: toggleWide, drop: 2 }]
|
|
1321
|
+
: []
|
|
1322
|
+
: [{ icon: "◧", label: "Details", keys: "ctrl+o", color: theme.thought, onPress: () => setNarrowView("details"), drop: 1 }]
|
|
1323
|
+
const idleButtons: Button[] = [refineButton, searchButton, ...viewButtons, ...(wide ? [] : [{ icon: "⇄", label: "Profile", keys: "tab", color: theme.muted, onPress: cycleProfile, drop: 4 }])]
|
|
1324
|
+
const runningButtons: Button[] = [{ icon: "■", label: "Stop", keys: "esc", color: theme.warn, onPress: () => openDialog("stop") }, searchButton, ...viewButtons]
|
|
1325
|
+
const idleHints: Hint[] = [...copiedHint, ...(lastStats && !wide && !copied ? [{ key: `last run ${lastStats}`, label: "" }] : []), closeHint]
|
|
1326
|
+
// The one-pane view has no tokens panel, so the tokens and time show here.
|
|
1327
|
+
const runningHints: Hint[] = [...copiedHint, ...(wide || copied ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" }]), closeHint]
|
|
1309
1328
|
const runningLeft = (
|
|
1310
|
-
|
|
1329
|
+
<box style={{ flexDirection: "row", marginRight: 1 }}>
|
|
1311
1330
|
<Spinner kind="bar" />
|
|
1312
|
-
|
|
1313
|
-
<span fg={theme.text}>{" esc"}</span>
|
|
1314
|
-
<span fg={theme.muted}>{" stop"}</span>
|
|
1315
|
-
</text>
|
|
1316
|
-
</>
|
|
1331
|
+
</box>
|
|
1317
1332
|
)
|
|
1318
1333
|
|
|
1319
1334
|
const renderControls = (promptWidth: number, placeholder: string, rows: [number, number]) => {
|
|
@@ -1406,7 +1421,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1406
1421
|
setPromptText(t)
|
|
1407
1422
|
}}
|
|
1408
1423
|
/>
|
|
1409
|
-
<
|
|
1424
|
+
<ButtonBar width={promptWidth} buttons={running ? runningButtons : idleButtons} hints={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
|
|
1410
1425
|
</>
|
|
1411
1426
|
)
|
|
1412
1427
|
}
|
|
@@ -1509,7 +1524,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1509
1524
|
const sel = (f: "plan" | "files") => (nav.focus === f ? Math.min(nav[f], Math.max(0, (f === "plan" ? planLines(insp) : filesByUse(insp)).length - 1)) : undefined)
|
|
1510
1525
|
return (
|
|
1511
1526
|
<box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
|
|
1512
|
-
<StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress} />
|
|
1527
|
+
<StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress || planProgress(insp)} actions={paneButtons} />
|
|
1513
1528
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1514
1529
|
{plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel("plan")} onFocus={focusOn("plan")} /> : null}
|
|
1515
1530
|
<ActivityPanel
|
|
@@ -1536,7 +1551,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1536
1551
|
{all.map((line) => (
|
|
1537
1552
|
<TranscriptLine key={line.id} line={line} />
|
|
1538
1553
|
))}
|
|
1539
|
-
{running && !split ? (
|
|
1554
|
+
{running && (!split || chatWide) ? (
|
|
1540
1555
|
<box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
|
|
1541
1556
|
<Spinner kind="dots" />
|
|
1542
1557
|
<text fg={theme.text}>{` ${activity || "Working…"}`}</text>
|
|
@@ -1548,18 +1563,22 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1548
1563
|
|
|
1549
1564
|
const split = width >= SPLIT_MIN
|
|
1550
1565
|
if (split) {
|
|
1551
|
-
|
|
1566
|
+
// Wide chat: the conversation across the screen for reading, the panels hidden until Split view.
|
|
1567
|
+
const leftWidth = chatWide ? width : Math.floor(width * 0.4)
|
|
1552
1568
|
return (
|
|
1553
1569
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1554
1570
|
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1555
|
-
<box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1556
|
-
<SessionPanel info={sessionInfo} width={leftWidth - 3} />
|
|
1557
|
-
{
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1561
|
-
{details(width - leftWidth - 3, height - 2)}
|
|
1571
|
+
<box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: chatWide ? 2 : 1 }}>
|
|
1572
|
+
<SessionPanel info={sessionInfo} width={leftWidth - (chatWide ? 4 : 3)} />
|
|
1573
|
+
{/* Wide chat shows what the agents do in the conversation, as the one-pane view does. */}
|
|
1574
|
+
{transcript(chatWide ? lines : chatLines, {})}
|
|
1575
|
+
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - (chatWide ? 4 : 3))}</box>
|
|
1562
1576
|
</box>
|
|
1577
|
+
{chatWide ? null : (
|
|
1578
|
+
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1579
|
+
{details(width - leftWidth - 3, height - 2)}
|
|
1580
|
+
</box>
|
|
1581
|
+
)}
|
|
1563
1582
|
</box>
|
|
1564
1583
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1565
1584
|
{paletteOverlay}
|
package/src/tui/components.tsx
CHANGED
|
@@ -206,6 +206,55 @@ export function Hints({ width, items: all, left, reserve = 0 }: { width: number;
|
|
|
206
206
|
)
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/**
|
|
210
|
+
* A button under the prompt: an icon, a label, and its key, on a raised background; `active` fills it with its colour
|
|
211
|
+
* (Refine once there is text to refine). `drop`: when it gives way on a narrow row, highest first.
|
|
212
|
+
*/
|
|
213
|
+
export type Button = { icon: string; label: string; keys?: string; color: string; onPress: () => void; active?: boolean; drop?: number }
|
|
214
|
+
|
|
215
|
+
const buttonCells = (b: Button) => b.icon.length + 1 + b.label.length + (b.keys ? b.keys.length + 1 : 0) + 3
|
|
216
|
+
|
|
217
|
+
/** The buttons that fit in `room` columns; a narrow row first loses whole buttons by their `drop`, then the keys. */
|
|
218
|
+
export function fitButtons(items: Button[], room: number): Button[] {
|
|
219
|
+
const cells = (list: Button[]) => list.reduce((n, b) => n + buttonCells(b), 0)
|
|
220
|
+
let kept = [...items]
|
|
221
|
+
for (const b of items.filter((x) => x.drop !== undefined).sort((x, y) => y.drop! - x.drop!)) {
|
|
222
|
+
if (cells(kept) <= room) return kept
|
|
223
|
+
kept = kept.filter((k) => k !== b)
|
|
224
|
+
}
|
|
225
|
+
return cells(kept) <= room ? kept : kept.map((b) => ({ ...b, keys: undefined }))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The row under the prompt: buttons on the left, the hints (close, a note) on the right. */
|
|
229
|
+
export function ButtonBar({ width, buttons, hints, left }: { width: number; buttons: Button[]; hints: Hint[]; left?: ReactNode }) {
|
|
230
|
+
const hintCells = hints.reduce((n, h, i) => n + h.key.length + (h.label ? h.label.length + 1 : 0) + (i ? 2 : 0), 0)
|
|
231
|
+
const shown = fitButtons(buttons, Math.max(0, width - hintCells - (left ? 22 : 0) - 1))
|
|
232
|
+
return (
|
|
233
|
+
<box style={{ width, height: 1, flexShrink: 0, flexDirection: "row", justifyContent: "space-between" }}>
|
|
234
|
+
<box style={{ flexDirection: "row" }}>
|
|
235
|
+
{left}
|
|
236
|
+
{shown.map((b) => (
|
|
237
|
+
<box key={b.label} onMouseDown={b.onPress} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: b.active ? b.color : theme.button }}>
|
|
238
|
+
<text>
|
|
239
|
+
<span fg={b.active ? theme.onButton : b.color}>{`${b.icon} `}</span>
|
|
240
|
+
<span fg={b.active ? theme.onButton : theme.text}>{b.label}</span>
|
|
241
|
+
{b.keys ? <span fg={b.active ? theme.onButton : theme.faint}>{` ${b.keys}`}</span> : null}
|
|
242
|
+
</text>
|
|
243
|
+
</box>
|
|
244
|
+
))}
|
|
245
|
+
</box>
|
|
246
|
+
<box style={{ flexDirection: "row" }}>
|
|
247
|
+
{hints.map((item, i) => (
|
|
248
|
+
<text key={`${item.key}-${item.label}`} onMouseDown={item.onPress}>
|
|
249
|
+
<span fg={theme.text}>{`${i ? " " : ""}${item.key}`}</span>
|
|
250
|
+
<span fg={item.onPress ? theme.muted : theme.faint}>{item.label ? ` ${item.label}` : ""}</span>
|
|
251
|
+
</text>
|
|
252
|
+
))}
|
|
253
|
+
</box>
|
|
254
|
+
</box>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
209
258
|
export type Choice = { id: string; label: string }
|
|
210
259
|
|
|
211
260
|
export type ChoicePanelProps = {
|