@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.
Files changed (41) hide show
  1. package/HOW_TO_USE.md +42 -2
  2. package/docs/user-guide.html +45 -3
  3. package/package.json +1 -1
  4. package/prompts/README.md +6 -1
  5. package/prompts/api-scenarios.md +21 -10
  6. package/prompts/api-tests.md +55 -25
  7. package/prompts/brain.md +296 -135
  8. package/prompts/browser-capture.md +8 -1
  9. package/prompts/browser-case-report.md +12 -1
  10. package/prompts/browser-checks.md +22 -8
  11. package/prompts/browser-efficiency.md +1 -1
  12. package/prompts/browser-error-recovery.md +18 -6
  13. package/prompts/browser-goal-verification.md +9 -8
  14. package/prompts/browser-skill-select.md +1 -1
  15. package/prompts/domain-rule.md +16 -8
  16. package/prompts/eval-judge.md +15 -8
  17. package/prompts/explore-charters.md +19 -15
  18. package/prompts/find-tests.md +26 -16
  19. package/prompts/heal.md +32 -24
  20. package/prompts/pr-review.md +21 -13
  21. package/prompts/refine.md +30 -10
  22. package/prompts/skill-rule.md +1 -1
  23. package/prompts/test-agent.md +43 -18
  24. package/prompts/test-reviewer.md +37 -15
  25. package/prompts/test-rules.md +55 -19
  26. package/src/brain/index.ts +4 -3
  27. package/src/brain/prompt.ts +34 -2
  28. package/src/capabilities/registry.ts +12 -0
  29. package/src/cli/help.ts +1 -0
  30. package/src/cli/index.ts +21 -3
  31. package/src/mcp/reporttools.ts +101 -0
  32. package/src/mcp/tools.ts +4 -0
  33. package/src/refine.ts +53 -7
  34. package/src/ste.ts +184 -0
  35. package/src/tui/App.tsx +60 -41
  36. package/src/tui/components.tsx +49 -0
  37. package/src/tui/inspector.tsx +40 -6
  38. package/src/tui/next.ts +12 -1
  39. package/src/tui/palette.tsx +6 -2
  40. package/src/tui/refine-panel.tsx +14 -1
  41. 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
- refined: { type: "string", description: "The request, precise and complete, as the user would say it" },
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: ["refined", "grounded", "assumed", "missing"],
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: 8,
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: refined, grounded, assumed, and missing.")
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 hints under the prompt leave them out.
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 viewHint: Hint[] = wide ? [] : [{ key: "ctrl+o", label: "details", onPress: () => setNarrowView("details") }]
1291
- // Each hint's `drop` is when it gives way on a narrow row: the last run and profiles (also in the palette) go first.
1292
- const idleHints: Hint[] = [
1293
- ...copiedHint.map((h) => ({ ...h, drop: 0 })),
1294
- ...(lastStats && !wide ? [{ key: `last run ${lastStats}`, label: "", drop: 5 }] : []),
1295
- ...viewHint.map((h) => ({ ...h, drop: 3 })),
1296
- { key: "ctrl+r", label: "refine", onPress: () => refineRef.current(), drop: 1 },
1297
- ...(wide ? [{ key: "shift+tab", label: "panels", onPress: () => setNav((n) => ({ ...n, focus: planLines(inspRef.current).length ? "plan" : "activity" })), drop: 4 }] : [{ key: "tab", label: "profiles", drop: 4 }]),
1298
- { key: "ctrl+p", label: "commands" },
1299
- closeHint,
1300
- ]
1301
- const runningHints: Hint[] = [
1302
- // A note ("opened report.html") is the last to give way: it answers what was just pressed.
1303
- ...copiedHint.map((h) => ({ ...h, drop: 0 })),
1304
- // The split view's panels show tokens and time; the one-pane view shows them here while there is room.
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
- <text onMouseDown={() => openDialog("stop")}>
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
- <Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} reserve={running ? 20 : 0} />
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
- const leftWidth = Math.floor(width * 0.4)
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
- {transcript(chatLines, {})}
1558
- <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1559
- </box>
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}
@@ -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 = {