@capagents/forgeqe 1.0.1 → 1.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "ForgeQE: a browser agent CLI that runs goals and test cases in Chrome and turns passing runs into tests in your repo",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -674,7 +674,8 @@ export async function openSession<R = AgentReport>(opts: {
674
674
  const line = toolLine(opts.repo, part.tool, input, part.state.title)
675
675
  const ok = part.state.status === "completed"
676
676
  const file = toolFile(opts.repo, part.tool, input)
677
- emit({ type: "code", kind: "tool", ok, tool: part.tool, ...(file ? { file } : {}), text: ok ? line : `${line}: ${clip(toolError(String(part.state.error || "")) || "failed", 120)}` })
677
+ const skill = part.tool === "skill" && ok ? String(input.name || "") : ""
678
+ emit({ type: "code", kind: "tool", ok, tool: part.tool, ...(file ? { file } : {}), ...(skill ? { skill } : {}), text: ok ? line : `${line}: ${clip(toolError(String(part.state.error || "")) || "failed", 120)}` })
678
679
  } else if (part?.type === "text" && part.time?.end && assistant.has(part.messageID) && !said.has(part.id)) {
679
680
  said.add(part.id)
680
681
  const text = String(part.text || "").trim()
@@ -15,6 +15,8 @@ export type ExternalServer = {
15
15
  config: Record<string, unknown>
16
16
  /** Values ForgeQE derives from its environment (e.g. base64 of email:token) for {env:} references in config. */
17
17
  secrets: Record<string, string>
18
+ /** How the server signs in, in a few words, for the shell and the agent: "PAT in ADO_PAT", "Azure CLI sign-in". */
19
+ signIn?: string
18
20
  }
19
21
 
20
22
  export type ExternalPlan = { servers: ExternalServer[]; skipped: { label: string; why: string }[] }
@@ -64,11 +66,16 @@ export function externalMcp(cfg: AppConfig, which: Which = Bun.which, env: NodeJ
64
66
  else if (!which("npx")) skipped.push({ label, why: "it needs Node.js (npx)" })
65
67
  else if (!auth) skipped.push({ label, why: `set ${ado.tokenEnv}, or sign in with az login` })
66
68
  else {
69
+ const signIn = auth === "pat" ? `PAT in ${ado.tokenEnv}` : "Azure CLI sign-in"
67
70
  servers.push({
68
71
  name: "azure_devops",
69
72
  label,
73
+ signIn,
70
74
  about:
71
75
  `Microsoft's Azure DevOps MCP server: organization ${org}${ado.project ? `, project ${ado.project}` : ", every project"} (work items, boards, test plans, pipelines, repos, wiki pages, search across code, wikis and work items, security alerts). ` +
76
+ (auth === "pat"
77
+ ? `It signs in with the personal access token in ${ado.tokenEnv}. `
78
+ : `${ado.tokenEnv} is not set, so it signs in as the Azure CLI's account (az login). When it answers TF400813 or "not authorized", that account has no access to ${org}: say so, and that setting ${ado.tokenEnv} (in .env, or /auth azure_devops with a token) or adding that account to the organization fixes it. `) +
72
79
  "Always pass project to its tools that take one: without it the server asks the client to pick a project, which this session cannot answer, so the call fails. " +
73
80
  "Its wiki tools only work in a wiki that exists and cannot create one; write wiki pages with ForgeQE's write_ado_wiki_page, which creates the project wiki when there is none. " +
74
81
  (ado.project
package/src/tui/App.tsx CHANGED
@@ -28,6 +28,29 @@ import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLi
28
28
  import { Dialog, dialogSpec } from "./dialogs.ts"
29
29
  import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
30
30
  import { featuresText } from "../capabilities/render.ts"
31
+ import { CAPABILITIES } from "../capabilities/registry.ts"
32
+ import { CONTEXT_LIMIT } from "../code/runtime.ts"
33
+ import { readKnowledge } from "../knowledge.ts"
34
+ import { externalMcp } from "../mcp/external.ts"
35
+ import { meterSince } from "../usage.ts"
36
+ import { ActivityPanel, ContextPanel, FilesPanel, PlanPanel, planRows, SessionPanel, StatusBar, TokensPanel, type ContextInfo, type SessionInfo } from "./inspector.tsx"
37
+ import {
38
+ addSkill,
39
+ caseDone,
40
+ caseStarted,
41
+ newInspector,
42
+ newRequest,
43
+ paneFor,
44
+ progressDone,
45
+ setContext,
46
+ setPlan,
47
+ setWho,
48
+ startProgress,
49
+ stepOutcome,
50
+ stepStarted,
51
+ useFile,
52
+ type Pane,
53
+ } from "./session.ts"
31
54
 
32
55
  export type TuiOptions = {
33
56
  configPath?: string
@@ -54,11 +77,14 @@ export type TuiOptions = {
54
77
  }
55
78
 
56
79
  const VERSION = packageVersion()
80
+ /** Narrower than this, the split view's right pane would not fit: one pane, and ctrl+o switches. */
81
+ export const SPLIT_MIN = 120
57
82
 
58
83
  export function App({ options }: { options: TuiOptions }) {
59
84
  const renderer = useRenderer()
60
85
  const { width, height } = useTerminalDimensions()
61
86
  const scrollRef = useRef<ScrollBoxRenderable>(null)
87
+ const activityScroll = useRef<ScrollBoxRenderable>(null)
62
88
  const submitRef = useRef<(value: string) => void>(() => {})
63
89
  const configRef = useRef<AppConfig | null>(null)
64
90
  const abortRef = useRef<AbortController | null>(null)
@@ -79,6 +105,19 @@ export function App({ options }: { options: TuiOptions }) {
79
105
  const testsRef = useRef<TestsSetting>({ on: false, why: "" })
80
106
  const codingRef = useRef(false)
81
107
  const codeRunDir = useRef("")
108
+ /** The right pane's live state: plan, steps or cases, files, context. */
109
+ const inspRef = useRef(newInspector())
110
+ /** Set while a typed line is handled, so the shell's answers to it go to the chat. */
111
+ const answeringRef = useRef(false)
112
+ const [, setRev] = useState(0)
113
+ const bump = () => setRev((n) => n + 1)
114
+ /** Usage since the shell opened, for the session's token and cost totals. */
115
+ const sessionMeter = useRef(meterSnapshot())
116
+ const sessionStarted = useRef<number | undefined>(undefined)
117
+ const requests = useRef(0)
118
+ /** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
119
+ const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
120
+ const contextInfo = useRef<{ key: string; knowledge: number; mcp: string[] }>({ key: "", knowledge: 0, mcp: [] })
82
121
 
83
122
  const [lines, setLines] = useState<Line[]>([])
84
123
  const [headed, setHeaded] = useState(Boolean(options.headed))
@@ -123,15 +162,29 @@ export function App({ options }: { options: TuiOptions }) {
123
162
  choiceRef.current = choice
124
163
  thinkingRef.current = thinking
125
164
 
126
- const push = (kind: Line["kind"], text: string) => {
165
+ /** Lines go to the chat, or while a request runs and they are not results, to the activity pane; `pane` overrides that. */
166
+ const push = (kind: Line["kind"], text: string, pane?: Pane) => {
127
167
  const parts = kind === "user" || kind === "reply" ? [text] : text.split("\n")
168
+ const where = pane || (answeringRef.current ? "chat" : paneFor(kind, abortRef.current !== null))
128
169
  setLines((prev) => {
129
170
  const next = [...prev]
130
- for (const part of parts) next.push({ id: idRef.current++, kind, text: part })
131
- return next.slice(-300)
171
+ for (const part of parts) next.push({ id: idRef.current++, kind, text: part, pane: where })
172
+ return next.slice(-600)
132
173
  })
133
174
  }
134
- const pushLine = (line: Omit<Line, "id">) => setLines((prev) => [...prev, { ...line, id: idRef.current++ }].slice(-300))
175
+ const pushLine = (line: Omit<Line, "id">) =>
176
+ setLines((prev) => [...prev, { ...line, pane: line.pane || paneFor(line.kind, abortRef.current !== null), id: idRef.current++ }].slice(-600))
177
+ /** What an agent's event tells the right pane: a file it used, a skill it loaded, its plan, how full its context is. */
178
+ const inspect = (e: CodeEvent) => {
179
+ const s = inspRef.current
180
+ if (e.kind === "tool") {
181
+ if (e.file) useFile(s, e.file)
182
+ if (e.skill) addSkill(s, e.skill)
183
+ } else if (e.kind === "plan") setPlan(s, e.items)
184
+ else if (e.kind === "usage") setContext(s, e.context || 0)
185
+ else return
186
+ bump()
187
+ }
135
188
  /** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
136
189
  const pushTool = (text: string, ok: boolean) => push(!ok ? "err" : /^(Writing|Editing) /.test(text) ? "file" : "tool", text)
137
190
  const pushProduced = () => {
@@ -374,7 +427,8 @@ export function App({ options }: { options: TuiOptions }) {
374
427
  return
375
428
  }
376
429
  if (key.name === "tab" && !key.shift) cycleProfile()
377
- if (key.name === "p" && key.ctrl) push("sys", HELP)
430
+ if (key.name === "p" && key.ctrl) push("sys", HELP, "chat")
431
+ if (key.name === "o" && key.ctrl && width < SPLIT_MIN) setNarrowView((v) => (v === "chat" ? "details" : "chat"))
378
432
  if (key.name === "escape" && abortRef.current) openDialog("stop")
379
433
  })
380
434
 
@@ -396,6 +450,9 @@ export function App({ options }: { options: TuiOptions }) {
396
450
  replayingRef.current = false
397
451
  setStep(0)
398
452
  setBrowsing(true)
453
+ startProgress(inspRef.current, `Browser · ${clip(e.goal, 40)}`)
454
+ setWho(inspRef.current, "Browser agent")
455
+ bump()
399
456
  if (agent) push("code", `Browser: ${clip(e.goal, 110)}`)
400
457
  if (!aborted()) setActivity(cfg?.llm.provider === "mock" ? "Running the offline demo…" : "Starting the browser…")
401
458
  return
@@ -417,6 +474,10 @@ export function App({ options }: { options: TuiOptions }) {
417
474
  if (shows(event, detailRef.current)) push(event.level === "error" ? "err" : "sys", event.text)
418
475
  if (!aborted()) setActivity(activityFromLog(event.text))
419
476
  }
477
+ if (event.type === "thought") {
478
+ stepStarted(inspRef.current, `Step ${event.step} · ${clip(event.text.replace(/\s+/g, " "), 90)}${replayingRef.current ? " (memory)" : ""}`)
479
+ bump()
480
+ }
420
481
  if (event.type === "thought" && !replayingRef.current) {
421
482
  if (shows(event, detailRef.current)) push("thought", stepIntent(event.step, event.text, detailRef.current))
422
483
  if (!aborted()) setActivity(`Acting in the browser (step ${stepRef.current})…`)
@@ -425,7 +486,13 @@ export function App({ options }: { options: TuiOptions }) {
425
486
  markRef.current = Date.now()
426
487
  if (!aborted()) setActivity(replayingRef.current ? "Replaying from memory…" : `Thinking about step ${stepRef.current + 1}…`)
427
488
  }
428
- if (event.type === "outcome") push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
489
+ if (event.type === "outcome") {
490
+ push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
491
+ // The action itself, without the result after a colon or the locator in brackets: "click "Search"".
492
+ const action = event.text.split(/: | {2}\[/)[0]!.trim()
493
+ stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(event.text, 80), clip(action, 60))
494
+ bump()
495
+ }
429
496
  return
430
497
  }
431
498
  case "run-done": {
@@ -450,17 +517,25 @@ export function App({ options }: { options: TuiOptions }) {
450
517
  producedRef.current.runs.push({ runDir: e.runDir, status: r?.status || "fail", files: e.files })
451
518
  setLastStats(summary.stats)
452
519
  setBrowsing(false)
520
+ progressDone(inspRef.current, aborted())
521
+ setWho(inspRef.current, codingRef.current ? "Scribe" : "ForgeQE's agent")
522
+ bump()
453
523
  if (agent && !aborted()) setActivity("The agent is reading the result…")
454
524
  return
455
525
  }
456
526
  case "suite-start":
457
527
  push("sys", `${e.cases.length} test case${e.cases.length === 1 ? "" : "s"}${e.parallel > 1 ? `, ${e.parallel} at a time` : ""}`)
458
528
  setSuiteProgress({ done: 0, total: e.cases.length })
529
+ startProgress(inspRef.current, `${e.cases.length} test case${e.cases.length === 1 ? "" : "s"}`)
530
+ bump()
459
531
  return
460
532
  case "suite-event": {
461
533
  const s = e.event
462
534
  const tag = (id: string) => ((configRef.current?.suite.parallel || 1) > 1 ? `[${id}] ` : "")
463
535
  if (s.type === "case-start") {
536
+ caseStarted(inspRef.current, s.case.id, s.case.title)
537
+ setWho(inspRef.current, "Browser agent")
538
+ bump()
464
539
  push("code", `${s.index + 1}/${s.total} ${s.case.id} ${s.case.title}`)
465
540
  // What was read from the test management system: where the case lives and the steps the browser will follow.
466
541
  const steps = s.case.steps
@@ -476,6 +551,8 @@ export function App({ options }: { options: TuiOptions }) {
476
551
  } else if (s.type === "case-done") {
477
552
  setSuiteProgress((p) => (p ? { ...p, done: p.done + 1 } : p))
478
553
  const r = s.result
554
+ caseDone(inspRef.current, r.case.id, r.status, r.failure ? FAILURE_LABEL[r.failure.type] : undefined)
555
+ bump()
479
556
  push(r.status === "passed" ? "ok" : "err", `${r.case.id} ${r.status} in ${formatDuration(r.ms)}${r.failure ? ` · ${FAILURE_LABEL[r.failure.type]}` : ""}`)
480
557
  if (r.failure) push("sys", ` ${tag(r.case.id)}${clip(r.failure.reason, 200)} Next: ${FAILURE_ACTION[r.failure.type]}.`)
481
558
  // Each step of the case with its verdict, and what the page showed for a step that did not pass.
@@ -508,6 +585,8 @@ export function App({ options }: { options: TuiOptions }) {
508
585
  return
509
586
  case "agent": {
510
587
  const ev = e.event
588
+ inspect(ev)
589
+ if (ev.kind === "plan") return
511
590
  if (ev.kind === "usage") useTokens(`agent-tool-${runIndex.current}`, ev.usage.input + ev.usage.output)
512
591
  // Hidden lines (reads, searches, the agent's narration) still show on the activity line below.
513
592
  else if (shows(ev, detailRef.current)) {
@@ -527,6 +606,8 @@ export function App({ options }: { options: TuiOptions }) {
527
606
  codingRef.current = true
528
607
  codeRunDir.current = e.runDir
529
608
  setCoding(true)
609
+ setWho(inspRef.current, "Scribe")
610
+ bump()
530
611
  const show = shortPath
531
612
  pushLine({
532
613
  kind: "card",
@@ -550,6 +631,8 @@ export function App({ options }: { options: TuiOptions }) {
550
631
  case "code-done":
551
632
  codingRef.current = false
552
633
  setCoding(false)
634
+ setWho(inspRef.current, "ForgeQE's agent")
635
+ bump()
553
636
  producedRef.current.tests.push({ runDir: codeRunDir.current, result: e.result, error: e.error })
554
637
  if (e.error) push("err", `Coding agent: ${e.error}`)
555
638
  if (agent && !aborted()) setActivity("The agent is reading the result…")
@@ -561,6 +644,8 @@ export function App({ options }: { options: TuiOptions }) {
561
644
  }
562
645
 
563
646
  const onBrainEvent = (e: CodeEvent) => {
647
+ inspect(e)
648
+ if (e.kind === "plan") return
564
649
  if (e.kind === "usage") {
565
650
  useTokens("agent", e.usage.input + e.usage.output)
566
651
  return
@@ -600,6 +685,10 @@ export function App({ options }: { options: TuiOptions }) {
600
685
  started.current = Date.now()
601
686
  usedRef.current = new Map()
602
687
  producedRef.current = emptyProduced()
688
+ newRequest(inspRef.current)
689
+ requests.current++
690
+ sessionStarted.current ??= Date.now()
691
+ bump()
603
692
  setTokens(0)
604
693
  setStatus("running")
605
694
  setActivity(activityText)
@@ -607,6 +696,8 @@ export function App({ options }: { options: TuiOptions }) {
607
696
  }
608
697
 
609
698
  const finish = (finalStatus: string) => {
699
+ progressDone(inspRef.current, finalStatus === "stopped")
700
+ bump()
610
701
  setStatus(finalStatus)
611
702
  setActivity("")
612
703
  setThinking(false)
@@ -671,21 +762,21 @@ export function App({ options }: { options: TuiOptions }) {
671
762
  controller.signal.removeEventListener("abort", onAbort)
672
763
  if (answer.outcome !== "stopped") convoRef.current.push({ user: text, reply: answer.reply })
673
764
  if (answer.outcome === "stopped") {
674
- push("sys", "Stopped.")
765
+ push("sys", "Stopped.", "chat")
675
766
  finalStatus = "stopped"
676
767
  } else {
677
768
  if (answer.reply) pushLine({ kind: "reply", text: answer.reply, outcome: answer.outcome })
678
- if (answer.error && !answer.reply) push("err", answer.error)
769
+ if (answer.error && !answer.reply) push("err", answer.error, "chat")
679
770
  finalStatus = answer.outcome === "done" ? "done" : answer.outcome === "needs_input" ? "idle" : "fail"
680
771
  }
681
772
  } catch (err) {
682
- push("err", (err as Error).message)
773
+ push("err", (err as Error).message, "chat")
683
774
  closeBrain()
684
775
  } finally {
685
776
  pushProduced()
686
777
  // What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
687
778
  const spent = requestLine(meterStart)
688
- if (spent) push("sys", spent)
779
+ if (spent) push("sys", spent, "chat")
689
780
  finish(finalStatus)
690
781
  }
691
782
  }
@@ -737,6 +828,15 @@ export function App({ options }: { options: TuiOptions }) {
737
828
  }, [])
738
829
 
739
830
  const onSubmit = (value: string) => {
831
+ // What a command answers (/help, /skills, "busy") is for the chat, even while a request runs.
832
+ answeringRef.current = true
833
+ try {
834
+ submit(value)
835
+ } finally {
836
+ answeringRef.current = false
837
+ }
838
+ }
839
+ const submit = (value: string) => {
740
840
  const text = value.replace(/\t/g, "").trim()
741
841
  setInputKey((k) => k + 1)
742
842
  const pending = entryRef.current
@@ -748,8 +848,10 @@ export function App({ options }: { options: TuiOptions }) {
748
848
  return
749
849
  }
750
850
  if (!text) return
851
+ // Setup notes come before the request, so they do not read as its answer; the split view shows them in its session panel.
852
+ const notes = priceNotesRef.current.splice(0)
853
+ if (width < SPLIT_MIN) for (const w of notes) push("sys", `Price check: ${w}`, "chat")
751
854
  push("user", text)
752
- for (const w of priceNotesRef.current.splice(0)) push("sys", `Price check: ${w}`)
753
855
  const cmd = parseCommand(text)
754
856
  if (cmd.type === "help") {
755
857
  push("sys", HELP)
@@ -864,17 +966,24 @@ export function App({ options }: { options: TuiOptions }) {
864
966
 
865
967
  const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
866
968
  const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
969
+ // The split view's right pane shows tokens, time, and the last result, so the hints under the prompt leave them out.
970
+ const wide = width >= SPLIT_MIN
971
+ const viewHint: Hint[] = wide ? [] : [{ key: "ctrl+o", label: "details", onPress: () => setNarrowView("details") }]
972
+ // While running, the row also holds the spinner and esc stop: below 95 columns the view hint does not fit beside them.
973
+ const runningViewHint: Hint[] = width >= 95 ? viewHint : []
867
974
  const idleHints: Hint[] = [
868
975
  ...copiedHint,
869
- ...(lastStats ? [{ key: `last run ${lastStats}`, label: "" }] : []),
976
+ ...(lastStats && !wide ? [{ key: `last run ${lastStats}`, label: "" }] : []),
977
+ ...viewHint,
870
978
  { key: "tab", label: "profiles" },
871
979
  { key: "ctrl+p", label: "commands" },
872
980
  closeHint,
873
981
  ]
874
982
  const runningHints: Hint[] = [
875
983
  ...copiedHint,
876
- { key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" },
877
- { key: "ctrl+p", label: "commands" },
984
+ ...(wide ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" }, ...runningViewHint]),
985
+ // Beside the 40% pane's spinner and esc stop, only close fits from 120 columns.
986
+ ...(wide ? [] : [{ key: "ctrl+p", label: "commands" }]),
878
987
  closeHint,
879
988
  ]
880
989
  const runningLeft = (
@@ -920,6 +1029,7 @@ export function App({ options }: { options: TuiOptions }) {
920
1029
  model={model}
921
1030
  right={right}
922
1031
  rightColor={rightColor}
1032
+ compact={width >= SPLIT_MIN && !home}
923
1033
  />
924
1034
  <Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
925
1035
  </>
@@ -946,37 +1056,131 @@ export function App({ options }: { options: TuiOptions }) {
946
1056
  )
947
1057
  }
948
1058
 
1059
+ // What the split view shows about the session and the request in progress.
1060
+ const cfgNow = configRef.current
1061
+ const insp = inspRef.current
1062
+ const spent = meterSince(sessionMeter.current)
1063
+ const contextKey = cfgNow ? `${cfgNow.configPath}|${cfgNow.activeProfile}|${cfgNow.knowledge?.dir}` : ""
1064
+ if (cfgNow && contextInfo.current.key !== contextKey) {
1065
+ let mcp: string[] = []
1066
+ try {
1067
+ // "Azure DevOps (az login)": which account a server works as decides what it may see, so the panel says it.
1068
+ mcp = externalMcp(cfgNow).servers.map((x) => (x.signIn ? `${x.label.replace(/ MCP$/, "")} (${x.signIn.startsWith("PAT") ? "PAT" : "az login"})` : x.label))
1069
+ } catch {}
1070
+ contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
1071
+ }
1072
+ const llm = cfgNow?.llm
1073
+ const sessionInfo: SessionInfo = {
1074
+ profile,
1075
+ model: model || (cfgNow ? modelLabel(cfgNow) : ""),
1076
+ auth: !llm ? "–" : llm.auth === "entra" ? "Entra ID, no key" : llm.apiKeyEnv ? `API key in ${llm.apiKeyEnv}` : llm.provider === "mock" ? "offline demo" : llm.provider,
1077
+ cwd: shortCwd(),
1078
+ tests: testsRef.current.on ? "written to this folder" : requests.current ? `not written (${testsRef.current.why || "off"})` : "set on the first request",
1079
+ browser: `${headed ? "visible" : "headless"} · up to ${maxSteps} steps${options.url ? ` · ${hostOf(options.url)}` : ""}`,
1080
+ ...(cfgNow?.domain ? { domain: `${cfgNow.domain.industry}${cfgNow.domain.regulations.length ? ` (${cfgNow.domain.regulations.join(", ")})` : ""}` } : {}),
1081
+ ...(llm?.priceWarning && llm.price ? { price: `${llm.price.input}/${llm.price.output} ${llm.price.currency} per M in/out from llms.json, not the list price` } : {}),
1082
+ started: sessionStarted.current,
1083
+ requests: requests.current,
1084
+ total: spent.roles.length ? { usage: spent.total, cost: spent.cost } : undefined,
1085
+ }
1086
+ const contextPanel: ContextInfo = {
1087
+ tokens: insp.context,
1088
+ limit: CONTEXT_LIMIT,
1089
+ skills: insp.skills,
1090
+ knowledge: contextInfo.current.knowledge,
1091
+ ...(cfgNow?.domain ? { domain: cfgNow.domain.industry } : {}),
1092
+ mcp: contextInfo.current.mcp,
1093
+ capabilities: CAPABILITIES.length,
1094
+ }
1095
+ const placeholder = running
1096
+ ? coding
1097
+ ? "The coding agent is writing a test · esc to stop"
1098
+ : `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
1099
+ : "Ask ForgeQE: a goal, test cases, a test, a pipeline…"
1100
+ const chatLines = lines.filter((l) => l.pane !== "activity")
1101
+ const activityLines = lines.filter((l) => l.pane === "activity")
1102
+ const stop = () => openDialog("stop")
1103
+
1104
+ /**
1105
+ * The right pane: the status bar, then bordered panels for the plan, the activity, and files, context, and tokens.
1106
+ * Rows are counted, not left to flex: the activity gets what the others leave, so nothing is pushed off screen.
1107
+ */
1108
+ const details = (paneWidth: number, rowsAvailable: number) => {
1109
+ const bottom = 6 + 2 // the context panel's six rows, and the border
1110
+ const plan = planRows(insp, Math.max(3, Math.min(8, Math.floor(rowsAvailable / 5))))
1111
+ const activityRows = Math.max(5, rowsAvailable - 2 - plan - bottom)
1112
+ const third = Math.floor(paneWidth / 3)
1113
+ return (
1114
+ <box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
1115
+ <StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} />
1116
+ <box style={{ height: 1, flexShrink: 0 }} />
1117
+ {plan ? <PlanPanel s={insp} width={paneWidth} height={plan} /> : null}
1118
+ <ActivityPanel lines={activityLines} scrollRef={activityScroll} width={paneWidth} height={activityRows} />
1119
+ <box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
1120
+ <FilesPanel s={insp} width={third} height={bottom} />
1121
+ <ContextPanel c={contextPanel} width={third} height={bottom} />
1122
+ <TokensPanel roles={spent.roles} total={{ usage: spent.total, cost: spent.cost }} live={running ? tokens : 0} width={paneWidth - 2 * third} height={bottom} />
1123
+ </box>
1124
+ </box>
1125
+ )
1126
+ }
1127
+
1128
+ const transcript = (all: Line[], style: Record<string, unknown>) => (
1129
+ <scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
1130
+ {all.map((line) => (
1131
+ <TranscriptLine key={line.id} line={line} />
1132
+ ))}
1133
+ {running && !split ? (
1134
+ <box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
1135
+ <Spinner kind="dots" />
1136
+ <text fg={theme.text}>{` ${activity || "Working…"}`}</text>
1137
+ </box>
1138
+ ) : null}
1139
+ </scrollbox>
1140
+ )
1141
+ const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.min(10, Math.floor(height / 3)))])
1142
+
1143
+ const split = width >= SPLIT_MIN
1144
+ if (split) {
1145
+ const leftWidth = Math.floor(width * 0.4)
1146
+ return (
1147
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
1148
+ <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1149
+ <box style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1150
+ <SessionPanel info={sessionInfo} width={leftWidth - 3} />
1151
+ {transcript(chatLines, {})}
1152
+ <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1153
+ </box>
1154
+ <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1155
+ {details(width - leftWidth - 3, height - 2)}
1156
+ </box>
1157
+ </box>
1158
+ <Footer left={shortCwd()} right={footerRight} width={width} />
1159
+ </box>
1160
+ )
1161
+ }
1162
+
949
1163
  const promptWidth = Math.max(40, width - 4)
1164
+ if (narrowView === "details") {
1165
+ return (
1166
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
1167
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>{details(promptWidth, height - 3 - (dialog ? 8 : 0))}</box>
1168
+ <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
1169
+ {dialog ? (
1170
+ // Stop, a question, or "your turn": the same panel the chat view shows in place of the prompt.
1171
+ controls(promptWidth)
1172
+ ) : (
1173
+ <Hints width={promptWidth} items={[{ key: "ctrl+o", label: "back to the chat", onPress: () => setNarrowView("chat") }, closeHint]} />
1174
+ )}
1175
+ </box>
1176
+ <Footer left={shortCwd()} right={footerRight} width={width} />
1177
+ </box>
1178
+ )
1179
+ }
950
1180
  return (
951
1181
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
952
- <scrollbox
953
- ref={scrollRef}
954
- stickyScroll
955
- stickyStart="bottom"
956
- style={{ flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}
957
- focused={false}
958
- >
959
- {lines.map((line) => (
960
- <TranscriptLine key={line.id} line={line} />
961
- ))}
962
- {running ? (
963
- <box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
964
- <Spinner kind="dots" />
965
- <text fg={theme.text}>{` ${activity || "Working…"}`}</text>
966
- </box>
967
- ) : null}
968
- </scrollbox>
969
- <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
970
- {renderControls(
971
- promptWidth,
972
- running
973
- ? coding
974
- ? "The coding agent is writing a test · esc to stop"
975
- : `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
976
- : "Ask ForgeQE: a goal, test cases, a test, a pipeline…",
977
- [3, Math.max(3, Math.min(10, Math.floor(height / 3)))],
978
- )}
979
- </box>
1182
+ {transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 })}
1183
+ <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>{controls(promptWidth)}</box>
980
1184
  <Footer left={shortCwd()} right={footerRight} width={width} />
981
1185
  </box>
982
1186
  )
@@ -48,6 +48,8 @@ export type PromptProps = {
48
48
  rightColor?: string
49
49
  /** A secret being entered: the box shows one dot per character instead of a text field, so the value is never drawn. */
50
50
  masked?: number
51
+ /** Beside a session panel that names the model and profile, and a status bar: no bottom row. */
52
+ compact?: boolean
51
53
  }
52
54
 
53
55
  /** Enter sends; Shift+Enter, Alt+Enter or Ctrl+J start a new line. */
@@ -106,18 +108,20 @@ export function Prompt(props: PromptProps) {
106
108
  placeholderColor={theme.faint}
107
109
  />
108
110
  )}
109
- <box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
110
- <text>
111
- <span fg={theme.muted}>{"Model "}</span>
112
- <span fg={theme.text}>{props.model || "…"}</span>
113
- </text>
114
- <text>
115
- {props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
116
- {props.right ? <span fg={theme.faint}>{" · "}</span> : null}
117
- <span fg={theme.muted}>{"profile "}</span>
118
- <span fg={theme.text}>{props.profile || "none"}</span>
119
- </text>
120
- </box>
111
+ {props.compact ? null : (
112
+ <box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
113
+ <text>
114
+ <span fg={theme.muted}>{"Model "}</span>
115
+ <span fg={theme.text}>{props.model || "…"}</span>
116
+ </text>
117
+ <text>
118
+ {props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
119
+ {props.right ? <span fg={theme.faint}>{" · "}</span> : null}
120
+ <span fg={theme.muted}>{"profile "}</span>
121
+ <span fg={theme.text}>{props.profile || "none"}</span>
122
+ </text>
123
+ </box>
124
+ )}
121
125
  </box>
122
126
  <text>
123
127
  <span fg={props.modeColor}>{edge.slice(0, 1)}</span>
@@ -0,0 +1,348 @@
1
+ import type { ScrollBoxRenderable } from "@opentui/core"
2
+ import type { ReactNode, RefObject } from "react"
3
+
4
+ import type { Usage } from "../types.ts"
5
+ import { formatCost, type Cost, type Role } from "../usage.ts"
6
+ import { Spinner } from "./components.tsx"
7
+ import { filesByUse, type FileUse, type Inspector, type Progress } from "./session.ts"
8
+ import { clip, tailCut } from "./text.ts"
9
+ import { formatTokens, theme } from "./theme.ts"
10
+ import type { Line } from "./transcript.tsx"
11
+
12
+ /**
13
+ * The split view's panels. Each is a rounded box with its title in the border, in its section's colour from the
14
+ * shell's palette; status colours keep their meaning inside (ok done, warn running or attention, err failed).
15
+ */
16
+ export const SECTION = {
17
+ session: theme.accent,
18
+ plan: theme.thought,
19
+ activity: theme.accent,
20
+ files: theme.memory,
21
+ context: theme.warn,
22
+ tokens: theme.ok,
23
+ }
24
+
25
+ const ROLE_COLOR: Record<Role, string> = {
26
+ "ForgeQE's agent": theme.accent,
27
+ "browser agent": theme.thought,
28
+ Scribe: theme.ok,
29
+ reviewer: theme.warn,
30
+ judge: theme.memory,
31
+ }
32
+
33
+ /** Rows a panel's border takes: top and bottom. */
34
+ export const PANEL_CHROME = 2
35
+
36
+ /** A titled, rounded panel; `width` and `height` include the border. */
37
+ export function Panel(props: { title: string; color: string; width?: number; height?: number; children: ReactNode }) {
38
+ return (
39
+ <box
40
+ border
41
+ borderStyle="rounded"
42
+ borderColor={theme.border}
43
+ title={` ${props.title} `}
44
+ titleColor={props.color}
45
+ style={{
46
+ flexDirection: "column",
47
+ paddingLeft: 1,
48
+ paddingRight: 1,
49
+ flexShrink: 0,
50
+ ...(props.width !== undefined ? { width: props.width } : {}),
51
+ ...(props.height !== undefined ? { height: props.height } : {}),
52
+ }}
53
+ >
54
+ {props.children}
55
+ </box>
56
+ )
57
+ }
58
+
59
+ export type SessionInfo = {
60
+ profile: string
61
+ model: string
62
+ auth: string
63
+ cwd: string
64
+ tests: string
65
+ browser: string
66
+ domain?: string
67
+ /** The profile's price is set in llms.json and differs from the model's list price. */
68
+ price?: string
69
+ started?: number
70
+ requests: number
71
+ total?: { usage: Usage; cost?: Cost }
72
+ }
73
+
74
+ /** Rows SessionPanel takes, border included. */
75
+ export function sessionRows(info: SessionInfo): number {
76
+ return 6 + (info.domain ? 1 : 0) + (info.price ? 1 : 0) + PANEL_CHROME
77
+ }
78
+
79
+ /** Top of the left pane: what this session runs with, and what it has used. */
80
+ export function SessionPanel({ info, width }: { info: SessionInfo; width: number }) {
81
+ const room = Math.max(10, width - 14)
82
+ const row = (label: string, value: string, color: string = theme.text) => (
83
+ <text key={label}>
84
+ <span fg={theme.muted}>{label.padEnd(9)}</span>
85
+ <span fg={color}>{clip(value, room)}</span>
86
+ </text>
87
+ )
88
+ const started = info.started ? new Date(info.started) : null
89
+ const since = started ? ` · since ${String(started.getHours()).padStart(2, "0")}:${String(started.getMinutes()).padStart(2, "0")}` : ""
90
+ const used = info.total ? `${formatTokens(info.total.usage.input + info.total.usage.output)} tokens${info.total.cost ? ` · ${formatCost(info.total.cost)}` : ""}` : "nothing yet"
91
+ return (
92
+ <Panel title={`Session${since}`} color={SECTION.session} width={width}>
93
+ {row("Model", `${info.profile || "none"} · ${info.model || "…"}`)}
94
+ {row("Sign-in", info.auth, info.auth.startsWith("Entra") ? theme.ok : theme.text)}
95
+ {row("Folder", tailCut(info.cwd, room))}
96
+ {row("Tests", info.tests, info.tests.startsWith("written") ? theme.ok : theme.muted)}
97
+ {row("Browser", info.browser)}
98
+ {info.domain ? row("Domain", info.domain, theme.memory) : null}
99
+ {info.price ? row("Price", info.price, theme.warn) : null}
100
+ {row("Used", `${info.requests} request${info.requests === 1 ? "" : "s"} · ${used}`, theme.muted)}
101
+ </Panel>
102
+ )
103
+ }
104
+
105
+ /** The right pane's first line: who is working on what and for how long, and Stop while something runs. */
106
+ export function StatusBar(props: { running: boolean; who: string; activity: string; elapsed: string; status: string; width: number; onStop: () => void }) {
107
+ const done = props.status === "done"
108
+ const failed = props.status === "fail"
109
+ const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
110
+ const what = props.running
111
+ ? props.activity || "working…"
112
+ : done
113
+ ? "Last request done"
114
+ : failed
115
+ ? "Last request failed"
116
+ : props.status === "stopped"
117
+ ? "Last request stopped"
118
+ : "Ready for your next request"
119
+ const room = Math.max(10, props.width - props.who.length - (props.running ? 30 : 6))
120
+ return (
121
+ <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
122
+ <box style={{ flexDirection: "row" }}>
123
+ {props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
124
+ <text>
125
+ {props.running ? <span fg={theme.text}>{` ${props.who}`}</span> : null}
126
+ <span fg={props.running ? theme.muted : color}>{`${props.running ? " " : " "}${clip(what, room)}`}</span>
127
+ </text>
128
+ </box>
129
+ {props.running ? (
130
+ <box style={{ flexDirection: "row" }}>
131
+ <text fg={theme.faint}>{`${props.elapsed} `}</text>
132
+ <box onMouseDown={props.onStop} style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.warn }}>
133
+ <text fg={theme.onButton}>{"■ Stop · esc"}</text>
134
+ </box>
135
+ </box>
136
+ ) : null}
137
+ </box>
138
+ )
139
+ }
140
+
141
+ const PLAN_MARK: Record<string, [string, string]> = {
142
+ completed: ["✓", theme.ok],
143
+ in_progress: ["◐", theme.warn],
144
+ pending: ["○", theme.faint],
145
+ cancelled: ["✗", theme.faint],
146
+ }
147
+ const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
148
+ ok: ["✓", theme.ok],
149
+ running: ["◐", theme.warn],
150
+ fail: ["✗", theme.err],
151
+ skipped: ["■", theme.faint],
152
+ }
153
+
154
+ type PlanLine = { mark: string; color: string; text: string; textColor: string }
155
+
156
+ function planLines(s: Inspector): PlanLine[] {
157
+ const lines: PlanLine[] = []
158
+ for (const p of s.plan) {
159
+ const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
160
+ lines.push({ mark, color, text: p.text, textColor: p.status === "completed" || p.status === "cancelled" ? theme.muted : theme.text })
161
+ }
162
+ if (s.progress.items.length && s.plan.length) lines.push({ mark: "─", color: theme.border, text: s.progress.title, textColor: theme.faint })
163
+ for (const i of s.progress.items) {
164
+ const [mark, color] = PROGRESS_MARK[i.status]
165
+ lines.push({ mark, color, text: i.detail ? `${i.label} — ${i.detail}` : i.label, textColor: i.status === "fail" ? theme.err : i.status === "running" ? theme.text : theme.muted })
166
+ }
167
+ return lines
168
+ }
169
+
170
+ /** Rows the plan panel takes, border included; 0 while there is nothing to plan or follow. */
171
+ export function planRows(s: Inspector, cap: number): number {
172
+ const n = planLines(s).length
173
+ return n ? Math.min(cap, n) + PANEL_CHROME : 0
174
+ }
175
+
176
+ /** The agent's to-do list, then the browser steps or suite cases; the latest that fit. */
177
+ export function PlanPanel({ s, width, height }: { s: Inspector; width: number; height: number }) {
178
+ const lines = planLines(s)
179
+ const done = s.plan.filter((p) => p.status === "completed").length
180
+ const count = s.plan.length ? ` · ${done} of ${s.plan.length}` : s.progress.items.length ? ` · ${clip(s.progress.title, 40)}` : ""
181
+ const shown = lines.slice(-Math.max(1, height - PANEL_CHROME))
182
+ return (
183
+ <Panel title={`Plan${count}`} color={SECTION.plan} width={width} height={height}>
184
+ {shown.map((l, i) => (
185
+ <text key={i}>
186
+ <span fg={l.color}>{`${l.mark} `}</span>
187
+ <span fg={l.textColor}>{clip(l.text, Math.max(10, width - 8))}</span>
188
+ </text>
189
+ ))}
190
+ </Panel>
191
+ )
192
+ }
193
+
194
+ /** Each kind of activity line: its mark, the mark's colour, and the text's colour. */
195
+ const ACTIVITY_STYLE: Record<string, [string, string, string]> = {
196
+ tool: ["⚙", theme.faint, theme.muted],
197
+ file: ["✎", theme.accent, theme.text],
198
+ code: ["▸", theme.memory, theme.memory],
199
+ timing: ["+", theme.warn, theme.warn],
200
+ memory: ["↻", theme.memory, theme.memory],
201
+ thought: [" ", theme.thought, theme.thought],
202
+ ok: ["✓", theme.ok, theme.text],
203
+ err: ["✗", theme.err, theme.text],
204
+ }
205
+
206
+ /** One activity line, its mark in a column of its own so wrapped text stays under the text. */
207
+ export function ActivityRow({ line }: { line: Line }) {
208
+ const [mark, markColor, textColor] = ACTIVITY_STYLE[line.kind] || ["·", theme.faint, theme.muted]
209
+ return (
210
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
211
+ <text fg={markColor} style={{ width: 2, flexShrink: 0 }}>
212
+ {mark}
213
+ </text>
214
+ <text fg={textColor} style={{ flexGrow: 1, flexShrink: 1 }}>
215
+ {line.text || " "}
216
+ </text>
217
+ </box>
218
+ )
219
+ }
220
+
221
+ /** Everything the agents do, newest at the bottom. */
222
+ export function ActivityPanel(props: { lines: Line[]; scrollRef: RefObject<ScrollBoxRenderable | null>; width: number; height: number }) {
223
+ return (
224
+ <Panel title={`Activity${props.lines.length ? ` · ${props.lines.length}` : ""}`} color={SECTION.activity} width={props.width} height={props.height}>
225
+ {props.lines.length ? (
226
+ <scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
227
+ {props.lines.map((line) => (
228
+ <ActivityRow key={line.id} line={line} />
229
+ ))}
230
+ </scrollbox>
231
+ ) : (
232
+ <box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
233
+ <text fg={theme.faint}>{clip("Tool calls, browser steps, and the agents' reasoning show here", Math.max(10, props.width - 4))}</text>
234
+ </box>
235
+ )}
236
+ </Panel>
237
+ )
238
+ }
239
+
240
+ const FILE_MARK: Record<FileUse["last"], [string, string]> = { read: ["◉", theme.accent], write: ["✚", theme.ok], edit: ["✎", theme.warn] }
241
+
242
+ /** "PayBeneficiaryTests.cs tests/Payments": the name first, its folder after it, dimmed. */
243
+ function fileLabel(path: string, room: number): [string, string] {
244
+ const cut = path.lastIndexOf("/")
245
+ const name = cut < 0 ? path : path.slice(cut + 1)
246
+ const dir = cut < 0 ? "" : path.slice(0, cut)
247
+ if (name.length >= room - 2) return [clip(name, room), ""]
248
+ // A folder cut to a few characters says nothing: leave it out rather than show "…ts".
249
+ const space = room - name.length - 2
250
+ return [name, dir && (dir.length <= space || space >= 12) ? tailCut(dir, space) : ""]
251
+ }
252
+
253
+ export function FilesPanel({ s, width, height }: { s: Inspector; width: number; height: number }) {
254
+ const files = filesByUse(s)
255
+ const rows = height - PANEL_CHROME
256
+ const room = Math.max(8, width - 10)
257
+ const listed = files.length > rows ? files.slice(0, rows - 1) : files
258
+ return (
259
+ <Panel title={`Files${files.length ? ` · ${files.length}` : ""}`} color={SECTION.files} width={width} height={height}>
260
+ {files.length ? (
261
+ listed.map((f) => {
262
+ const [mark, color] = FILE_MARK[f.last]
263
+ const [name, dir] = fileLabel(f.path, room)
264
+ const times = f.reads + f.changes
265
+ return (
266
+ <text key={f.path}>
267
+ <span fg={color}>{`${mark} `}</span>
268
+ <span fg={f.changes ? theme.text : theme.muted}>{name}</span>
269
+ {dir ? <span fg={theme.faint}>{` ${dir}`}</span> : null}
270
+ {times > 1 ? <span fg={theme.faint}>{` ×${times}`}</span> : null}
271
+ </text>
272
+ )
273
+ })
274
+ ) : (
275
+ <text fg={theme.faint}>{"None yet"}</text>
276
+ )}
277
+ {files.length > listed.length ? <text fg={theme.faint}>{`+${files.length - listed.length} more`}</text> : null}
278
+ </Panel>
279
+ )
280
+ }
281
+
282
+ export type ContextInfo = { tokens: number; limit: number; skills: string[]; knowledge: number; domain?: string; mcp: string[]; capabilities: number }
283
+
284
+ /** How full the model's context window is, and what ForgeQE put into it. */
285
+ export function ContextPanel({ c, width, height }: { c: ContextInfo; width: number; height: number }) {
286
+ const inner = Math.max(6, width - 4)
287
+ const share = c.limit ? Math.min(1, c.tokens / c.limit) : 0
288
+ const filled = Math.round(share * inner)
289
+ const color = share < 0.6 ? theme.ok : share < 0.85 ? theme.warn : theme.err
290
+ const room = Math.max(6, inner - 11)
291
+ const row = (label: string, value: string, valueColor: string = theme.text) => (
292
+ <text key={label}>
293
+ <span fg={theme.muted}>{label.padEnd(11)}</span>
294
+ <span fg={valueColor}>{clip(value, room)}</span>
295
+ </text>
296
+ )
297
+ return (
298
+ <Panel title={`Context · ${Math.round(share * 100)}%`} color={SECTION.context} width={width} height={height}>
299
+ <text>
300
+ <span fg={color}>{"█".repeat(filled)}</span>
301
+ <span fg={theme.border}>{"█".repeat(inner - filled)}</span>
302
+ </text>
303
+ <text fg={theme.muted}>{`${formatTokens(c.tokens)} of ${formatTokens(c.limit)} tokens`}</text>
304
+ {row("Skills", c.skills.length ? c.skills.join(", ") : "none loaded", c.skills.length ? theme.ok : theme.faint)}
305
+ {row("Knowledge", c.knowledge ? `${c.knowledge} product file${c.knowledge === 1 ? "" : "s"}` : "none", c.knowledge ? theme.text : theme.faint)}
306
+ {row("Domain", c.domain || "not declared", c.domain ? theme.memory : theme.faint)}
307
+ {row("MCP", c.mcp.length ? c.mcp.join(", ") : "none", c.mcp.length ? theme.text : theme.faint)}
308
+ </Panel>
309
+ )
310
+ }
311
+
312
+ export function TokensPanel(props: {
313
+ roles: { role: Role; usage: Usage; cost?: Cost }[]
314
+ total: { usage: Usage; cost?: Cost }
315
+ /** Tokens of the request in progress, counted as they stream; the roles above get them when each run ends. */
316
+ live?: number
317
+ width: number
318
+ height: number
319
+ }) {
320
+ const { roles, total, width } = props
321
+ // The label takes what the token count (7) and, when priced, the cost (two spaces and up to 7) leave.
322
+ const priced = roles.some((r) => r.cost) || Boolean(total.cost)
323
+ const labelWidth = Math.max(6, width - 4 - 7 - (priced ? 9 : 0))
324
+ const line = (label: string, labelColor: string, u: Usage, c: Cost | undefined, valueColor: string = theme.text) => (
325
+ <text key={label}>
326
+ <span fg={labelColor}>{clip(label, labelWidth).padEnd(labelWidth)}</span>
327
+ <span fg={valueColor}>{formatTokens(u.input + u.output).padStart(7)}</span>
328
+ {c ? <span fg={theme.muted}>{` ${formatCost(c)}`}</span> : null}
329
+ </text>
330
+ )
331
+ return (
332
+ <Panel title="Tokens" color={SECTION.tokens} width={width} height={props.height}>
333
+ {props.live ? (
334
+ <text>
335
+ <span fg={theme.warn}>{"This request".padEnd(labelWidth)}</span>
336
+ <span fg={theme.warn}>{formatTokens(props.live).padStart(7)}</span>
337
+ </text>
338
+ ) : null}
339
+ {roles.length ? roles.map((r) => line(r.role, ROLE_COLOR[r.role] || theme.text, r.usage, r.cost)) : props.live ? null : <text fg={theme.faint}>{"None used yet"}</text>}
340
+ {roles.length > 1 ? line("Total", theme.text, total.usage, total.cost, theme.ok) : null}
341
+ {roles.length ? (
342
+ <text fg={theme.faint}>
343
+ {clip(`in ${formatTokens(total.usage.input)} · out ${formatTokens(total.usage.output)}${total.usage.cached ? ` · ${formatTokens(total.usage.cached)} cached` : ""}`, Math.max(10, width - 4))}
344
+ </text>
345
+ ) : null}
346
+ </Panel>
347
+ )
348
+ }
@@ -0,0 +1,123 @@
1
+ import type { FileAction, PlanItem } from "../types.ts"
2
+
3
+ /**
4
+ * What the shell's right-hand pane shows about the request in progress: the agent's plan, the browser steps or suite
5
+ * cases, the files the agents used, and what is in the model's context. Plain data, changed only through the
6
+ * functions below, so it is tested without a terminal.
7
+ */
8
+
9
+ export type FileUse = { path: string; reads: number; changes: number; last: FileAction; order: number }
10
+
11
+ export type Progress = { label: string; status: "running" | "ok" | "fail" | "skipped"; detail?: string; actions?: string[] }
12
+
13
+ export type Who = "ForgeQE's agent" | "Browser agent" | "Scribe"
14
+
15
+ export type Inspector = {
16
+ /** The agent's own to-do list. */
17
+ plan: PlanItem[]
18
+ /** The browser run's steps, or the suite's cases; `title` says which. */
19
+ progress: { title: string; items: Progress[] }
20
+ files: Map<string, FileUse>
21
+ /** Input tokens of the latest model call: how full its context window is. */
22
+ context: number
23
+ skills: string[]
24
+ who: Who
25
+ /** Counts file uses, so the most recent comes first. */
26
+ seq: number
27
+ }
28
+
29
+ export const MAX_PROGRESS = 200
30
+
31
+ export function newInspector(): Inspector {
32
+ return { plan: [], progress: { title: "", items: [] }, files: new Map(), context: 0, skills: [], who: "ForgeQE's agent", seq: 0 }
33
+ }
34
+
35
+ /** A new request keeps the files, skills, and context of the session, and starts a fresh plan and progress. */
36
+ export function newRequest(s: Inspector): void {
37
+ s.plan = []
38
+ s.progress = { title: "", items: [] }
39
+ s.who = "ForgeQE's agent"
40
+ }
41
+
42
+ export function setPlan(s: Inspector, items: PlanItem[]): void {
43
+ s.plan = items
44
+ }
45
+
46
+ export function useFile(s: Inspector, file: { path: string; action: FileAction }): void {
47
+ const was = s.files.get(file.path)
48
+ const reads = (was?.reads || 0) + (file.action === "read" ? 1 : 0)
49
+ const changes = (was?.changes || 0) + (file.action === "read" ? 0 : 1)
50
+ // Newest first: a file used again moves to the top.
51
+ s.files.set(file.path, { path: file.path, reads, changes, last: file.action, order: ++s.seq })
52
+ }
53
+
54
+ export function addSkill(s: Inspector, name: string): void {
55
+ if (name && !s.skills.includes(name)) s.skills.push(name)
56
+ }
57
+
58
+ export function setContext(s: Inspector, tokens: number): void {
59
+ if (tokens > 0) s.context = tokens
60
+ }
61
+
62
+ export function setWho(s: Inspector, who: Who): void {
63
+ s.who = who
64
+ }
65
+
66
+ export function startProgress(s: Inspector, title: string): void {
67
+ s.progress = { title, items: [] }
68
+ }
69
+
70
+ /** A browser step started (or a case): the previous running one, if any, counts as done. */
71
+ export function stepStarted(s: Inspector, label: string): void {
72
+ for (const i of s.progress.items) if (i.status === "running") i.status = "ok"
73
+ s.progress.items.push({ label, status: "running" })
74
+ if (s.progress.items.length > MAX_PROGRESS) s.progress.items.splice(0, s.progress.items.length - MAX_PROGRESS)
75
+ }
76
+
77
+ /**
78
+ * An action of the running step: the step is then named by what it did ("Step 2 · click "Learn more""), which says
79
+ * more than the agent's reasoning. A failed action marks the step; a later success does not clear the failure.
80
+ */
81
+ export function stepOutcome(s: Inspector, ok: boolean, detail?: string, action?: string): void {
82
+ const running = [...s.progress.items].reverse().find((i) => i.status === "running" || i.status === "fail")
83
+ if (!running) return
84
+ if (action) {
85
+ running.actions = [...(running.actions || []), action]
86
+ const step = /^Step \d+/.exec(running.label)?.[0]
87
+ if (step) running.label = `${step} · ${running.actions.slice(0, 2).join(", ")}${running.actions.length > 2 ? ` +${running.actions.length - 2}` : ""}`
88
+ }
89
+ if (!ok) {
90
+ running.status = "fail"
91
+ if (detail) running.detail = detail
92
+ }
93
+ }
94
+
95
+ /** Every step still running is over: done, or stopped when the run was. */
96
+ export function progressDone(s: Inspector, stopped = false): void {
97
+ for (const i of s.progress.items) if (i.status === "running") i.status = stopped ? "skipped" : "ok"
98
+ }
99
+
100
+ export function caseStarted(s: Inspector, id: string, title: string): void {
101
+ s.progress.items.push({ label: `${id} ${title}`, status: "running" })
102
+ }
103
+
104
+ export function caseDone(s: Inspector, id: string, status: "passed" | "failed" | "error" | "stopped", detail?: string): void {
105
+ const item = s.progress.items.find((i) => i.label.startsWith(`${id} `) && i.status === "running")
106
+ if (!item) return
107
+ item.status = status === "passed" ? "ok" : status === "stopped" ? "skipped" : "fail"
108
+ if (detail) item.detail = detail
109
+ }
110
+
111
+ /** The files, most recently used first. */
112
+ export function filesByUse(s: Inspector): FileUse[] {
113
+ return [...s.files.values()].sort((a, b) => b.order - a.order)
114
+ }
115
+
116
+ /** Which pane a transcript line belongs in: the conversation, or what the agents are doing. */
117
+ export type Pane = "chat" | "activity"
118
+ const CHAT_KINDS = new Set(["user", "reply", "card", "summary", "suitesummary"])
119
+
120
+ /** Results and the conversation go to the chat; while a request runs, everything else is activity. */
121
+ export function paneFor(kind: string, running: boolean): Pane {
122
+ return CHAT_KINDS.has(kind) || !running ? "chat" : "activity"
123
+ }
package/src/tui/text.ts CHANGED
@@ -7,6 +7,11 @@ export function clip(text: string, n = 72): string {
7
7
  return s.length <= n ? s : `${s.slice(0, n - 1)}…`
8
8
  }
9
9
 
10
+ /** "…/pages/LoginPage.ts": a path cut from the start, since its end tells files apart. */
11
+ export function tailCut(text: string, n: number): string {
12
+ return text.length <= n ? text : `…${text.slice(text.length - n + 1)}`
13
+ }
14
+
10
15
  export function modelLabel(cfg: AppConfig): string {
11
16
  if (cfg.llm.provider === "none") return "none set up"
12
17
  if (cfg.llm.provider === "mock") return "offline"
package/src/tui/theme.ts CHANGED
@@ -14,6 +14,8 @@ export const theme = {
14
14
  thought: "#a5b4fc",
15
15
  memory: "#c792ea",
16
16
  button: "#21262d",
17
+ /** Panel borders and empty gauge cells: visible on the background, quieter than any text. */
18
+ border: "#30363d",
17
19
  onButton: "#0d1117",
18
20
  }
19
21
 
@@ -20,6 +20,8 @@ export type Line = {
20
20
  suite?: SuiteResult
21
21
  card?: Card
22
22
  outcome?: "done" | "failed" | "needs_input"
23
+ /** Which side of the split view shows it: the conversation, or what the agents are doing. */
24
+ pane?: "chat" | "activity"
23
25
  }
24
26
 
25
27
  /** What one request produced: browser runs with their files, suites, and the tests the coding agent wrote. */
package/src/types.ts CHANGED
@@ -127,7 +127,7 @@ export type PlanItem = { text: string; status: "pending" | "in_progress" | "comp
127
127
  export type CodeEvent =
128
128
  | { type: "code"; kind: "info"; text: string }
129
129
  /** A finished tool call: `tool` is its name, and `file` the file it read or changed, relative to the repository. */
130
- | { type: "code"; kind: "tool"; text: string; ok: boolean; tool?: string; file?: { path: string; action: FileAction } }
130
+ | { type: "code"; kind: "tool"; text: string; ok: boolean; tool?: string; file?: { path: string; action: FileAction }; skill?: string }
131
131
  | { type: "code"; kind: "say"; text: string }
132
132
  | { type: "code"; kind: "test"; text: string; ok: boolean; attempt: number }
133
133
  /** The session's usage so far; `context` is the last model call's input, how full its context window is. */