@capagents/forgeqe 1.0.9 → 1.1.1

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/src/mcp/tools.ts CHANGED
@@ -98,6 +98,25 @@ export type ToolEvent =
98
98
  | { type: "card"; card: Card }
99
99
  /** write_report saved a report: its page and its Markdown. */
100
100
  | { type: "report"; title: string; html: string; markdown: string }
101
+ /** A finished call of a tool that has no card of its own, as one line: "Azure DevOps API · QI-Claims/_apis/build/builds (top 5)". */
102
+ | { type: "call"; text: string; ok: boolean }
103
+
104
+ const API_LABEL: Record<string, string> = { azure_devops: "Azure DevOps", jira: "Jira", xray: "Xray", confluence: "Confluence" }
105
+
106
+ /**
107
+ * A query_api call as a line: "Azure DevOps API · QI-Claims/_apis/release/releases (top 1, queryOrder descending)". The
108
+ * host and organization are left out, as are api-version and the start of a WIQL query.
109
+ */
110
+ export function queryLine(args: Record<string, any>): string {
111
+ const name = `${API_LABEL[String(args.system)] || String(args.system || "API")} API`
112
+ if (args.wiql) return `${name} · WIQL (${clip(String(args.wiql).replace(/\s+/g, " ").replace(/^SELECT .*? FROM WorkItems WHERE /i, ""), 90)})`
113
+ if (args.query) return `${name} · GraphQL (${clip(String(args.query).replace(/\s+/g, " "), 90)})`
114
+ const raw = String(args.path || "")
115
+ const url = /^https?:\/\//.test(raw) ? raw.replace(/^https?:\/\/[^/]+\/(?:[^/]+\/(?=[^/]+\/_apis\/))?/, "") : raw.replace(/^\//, "")
116
+ const [where, search = ""] = url.split("?")
117
+ const params = [...new URLSearchParams(search)].filter(([k]) => k !== "api-version").map(([k, v]) => `${k.replace(/^\$/, "")} ${v}`)
118
+ return `${name} · ${decodeURIComponent(where || "")}${params.length ? ` (${clip(params.join(", "), 90)})` : ""}`
119
+ }
101
120
 
102
121
  export type ToolContext = {
103
122
  config: AppConfig
@@ -135,6 +154,8 @@ export type McpTool = {
135
154
  description: string
136
155
  inputSchema: Record<string, unknown>
137
156
  run(args: Record<string, any>, ctx: ToolContext): Promise<unknown>
157
+ /** The call as one line in the conversation, for a tool that shows nothing else: the ForgeQE tools are hidden there. */
158
+ line?(args: Record<string, any>): string
138
159
  }
139
160
 
140
161
  const clip = (s: string | undefined, n: number) => {
@@ -563,6 +584,7 @@ export const TOOLS: McpTool[] = [
563
584
  limit: { type: "integer", minimum: 1, maximum: 25, description: "Most pages to return (default 10)" },
564
585
  },
565
586
  },
587
+ line: (args) => `Confluence · ${args.cql ? `search (${clip(String(args.cql), 90)})` : `pages (${clip((Array.isArray(args.pages) ? args.pages : []).map(String).join(", "), 90)})`}`,
566
588
  async run(args, ctx) {
567
589
  const pages = Array.isArray(args.pages) ? args.pages.map(String) : undefined
568
590
  return readConfluence(ctx.config.sources, { pages, cql: args.cql ? String(args.cql) : undefined, limit: Number(args.limit) || undefined })
@@ -571,7 +593,7 @@ export const TOOLS: McpTool[] = [
571
593
  {
572
594
  name: "query_api",
573
595
  description:
574
- "Read anything from Azure DevOps, Jira, Xray, or Confluence through their APIs with the credentials in ForgeQE's settings: work items, bugs, stories, queries, pipeline runs, issues, JQL searches, Xray tests and executions, Confluence pages and spaces. Use it for what the other tools and connected MCP servers don't cover, or when a system has no MCP server. Read-only: GET requests, WIQL searches for Azure DevOps work items, and GraphQL queries for Xray Cloud.",
596
+ "Read anything from Azure DevOps, Jira, Xray, or Confluence through their APIs with the credentials in ForgeQE's settings: work items, bugs, stories, queries, pipeline runs, classic releases and deployments, issues, JQL searches, Xray tests and executions, Confluence pages and spaces. Use it for what the other tools and connected MCP servers don't cover, or when a system has no MCP server: before the browser, and never curl or scripts in the shell, which have no credentials. Read-only: GET requests, WIQL searches for Azure DevOps work items, and GraphQL queries for Xray Cloud.",
575
597
  inputSchema: {
576
598
  type: "object",
577
599
  properties: {
@@ -579,7 +601,7 @@ export const TOOLS: McpTool[] = [
579
601
  path: {
580
602
  type: "string",
581
603
  description:
582
- "azure_devops: REST path under the organization URL, e.g. Shop/_apis/wit/workitems/12?$expand=all (api-version=7.1 is added when missing). jira: REST path under the site URL, e.g. /rest/api/3/search/jql?jql=project%3DSHOP&fields=summary,status (Server/Data Center: /rest/api/2/..., Xray Server: /rest/raven/1.0/...). confluence: REST path under the Confluence URL, e.g. /rest/api/space or /api/v2/pages/123/children (Cloud).",
604
+ "azure_devops: REST path under the organization URL, e.g. Shop/_apis/wit/workitems/12?$expand=all or Shop/_apis/build/builds?$top=5 (api-version=7.1 is added when missing). Classic releases are on the release host: pass the full URL, e.g. https://vsrm.dev.azure.com/<org>/Shop/_apis/release/releases?$top=10, .../_apis/release/definitions, or .../_apis/release/deployments. jira: REST path under the site URL, e.g. /rest/api/3/search/jql?jql=project%3DSHOP&fields=summary,status (Server/Data Center: /rest/api/2/..., Xray Server: /rest/raven/1.0/...). confluence: REST path under the Confluence URL, e.g. /rest/api/space or /api/v2/pages/123/children (Cloud).",
583
605
  },
584
606
  query: { type: "string", description: "xray (Cloud): a GraphQL query, e.g. { getTests(jql: \"project = SHOP\", limit: 10) { results { jira(fields: [\"key\"]) } } }" },
585
607
  wiql: {
@@ -592,6 +614,7 @@ export const TOOLS: McpTool[] = [
592
614
  },
593
615
  required: ["system"],
594
616
  },
617
+ line: queryLine,
595
618
  async run(args, ctx) {
596
619
  return queryApi(ctx.config.sources, { system: args.system, path: args.path, query: args.query, variables: args.variables, wiql: args.wiql, project: args.project })
597
620
  },
package/src/refine.ts CHANGED
@@ -10,6 +10,7 @@ import { CAPABILITIES } from "./capabilities/registry.ts"
10
10
  import { allStatuses } from "./capabilities/status.ts"
11
11
  import { domainNotes } from "./domain.ts"
12
12
  import type { CodeEvent } from "./types.ts"
13
+ import { steScore } from "./ste.ts"
13
14
 
14
15
  /**
15
16
  * The shell's Refine (ctrl+r): what the user typed becomes a precise request for ForgeQE's agent, checked against what
@@ -20,13 +21,18 @@ import type { CodeEvent } from "./types.ts"
20
21
  /** The only tools the refiner may call: each reads, none changes anything or starts a run. */
21
22
  export const REFINE_TOOLS = ["capabilities", "query_api", "find_test_cases", "list_sprints", "run_details"]
22
23
 
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 }
24
+ /**
25
+ * The refined request as a task specification, laid out as a system prompt: the role the agent takes, the objective,
26
+ * the context, the steps, the rules, the output format, and the objective again in one sentence at the end.
27
+ */
28
+ export type RefinedSpec = { role: string; objective: string; context: string[]; steps: string[]; rules: string[]; output: string; summary: string }
25
29
 
26
30
  export type Refined = {
27
- /** The request that is sent: the spec written out (Goal, Context, Steps, Rules, Result). */
31
+ /** The request that is sent: the spec written out (Role, Objective, Context, Steps, Rules, Output format, Summary). */
28
32
  refined: string
29
33
  spec?: RefinedSpec
34
+ /** The share of the request's sentences that keep the ASD-STE100 rules, 0 to 1. */
35
+ ste?: number
30
36
  grounded: { fact: string; checked: boolean }[]
31
37
  assumed: string[]
32
38
  missing: string[]
@@ -37,11 +43,13 @@ const lines = { type: "array", items: { type: "string" } } as const
37
43
  export const REFINE_SCHEMA = {
38
44
  type: "object",
39
45
  properties: {
40
- goal: { type: "string", description: "One instruction that starts with a verb: what the agent must do" },
46
+ role: { type: "string", description: "The role the agent takes for this task, as one description, e.g. A release analyst who reads Azure DevOps deployments" },
47
+ objective: { type: "string", description: "One instruction that starts with a verb: what the agent must do" },
41
48
  context: { ...lines, description: "Facts the agent needs, one per line: system, project, work items, files, runs, with exact names and ids" },
42
49
  steps: { ...lines, description: "The actions in sequence, one per step; empty for a request with one action" },
43
50
  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" },
51
+ output: { type: "string", description: "The output format: what the agent gives back and how it is laid out (sections, a table, a file)" },
52
+ summary: { type: "string", description: "The objective again, in one short sentence, as the last line" },
45
53
  grounded: {
46
54
  type: "array",
47
55
  items: { type: "object", properties: { fact: { type: "string" }, checked: { type: "boolean" } }, required: ["fact", "checked"] },
@@ -49,7 +57,7 @@ export const REFINE_SCHEMA = {
49
57
  assumed: { type: "array", items: { type: "string" } },
50
58
  missing: { type: "array", items: { type: "string" } },
51
59
  },
52
- required: ["goal", "context", "steps", "rules", "result", "grounded", "assumed", "missing"],
60
+ required: ["role", "objective", "context", "steps", "rules", "output", "summary", "grounded", "assumed", "missing"],
53
61
  } as const
54
62
 
55
63
  const REFINE_AGENT = {
@@ -64,11 +72,14 @@ const REFINE_AGENT = {
64
72
 
65
73
  /** The spec as the request ForgeQE's agent gets: labelled parts, empty ones left out. */
66
74
  export function composeRefined(spec: RefinedSpec): string {
67
- const parts = [`Goal: ${spec.goal}`]
75
+ const parts: string[] = []
76
+ if (spec.role) parts.push(`Role: ${spec.role}`)
77
+ parts.push(`Objective: ${spec.objective}`)
68
78
  if (spec.context.length) parts.push(["Context:", ...spec.context.map((c) => `- ${c}`)].join("\n"))
69
79
  if (spec.steps.length) parts.push(["Steps:", ...spec.steps.map((st, i) => `${i + 1}. ${st}`)].join("\n"))
70
80
  if (spec.rules.length) parts.push(["Rules:", ...spec.rules.map((r) => `- ${r}`)].join("\n"))
71
- if (spec.result) parts.push(`Result: ${spec.result}`)
81
+ if (spec.output) parts.push(`Output format: ${spec.output}`)
82
+ if (spec.summary) parts.push(`Summary: ${spec.summary}`)
72
83
  return parts.join("\n\n")
73
84
  }
74
85
 
@@ -97,13 +108,18 @@ export function refineRequest(text: string, recent: { user: string; reply: strin
97
108
  }
98
109
 
99
110
  /** Keeps only what the schema allows, so the shell never shows a malformed answer. */
100
- export function cleanRefined(r: (Partial<Refined> & Partial<RefinedSpec>) | null | undefined, original: string): Refined {
111
+ export function cleanRefined(r: (Partial<Refined> & Partial<RefinedSpec> & { goal?: string; result?: string }) | null | undefined, original: string): Refined {
101
112
  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
113
+ const text = (v: unknown) => String(v || "").trim()
114
+ // goal and result: the names an older refine prompt (a project's own copy) gives the objective and the output.
115
+ const objective = text(r?.objective) || text(r?.goal)
116
+ const spec: RefinedSpec | undefined = objective
117
+ ? { role: text(r?.role), objective, context: strings(r?.context), steps: strings(r?.steps), rules: strings(r?.rules), output: text(r?.output) || text(r?.result), summary: text(r?.summary) }
118
+ : undefined
119
+ const refined = spec ? composeRefined(spec) : text(r?.refined) || original
104
120
  return {
105
- refined: spec ? composeRefined(spec) : String(r?.refined || "").trim() || original,
106
- ...(spec ? { spec } : {}),
121
+ refined,
122
+ ...(spec ? { spec, ste: steScore(refined).score } : {}),
107
123
  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 })) : [],
108
124
  assumed: strings(r?.assumed),
109
125
  missing: strings(r?.missing),
@@ -150,7 +166,7 @@ export async function refine(opts: {
150
166
  })
151
167
  try {
152
168
  let out = await session.prompt(refineRequest(opts.text, opts.recent))
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.")
169
+ if (!out.report && !out.error && !opts.signal?.aborted) out = await session.prompt("Give the structured result: role, objective, context, steps, rules, output, summary, grounded, assumed, and missing.")
154
170
  if (!out.report) throw new Error(out.error ? `Refine failed: ${out.error}` : "Refine gave no result.")
155
171
  return cleanRefined(out.report, opts.text)
156
172
  } finally {
package/src/tui/App.tsx CHANGED
@@ -26,8 +26,9 @@ import { meterSnapshot, requestLine } from "../usage.ts"
26
26
  import { cleanSelection, copyText } from "./clipboard.ts"
27
27
  import { ButtonBar, ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Button, type Hint } from "./components.tsx"
28
28
  import { setupChecklist, type SetupItem } from "./setup.ts"
29
+ import { initWorkspace } from "../init.ts"
29
30
  import { ShortcutsPanel } from "./shortcuts.tsx"
30
- import { ago, projectLine, setupSentence, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
31
+ import { ago, getStarted, projectLine, setupSentence, type Fact, type GetStarted, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
31
32
  import { TIPS, formatDuration, formatTokens, packageVersion, shortCwd, theme } from "./theme.ts"
32
33
  import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
33
34
  import { Dialog, dialogSpec } from "./dialogs.ts"
@@ -35,7 +36,7 @@ import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
35
36
  import { featuresText } from "../capabilities/render.ts"
36
37
  import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
37
38
  import { commandSuggestions, complete, lookupWorkItem, recentRuns, referenceSuggestions, repoFiles, triggerAt, type Suggestion, type WorkItem } from "./suggest.ts"
38
- import { REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
39
+ import { initialChoice, REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
39
40
  import { refine } from "../refine.ts"
40
41
  import { nextSteps, type NextStep } from "./next.ts"
41
42
  import { CAPABILITIES } from "../capabilities/registry.ts"
@@ -178,8 +179,8 @@ export function App({ options }: { options: TuiOptions }) {
178
179
  /** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
179
180
  const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
180
181
  // The split view's chat at the full width, for reading a long answer; the panels come back with Split view.
181
- const [chatWide, setChatWide] = useState(false)
182
- const setupRef = useRef<{ key: string; items: SetupItem[]; start: StartAction[]; recent: RecentItem[] }>({ key: "", items: [], start: [], recent: [] })
182
+ const [chatWide, setChatWide] = useState(true)
183
+ const setupRef = useRef<{ key: string; items: SetupItem[]; start: StartAction[]; recent: RecentItem[]; first: GetStarted[]; facts: Fact[] }>({ key: "", items: [], start: [], recent: [], first: [], facts: [] })
183
184
  // Which panel the keys go to (shift+tab moves on), the row selected in the plan and files, and the activity filter.
184
185
  const [nav, setNavState] = useState<{ focus: Focus; plan: number; files: number; filter: ActivityFilter }>({ focus: "chat", plan: 0, files: 0, filter: "all" })
185
186
  const navRef = useRef(nav)
@@ -518,6 +519,7 @@ export function App({ options }: { options: TuiOptions }) {
518
519
  }
519
520
 
520
521
  /** Refines what is in the prompt; the user then decides what is sent. */
522
+ const refineCount = useRef(0)
521
523
  const startRefine = () => {
522
524
  const text = promptTextRef.current.trim()
523
525
  const cfg = configRef.current
@@ -533,10 +535,19 @@ export function App({ options }: { options: TuiOptions }) {
533
535
  const controller = new AbortController()
534
536
  refineAbort.current = controller
535
537
  setRefine({ phase: "working", original: text })
536
- void (options.refine || refine)({ config: cfg, cwd: sessionSetup(cfg, options.repo).cwd, text, recent: convoRef.current, signal: controller.signal })
538
+ // Refine is a model session of its own: its tokens count in the session total, under a key per refine.
539
+ const key = `refine-${++refineCount.current}`
540
+ const started = Date.now()
541
+ let used = 0
542
+ const onEvent = (e: CodeEvent) => {
543
+ if (e.kind !== "usage") return
544
+ used = e.usage.input + e.usage.output
545
+ useTokens(key, used)
546
+ }
547
+ void (options.refine || refine)({ config: cfg, cwd: sessionSetup(cfg, options.repo).cwd, text, recent: convoRef.current, signal: controller.signal, onEvent })
537
548
  .then((result) => {
538
549
  if (controller.signal.aborted || refiningRef.current?.phase !== "working") return
539
- setRefine({ phase: "done", original: text, result, choice: 0 })
550
+ setRefine({ phase: "done", original: text, result, choice: initialChoice(result.missing), ...(used ? { tokens: used } : {}), ms: Date.now() - started })
540
551
  })
541
552
  .catch((err) => {
542
553
  if (controller.signal.aborted) return
@@ -668,7 +679,8 @@ export function App({ options }: { options: TuiOptions }) {
668
679
  // The panels: shift+tab moves focus through those on screen; a focused panel takes the arrows, enter, and esc.
669
680
  const panes = (width >= SPLIT_MIN && !chatWide) || (width < SPLIT_MIN && narrowView === "details")
670
681
  const insp = inspRef.current
671
- const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && width >= SPLIT_MIN)
682
+ const panelsOpen = width >= SPLIT_MIN && !chatWide
683
+ const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && panelsOpen)
672
684
  if (panes && key.name === "tab" && key.shift && !entryRef.current) {
673
685
  // Consumed here: the prompt can take focus within this key press and must not also act on it.
674
686
  key.preventDefault()
@@ -682,7 +694,7 @@ export function App({ options }: { options: TuiOptions }) {
682
694
  const list = at === "plan" ? planLines(insp).length : at === "files" ? filesByUse(insp).length : 0
683
695
  const move = (d: number) => setNav((n) => ({ ...n, [at]: Math.max(0, Math.min(list - 1, (n[at as "plan" | "files"] ?? 0) + d)) }))
684
696
  const page = Math.max(3, Math.floor(height / 3))
685
- if (key.name === "escape") return setNav((n) => ({ ...n, focus: width >= SPLIT_MIN ? "chat" : "activity" }))
697
+ if (key.name === "escape") return setNav((n) => ({ ...n, focus: panelsOpen ? "chat" : "activity" }))
686
698
  if (at === "activity") {
687
699
  const box = activityScroll.current
688
700
  if (key.name === "up") return box?.scrollBy(-1)
@@ -710,7 +722,7 @@ export function App({ options }: { options: TuiOptions }) {
710
722
  }
711
723
  }
712
724
  // Anything typed goes back to the chat, where the prompt is.
713
- if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && width >= SPLIT_MIN) return setNav((n) => ({ ...n, focus: "chat" }))
725
+ if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && panelsOpen) return setNav((n) => ({ ...n, focus: "chat" }))
714
726
  if (!key.ctrl && !key.meta) return
715
727
  }
716
728
  // ? on an empty prompt: every key in one panel; esc or ? closes it.
@@ -726,8 +738,11 @@ export function App({ options }: { options: TuiOptions }) {
726
738
  }
727
739
  // The start screen's actions: alt+1 to alt+4 put one in the prompt.
728
740
  if (key.meta && /^[1-4]$/.test(key.name || "") && linesRef.current === 0 && !abortRef.current) {
729
- const a = setupRef.current.start[Number(key.name) - 1]
741
+ const n = Number(key.name) - 1
742
+ const a = setupRef.current.start[n]
743
+ const g = setupRef.current.first[n]
730
744
  if (a) fillPrompt(a.request)
745
+ else if (!setupRef.current.start.length && g) g.command ? submitRef.current(g.command) : g.fix && push("sys", g.fix, "chat")
731
746
  return
732
747
  }
733
748
  if (suggRef.current.list.length && suggRef.current.trigger && !entryRef.current) {
@@ -748,7 +763,11 @@ export function App({ options }: { options: TuiOptions }) {
748
763
  return
749
764
  }
750
765
  if (key.name === "r" && key.ctrl && !entryRef.current) startRefine()
751
- if (key.name === "o" && key.ctrl && width < SPLIT_MIN) setNarrowView((v) => (v === "chat" ? "details" : "chat"))
766
+ // ctrl+o: the panels (plan, activity, files, context, tokens) beside the chat on a wide terminal, in its place on a narrow one.
767
+ if (key.name === "o" && key.ctrl) {
768
+ if (width >= SPLIT_MIN) setChatWide((w) => !w)
769
+ else setNarrowView((v) => (v === "chat" ? "details" : "chat"))
770
+ }
752
771
  if (key.name === "escape" && abortRef.current) openDialog("stop")
753
772
  })
754
773
 
@@ -905,6 +924,10 @@ export function App({ options }: { options: TuiOptions }) {
905
924
  case "info":
906
925
  push(e.ok === undefined ? "code" : e.ok ? "ok" : "err", e.text)
907
926
  return
927
+ case "call":
928
+ pushTool(e.text, e.ok)
929
+ if (!aborted()) setActivity(`Agent: ${clip(e.text, 60)}`)
930
+ return
908
931
  case "agent": {
909
932
  const ev = e.event
910
933
  inspect(ev)
@@ -1104,7 +1127,9 @@ export function App({ options }: { options: TuiOptions }) {
1104
1127
  pushProduced()
1105
1128
  // What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
1106
1129
  // The split view's session and tokens panels show this; only the one-pane view needs it in the chat.
1107
- const spent = widthRef.current < SPLIT_MIN ? requestLine(meterStart) : ""
1130
+ // The footer and the tokens panel show what a request used; the conversation stays about the work.
1131
+ const spent = ""
1132
+ void meterStart
1108
1133
  if (spent) push("sys", spent, "chat")
1109
1134
  finish(finalStatus, followUps)
1110
1135
  }
@@ -1129,6 +1154,19 @@ export function App({ options }: { options: TuiOptions }) {
1129
1154
  else void askAgent(text, cfg)
1130
1155
  }
1131
1156
 
1157
+ /** Reads forgeqe.yaml and llms.json again (after /init wrote them), keeping the session going. */
1158
+ const reloadConfig = () => {
1159
+ let cfg = loadConfig({ configPath: options.configPath, profile: options.profile })
1160
+ if (options.profile) cfg = selectProfile(cfg, options.profile)
1161
+ configRef.current = cfg
1162
+ setProfile(cfg.activeProfile)
1163
+ setModel(modelLabel(cfg))
1164
+ setupRef.current.key = ""
1165
+ contextInfo.current.key = ""
1166
+ closeBrain()
1167
+ bump()
1168
+ }
1169
+
1132
1170
  useEffect(() => {
1133
1171
  if (booted.current) return
1134
1172
  booted.current = true
@@ -1198,7 +1236,8 @@ export function App({ options }: { options: TuiOptions }) {
1198
1236
  if (!text) return
1199
1237
  // Setup notes come before the request, so they do not read as its answer; the split view shows them in its session panel.
1200
1238
  const notes = priceNotesRef.current.splice(0)
1201
- if (width < SPLIT_MIN) for (const w of notes) push("sys", `Price check: ${w}`, "chat")
1239
+ // Price notes stay out of the conversation: forgeqe models and the reports say what a run cost.
1240
+ void notes
1202
1241
  push("user", text)
1203
1242
  const cmd = parseCommand(text)
1204
1243
  if (cmd.type === "help") {
@@ -1240,6 +1279,17 @@ export function App({ options }: { options: TuiOptions }) {
1240
1279
  }
1241
1280
  return
1242
1281
  }
1282
+ if (cmd.type === "init") {
1283
+ // forgeqe init in this folder, then the session reads what it wrote: the start screen updates at once.
1284
+ try {
1285
+ const r = initWorkspace(process.cwd(), {})
1286
+ push("sys", [r.created.length ? `Created in ${shortCwd()}:` : "Nothing to create: this folder is set up.", ...r.created.map((f) => ` ✚ ${path.relative(process.cwd(), f) || f}`), ...r.notes].join("\n"), "chat")
1287
+ reloadConfig()
1288
+ } catch (err) {
1289
+ push("err", (err as Error).message, "chat")
1290
+ }
1291
+ return
1292
+ }
1243
1293
  if (cmd.type === "features") {
1244
1294
  if (configRef.current) push("sys", featuresText(configRef.current))
1245
1295
  else push("err", "Config is not loaded.")
@@ -1309,7 +1359,11 @@ export function App({ options }: { options: TuiOptions }) {
1309
1359
  ? "headed"
1310
1360
  : ""
1311
1361
  const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
1312
- const footerRight = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
1362
+ const footerVersion = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
1363
+ // In the conversation, the footer is the status line: the model, what the session used, and the version.
1364
+ const sessionTokens = meterSince(sessionMeter.current).total
1365
+ const usedTokens = sessionTokens.input + sessionTokens.output
1366
+ const footerRight = lines.length ? [configRef.current ? modelLabel(configRef.current) : model, usedTokens ? `${formatTokens(usedTokens)} tokens` : "", footerVersion].filter(Boolean).join(" · ") : footerVersion
1313
1367
  // Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
1314
1368
  const suggTrigger = triggerAt(promptText)
1315
1369
  const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
@@ -1336,8 +1390,10 @@ export function App({ options }: { options: TuiOptions }) {
1336
1390
 
1337
1391
  const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
1338
1392
  const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
1339
- // The split view's right pane shows tokens, time, and the last result, so the row under the prompt leaves them out.
1393
+ // One column by default, as in Claude Code; ctrl+o (or Panels) opens the plan, activity, files, context, and
1394
+ // tokens beside it on a wide terminal, or in its place on a narrow one.
1340
1395
  const wide = width >= SPLIT_MIN
1396
+ const panelsOpen = wide && !chatWide
1341
1397
  const openPalette = () => {
1342
1398
  paletteRef.current = { query: "", selected: 0 }
1343
1399
  setPalette(paletteRef.current)
@@ -1347,25 +1403,26 @@ export function App({ options }: { options: TuiOptions }) {
1347
1403
  // `drop`: the order they give way on a narrow row; Refine, Search, and Stop always stay.
1348
1404
  const refineButton: Button = { icon: "✦", label: "Refine", keys: "ctrl+r", color: theme.memory, onPress: () => refineRef.current(), active: Boolean(promptText.trim()) }
1349
1405
  const searchButton: Button = { icon: "⌕", label: "Search", keys: "ctrl+p", color: theme.accent, onPress: openPalette }
1350
- // The view: in the split view, Panels and Wide chat sit in the right pane's status line, where there is room;
1351
- // Split view comes back here when the chat is wide, and Details on a narrow terminal.
1406
+ // The view: with the panels open, moving into them and closing them sit in their status line; otherwise Panels
1407
+ // (Details on a narrow terminal) opens them.
1352
1408
  const toggleWide = () => setChatWide((w) => !w)
1353
1409
  const paneButtons: Button[] = [
1354
- { icon: "▦", label: "Panels", keys: "shift+tab", color: theme.thought, onPress: focusPanels, drop: 2 },
1355
- { icon: "⤢", label: "Wide chat", color: theme.muted, onPress: toggleWide, drop: 1 },
1410
+ { icon: "▦", label: "Focus panels", keys: "shift+tab", color: theme.thought, onPress: focusPanels, drop: 2 },
1411
+ { icon: "✕", label: "Close", keys: "ctrl+o", color: theme.muted, onPress: toggleWide, drop: 1 },
1356
1412
  ]
1357
1413
  const viewButtons: Button[] = wide
1358
1414
  ? chatWide
1359
- ? [{ icon: "◧", label: "Split view", color: theme.thought, onPress: toggleWide, drop: 2 }]
1415
+ ? [{ icon: "▦", label: "Panels", keys: "ctrl+o", color: theme.thought, onPress: toggleWide, drop: 2 }]
1360
1416
  : []
1361
1417
  : [{ icon: "◧", label: "Details", keys: "ctrl+o", color: theme.thought, onPress: () => setNarrowView("details"), drop: 1 }]
1362
1418
  const idleButtons: Button[] = [refineButton, searchButton, ...viewButtons, ...(wide ? [] : [{ icon: "⇄", label: "Profile", keys: "tab", color: theme.muted, onPress: cycleProfile, drop: 4 }])]
1363
1419
  const runningButtons: Button[] = [{ icon: "■", label: "Stop", keys: "esc", color: theme.warn, onPress: () => openDialog("stop") }, searchButton, ...viewButtons]
1364
1420
  const shortcutsHint: Hint = { key: "?", label: "shortcuts", onPress: () => setShortcuts(true) }
1365
1421
  // ? is offered where there is room for it: the start screen of a terminal 110 columns or wider; it works everywhere.
1366
- const idleHints: Hint[] = [...copiedHint, ...(lastStats && !wide && !copied ? [{ key: `last run ${lastStats}`, label: "" }] : []), ...(home && width >= 110 ? [shortcutsHint] : []), closeHint]
1422
+ const idleHints: Hint[] = [...copiedHint, ...(home && width >= 110 ? [shortcutsHint] : []), closeHint]
1367
1423
  // The one-pane view has no tokens panel, so the tokens and time show here.
1368
- const runningHints: Hint[] = [...copiedHint, ...(wide || copied ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" }]), closeHint]
1424
+ // While a request runs, the line above the prompt says what is happening, for how long, and with how many tokens.
1425
+ const runningHints: Hint[] = [...copiedHint, closeHint]
1369
1426
  const runningLeft = (
1370
1427
  <box style={{ flexDirection: "row", marginRight: 1 }}>
1371
1428
  <Spinner kind="bar" />
@@ -1454,8 +1511,8 @@ export function App({ options }: { options: TuiOptions }) {
1454
1511
  model={model}
1455
1512
  right={right}
1456
1513
  rightColor={rightColor}
1457
- compact={width >= SPLIT_MIN && !home}
1458
- focused={!palette && !refining && (nav.focus === "chat" || (width < SPLIT_MIN && narrowView === "chat"))}
1514
+ compact={!home}
1515
+ focused={!palette && !refining && (panelsOpen ? nav.focus === "chat" : wide || narrowView === "chat")}
1459
1516
  initialValue={draft}
1460
1517
  onText={(t) => {
1461
1518
  promptTextRef.current = t
@@ -1479,24 +1536,37 @@ export function App({ options }: { options: TuiOptions }) {
1479
1536
  const cfgHome = configRef.current
1480
1537
  const setupKey = cfgHome ? `${cfgHome.configPath}|${cfgHome.activeProfile}` : ""
1481
1538
  if (cfgHome && setupRef.current.key !== setupKey) {
1539
+ const items = setupChecklist(cfgHome)
1540
+ const recent = recentWork(path.resolve(cfgHome.export.outDir))
1541
+ let signIns: Record<string, string> = {}
1542
+ try {
1543
+ // "Azure DevOps (PAT)": how each system's server signs in, as the session panel says it.
1544
+ signIns = Object.fromEntries(externalMcp(cfgHome).servers.filter((x) => x.signIn).map((x) => [x.label.replace(/ MCP$/, ""), x.signIn!.startsWith("PAT") ? "PAT" : "az login"]))
1545
+ } catch {}
1482
1546
  setupRef.current = {
1483
1547
  key: setupKey,
1484
- items: setupChecklist(cfgHome),
1485
- start: startActions(cfgHome),
1486
- recent: recentWork(path.resolve(cfgHome.export.outDir)),
1548
+ items,
1549
+ start: startActions(cfgHome, 4, undefined, recent.some((r) => r.kind === "run")),
1550
+ recent,
1551
+ first: getStarted(cfgHome, items),
1552
+ facts: projectLine(cfgHome, process.cwd(), { model: modelLabel(cfgHome), signIns }),
1487
1553
  }
1488
1554
  }
1489
- const { items: setup, start, recent } = setupRef.current
1555
+ const { items: setup, start, recent, first, facts } = setupRef.current
1490
1556
  const open = setup.filter((i) => !i.done)
1491
1557
  // Each missing item with how to set it up, in the chat; the first that a command sets up (/auth) is offered last.
1492
1558
  const explainSetup = (items: SetupItem[]) => push("sys", items.map((i) => `○ ${i.fix}${i.command ? ` Type ${i.command} to start.` : ""}`).join("\n"), "chat")
1493
- const facts = projectLine(cfgHome, process.cwd(), cfgHome ? modelLabel(cfgHome) : "")
1494
1559
 
1495
1560
  // Rows: the logo, the project line, and the prompt always; then Start with, Continue, and Setup while they fit.
1496
1561
  // A long request takes rows from them, last section first, so the box never scrolls while there is room.
1497
1562
  const loading = !cfgHome
1498
1563
  // Each section is a blank row and then its own rows: a heading and its items, or the setup line.
1499
- const startRows = loading ? 1 + 1 + 4 : start.length ? 1 + 1 + start.length : 0
1564
+ // Get started takes Start with's place while there is nothing to start with: no model, or no forgeqe.yaml.
1565
+ const offer: { id: string; label: string; detail: string; run: () => void }[] = start.length
1566
+ ? start.map((a) => ({ id: a.id, label: a.label, detail: a.detail, run: () => fillPrompt(a.request) }))
1567
+ : first.map((g) => ({ id: g.id, label: g.label, detail: g.detail, run: () => (g.command ? submitRef.current(g.command) : g.fix && push("sys", g.fix, "chat")) }))
1568
+ const offerTitle = start.length ? "Start with" : "Get started"
1569
+ const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
1500
1570
  const recentRows = recent.length ? 1 + 1 + recent.length : 0
1501
1571
  const setupRows = loading || open.length ? 2 : 0
1502
1572
  const fixed = 5 + 2 + 1 + 5 + 1 + 1 + 1 // logo, project line, gap, prompt chrome, buttons, footer, one row to spare
@@ -1521,8 +1591,8 @@ export function App({ options }: { options: TuiOptions }) {
1521
1591
  <text>
1522
1592
  {facts.length ? (
1523
1593
  facts.map((f, i) => (
1524
- <span key={i} fg={i === 0 ? theme.text : theme.muted}>
1525
- {`${i ? " · " : ""}${f}`}
1594
+ <span key={i} fg={f.tone === "name" ? theme.text : f.tone === "warn" ? theme.warn : theme.muted}>
1595
+ {`${i ? " · " : ""}${f.text}`}
1526
1596
  </span>
1527
1597
  ))
1528
1598
  ) : (
@@ -1535,11 +1605,11 @@ export function App({ options }: { options: TuiOptions }) {
1535
1605
  {renderControls(promptWidth, "Describe what you want to test…", [5, maxRows])}
1536
1606
  {showStart ? (
1537
1607
  <box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
1538
- {heading("Start with")}
1608
+ {heading(offerTitle)}
1539
1609
  {loading
1540
1610
  ? [0, 1, 2, 3].map((i) => <text key={i} fg={theme.faint}>{i ? " " : " Loading what works in this setup…"}</text>)
1541
- : start.map((a, i) => (
1542
- <box key={a.id} onMouseDown={() => fillPrompt(a.request)} style={{ flexDirection: "row", justifyContent: "space-between", flexShrink: 0 }}>
1611
+ : offer.map((a, i) => (
1612
+ <box key={a.id} onMouseDown={a.run} style={{ flexDirection: "row", justifyContent: "space-between", flexShrink: 0 }}>
1543
1613
  <text>
1544
1614
  <span fg={theme.accent}>{" ▸ "}</span>
1545
1615
  <span fg={theme.text}>{a.label.padEnd(32)}</span>
@@ -1640,9 +1710,7 @@ export function App({ options }: { options: TuiOptions }) {
1640
1710
  ? coding
1641
1711
  ? "The coding agent is writing a test · esc to stop"
1642
1712
  : `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
1643
- : width >= SPLIT_MIN
1644
- ? "Ask ForgeQE · / commands · @ references"
1645
- : "Ask ForgeQE: a goal, test cases, a test, a pipeline… (/ commands, @ references)"
1713
+ : "Ask ForgeQE… (/ for commands, @ to reference a run, file, or work item)"
1646
1714
  const chatLines = lines.filter((l) => l.pane !== "activity")
1647
1715
  const activityLines = lines.filter((l) => l.pane === "activity")
1648
1716
  const stop = () => openDialog("stop")
@@ -1684,13 +1752,17 @@ export function App({ options }: { options: TuiOptions }) {
1684
1752
 
1685
1753
  const transcript = (all: Line[], style: Record<string, unknown>) => (
1686
1754
  <scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
1687
- {all.map((line) => (
1688
- <TranscriptLine key={line.id} line={line} />
1755
+ {all.map((line, i) => (
1756
+ <TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
1689
1757
  ))}
1690
- {running && (!split || chatWide) ? (
1691
- <box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
1692
- <Spinner kind="dots" />
1693
- <text fg={theme.text}>{` ${activity || "Working…"}`}</text>
1758
+ {running && !panelsOpen ? (
1759
+ // What is happening now, for how long, and with how many tokens, as one line under the conversation.
1760
+ <box style={{ flexDirection: "row", marginTop: 1, flexShrink: 0 }}>
1761
+ <Spinner kind="dots" color={theme.memory} />
1762
+ <text>
1763
+ <span fg={theme.memory}>{` ${activity || "Working…"}`}</span>
1764
+ <span fg={theme.faint}>{` (${elapsed}${tokens ? ` · ${formatTokens(tokens)} tokens` : ""} · esc to interrupt)`}</span>
1765
+ </text>
1694
1766
  </box>
1695
1767
  ) : null}
1696
1768
  </scrollbox>
@@ -1698,24 +1770,20 @@ export function App({ options }: { options: TuiOptions }) {
1698
1770
  // A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
1699
1771
  const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.floor(height * 0.45))])
1700
1772
 
1701
- const split = width >= SPLIT_MIN
1773
+ const split = panelsOpen
1702
1774
  if (split) {
1703
- // Wide chat: the conversation across the screen for reading, the panels hidden until Split view.
1704
- const leftWidth = chatWide ? width : Math.floor(width * 0.4)
1775
+ const leftWidth = Math.floor(width * 0.4)
1705
1776
  return (
1706
1777
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1707
1778
  <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1708
- <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 }}>
1709
- <SessionPanel info={sessionInfo} width={leftWidth - (chatWide ? 4 : 3)} />
1710
- {/* Wide chat shows what the agents do in the conversation, as the one-pane view does. */}
1711
- {transcript(chatWide ? lines : chatLines, {})}
1712
- <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - (chatWide ? 4 : 3))}</box>
1779
+ <box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1780
+ <SessionPanel info={sessionInfo} width={leftWidth - 3} />
1781
+ {transcript(chatLines, {})}
1782
+ <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1783
+ </box>
1784
+ <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1785
+ {details(width - leftWidth - 3, height - 2)}
1713
1786
  </box>
1714
- {chatWide ? null : (
1715
- <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1716
- {details(width - leftWidth - 3, height - 2)}
1717
- </box>
1718
- )}
1719
1787
  </box>
1720
1788
  <Footer left={shortCwd()} right={footerRight} width={width} />
1721
1789
  {paletteOverlay}
@@ -1724,7 +1792,7 @@ export function App({ options }: { options: TuiOptions }) {
1724
1792
  }
1725
1793
 
1726
1794
  const promptWidth = Math.max(40, width - 4)
1727
- if (narrowView === "details") {
1795
+ if (!wide && narrowView === "details") {
1728
1796
  return (
1729
1797
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1730
1798
  <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>{details(promptWidth, height - 3 - (dialog ? 8 : 0))}</box>