@capagents/forgeqe 1.0.2 → 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.2",
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",
@@ -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
@@ -33,7 +33,7 @@ import { CONTEXT_LIMIT } from "../code/runtime.ts"
33
33
  import { readKnowledge } from "../knowledge.ts"
34
34
  import { externalMcp } from "../mcp/external.ts"
35
35
  import { meterSince } from "../usage.ts"
36
- import { ActivityLog, ContextPanel, FilesPanel, PlanPanel, SessionBlock, TokensPanel, WorkHeader, type ContextInfo, type SessionInfo } from "./inspector.tsx"
36
+ import { ActivityPanel, ContextPanel, FilesPanel, PlanPanel, planRows, SessionPanel, StatusBar, TokensPanel, type ContextInfo, type SessionInfo } from "./inspector.tsx"
37
37
  import {
38
38
  addSkill,
39
39
  caseDone,
@@ -488,7 +488,9 @@ export function App({ options }: { options: TuiOptions }) {
488
488
  }
489
489
  if (event.type === "outcome") {
490
490
  push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
491
- stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(event.text, 80))
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))
492
494
  bump()
493
495
  }
494
496
  return
@@ -846,8 +848,10 @@ export function App({ options }: { options: TuiOptions }) {
846
848
  return
847
849
  }
848
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")
849
854
  push("user", text)
850
- for (const w of priceNotesRef.current.splice(0)) push("sys", `Price check: ${w}`)
851
855
  const cmd = parseCommand(text)
852
856
  if (cmd.type === "help") {
853
857
  push("sys", HELP)
@@ -1060,7 +1064,8 @@ export function App({ options }: { options: TuiOptions }) {
1060
1064
  if (cfgNow && contextInfo.current.key !== contextKey) {
1061
1065
  let mcp: string[] = []
1062
1066
  try {
1063
- mcp = externalMcp(cfgNow).servers.map((x) => x.label)
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))
1064
1069
  } catch {}
1065
1070
  contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
1066
1071
  }
@@ -1073,6 +1078,7 @@ export function App({ options }: { options: TuiOptions }) {
1073
1078
  tests: testsRef.current.on ? "written to this folder" : requests.current ? `not written (${testsRef.current.why || "off"})` : "set on the first request",
1074
1079
  browser: `${headed ? "visible" : "headless"} · up to ${maxSteps} steps${options.url ? ` · ${hostOf(options.url)}` : ""}`,
1075
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` } : {}),
1076
1082
  started: sessionStarted.current,
1077
1083
  requests: requests.current,
1078
1084
  total: spent.roles.length ? { usage: spent.total, cost: spent.cost } : undefined,
@@ -1096,28 +1102,24 @@ export function App({ options }: { options: TuiOptions }) {
1096
1102
  const stop = () => openDialog("stop")
1097
1103
 
1098
1104
  /**
1099
- * The right pane: who is working, the plan, the activity, and files, context, and tokens. Its rows are counted, not
1100
- * left to flex: the activity log gets what the fixed parts leave, so the bottom panels are never pushed off screen.
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.
1101
1107
  */
1102
1108
  const details = (paneWidth: number, rowsAvailable: number) => {
1103
- const bottomRows = Math.max(6, Math.min(9, Math.floor(rowsAvailable / 4)))
1104
- const planCap = Math.max(3, Math.min(10, Math.floor(rowsAvailable / 4)))
1105
- const planLines = insp.plan.length + insp.progress.items.length + (insp.plan.length && insp.progress.items.length ? 1 : 0)
1106
- const planHeight = 1 + Math.max(1, Math.min(planCap, planLines))
1107
- // Header, a blank row, the plan, a blank row, the activity, a blank row, and the bottom panels with their title and legend.
1108
- const activityRows = Math.max(4, rowsAvailable - 1 - 1 - planHeight - 1 - 1 - (bottomRows + 2))
1109
- const third = Math.floor((paneWidth - 2) / 3)
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)
1110
1113
  return (
1111
1114
  <box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
1112
- <WorkHeader running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} />
1115
+ <StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} />
1113
1116
  <box style={{ height: 1, flexShrink: 0 }} />
1114
- <PlanPanel s={insp} width={paneWidth} rows={planCap} />
1115
- <box style={{ height: 1, flexShrink: 0 }} />
1116
- <ActivityLog lines={activityLines} scrollRef={activityScroll} height={activityRows} width={paneWidth} />
1117
- <box style={{ flexDirection: "row", height: bottomRows + 2, flexShrink: 0, marginTop: 1 }}>
1118
- <FilesPanel s={insp} width={third} rows={bottomRows - 1} />
1119
- <ContextPanel c={contextPanel} width={third} />
1120
- <TokensPanel roles={spent.roles} total={{ usage: spent.total, cost: spent.cost }} width={paneWidth - 2 * third} />
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} />
1121
1123
  </box>
1122
1124
  </box>
1123
1125
  )
@@ -1141,17 +1143,16 @@ export function App({ options }: { options: TuiOptions }) {
1141
1143
  const split = width >= SPLIT_MIN
1142
1144
  if (split) {
1143
1145
  const leftWidth = Math.floor(width * 0.4)
1144
- const rightWidth = width - leftWidth - 3
1145
1146
  return (
1146
1147
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1147
1148
  <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1148
1149
  <box style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1149
- <SessionBlock info={sessionInfo} width={leftWidth - 3} />
1150
+ <SessionPanel info={sessionInfo} width={leftWidth - 3} />
1150
1151
  {transcript(chatLines, {})}
1151
1152
  <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1152
1153
  </box>
1153
- <box border={["left"]} borderColor={theme.button} style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1154
- {details(rightWidth - 2, height - 2)}
1154
+ <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1155
+ {details(width - leftWidth - 3, height - 2)}
1155
1156
  </box>
1156
1157
  </box>
1157
1158
  <Footer left={shortCwd()} right={footerRight} width={width} />
@@ -48,7 +48,7 @@ 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
- /** Narrow pane beside a session panel that names the model and profile: the bottom row shows only `right`. */
51
+ /** Beside a session panel that names the model and profile, and a status bar: no bottom row. */
52
52
  compact?: boolean
53
53
  }
54
54
 
@@ -108,20 +108,20 @@ export function Prompt(props: PromptProps) {
108
108
  placeholderColor={theme.faint}
109
109
  />
110
110
  )}
111
- <box style={{ flexDirection: "row", justifyContent: props.compact ? "flex-end" : "space-between", marginTop: 1 }}>
112
- {props.compact ? null : (
111
+ {props.compact ? null : (
112
+ <box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
113
113
  <text>
114
114
  <span fg={theme.muted}>{"Model "}</span>
115
115
  <span fg={theme.text}>{props.model || "…"}</span>
116
116
  </text>
117
- )}
118
- <text>
119
- {props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
120
- {props.right && !props.compact ? <span fg={theme.faint}>{" · "}</span> : null}
121
- {props.compact ? null : <span fg={theme.muted}>{"profile "}</span>}
122
- {props.compact ? null : <span fg={theme.text}>{props.profile || "none"}</span>}
123
- </text>
124
- </box>
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
+ )}
125
125
  </box>
126
126
  <text>
127
127
  <span fg={props.modeColor}>{edge.slice(0, 1)}</span>
@@ -1,5 +1,5 @@
1
1
  import type { ScrollBoxRenderable } from "@opentui/core"
2
- import type { RefObject } from "react"
2
+ import type { ReactNode, RefObject } from "react"
3
3
 
4
4
  import type { Usage } from "../types.ts"
5
5
  import { formatCost, type Cost, type Role } from "../usage.ts"
@@ -7,9 +7,12 @@ import { Spinner } from "./components.tsx"
7
7
  import { filesByUse, type FileUse, type Inspector, type Progress } from "./session.ts"
8
8
  import { clip, tailCut } from "./text.ts"
9
9
  import { formatTokens, theme } from "./theme.ts"
10
- import { TranscriptLine, type Line } from "./transcript.tsx"
10
+ import type { Line } from "./transcript.tsx"
11
11
 
12
- /** Each section of the split view has its own colour from the shell's palette; status colours keep their meaning. */
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
+ */
13
16
  export const SECTION = {
14
17
  session: theme.accent,
15
18
  plan: theme.thought,
@@ -27,15 +30,28 @@ const ROLE_COLOR: Record<Role, string> = {
27
30
  judge: theme.memory,
28
31
  }
29
32
 
30
- export function SectionTitle({ title, color, right, width }: { title: string; color: string; right?: string; width?: number }) {
31
- if (right && width) right = clip(right, Math.max(4, width - title.length - 3))
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 }) {
32
38
  return (
33
- <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0 }}>
34
- <text>
35
- <span fg={color}>{"▍"}</span>
36
- <span fg={color}>{title}</span>
37
- </text>
38
- {right ? <text fg={theme.muted}>{right}</text> : null}
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}
39
55
  </box>
40
56
  )
41
57
  }
@@ -48,14 +64,21 @@ export type SessionInfo = {
48
64
  tests: string
49
65
  browser: string
50
66
  domain?: string
67
+ /** The profile's price is set in llms.json and differs from the model's list price. */
68
+ price?: string
51
69
  started?: number
52
70
  requests: number
53
71
  total?: { usage: Usage; cost?: Cost }
54
72
  }
55
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
+
56
79
  /** Top of the left pane: what this session runs with, and what it has used. */
57
- export function SessionBlock({ info, width }: { info: SessionInfo; width: number }) {
58
- const room = Math.max(10, width - 12)
80
+ export function SessionPanel({ info, width }: { info: SessionInfo; width: number }) {
81
+ const room = Math.max(10, width - 14)
59
82
  const row = (label: string, value: string, color: string = theme.text) => (
60
83
  <text key={label}>
61
84
  <span fg={theme.muted}>{label.padEnd(9)}</span>
@@ -63,41 +86,54 @@ export function SessionBlock({ info, width }: { info: SessionInfo; width: number
63
86
  </text>
64
87
  )
65
88
  const started = info.started ? new Date(info.started) : null
66
- const clock = started ? `${String(started.getHours()).padStart(2, "0")}:${String(started.getMinutes()).padStart(2, "0")}` : "–"
89
+ const since = started ? ` · since ${String(started.getHours()).padStart(2, "0")}:${String(started.getMinutes()).padStart(2, "0")}` : ""
67
90
  const used = info.total ? `${formatTokens(info.total.usage.input + info.total.usage.output)} tokens${info.total.cost ? ` · ${formatCost(info.total.cost)}` : ""}` : "nothing yet"
68
91
  return (
69
- <box style={{ flexDirection: "column", flexShrink: 0, paddingBottom: 1 }}>
70
- <SectionTitle title="SESSION" color={SECTION.session} right={`started ${clock}`} width={width} />
92
+ <Panel title={`Session${since}`} color={SECTION.session} width={width}>
71
93
  {row("Model", `${info.profile || "none"} · ${info.model || "…"}`)}
72
94
  {row("Sign-in", info.auth, info.auth.startsWith("Entra") ? theme.ok : theme.text)}
73
95
  {row("Folder", tailCut(info.cwd, room))}
74
96
  {row("Tests", info.tests, info.tests.startsWith("written") ? theme.ok : theme.muted)}
75
97
  {row("Browser", info.browser)}
76
98
  {info.domain ? row("Domain", info.domain, theme.memory) : null}
99
+ {info.price ? row("Price", info.price, theme.warn) : null}
77
100
  {row("Used", `${info.requests} request${info.requests === 1 ? "" : "s"} · ${used}`, theme.muted)}
78
- </box>
101
+ </Panel>
79
102
  )
80
103
  }
81
104
 
82
- /** The right pane's top line: who is working on what, for how long, and the Stop button. */
83
- export function WorkHeader(props: { running: boolean; who: string; activity: string; elapsed: string; status: string; width: number; onStop: () => void }) {
84
- const statusColor = props.running ? theme.warn : props.status === "done" ? theme.ok : props.status === "fail" ? theme.err : theme.muted
85
- const label = props.running ? `${props.who} · ${props.activity || "working…"}` : props.status === "idle" ? "Waiting for your next request" : `Last request ${props.status}`
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))
86
120
  return (
87
- <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0 }}>
121
+ <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
88
122
  <box style={{ flexDirection: "row" }}>
89
- {props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={statusColor}>{"●"}</text>}
123
+ {props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
90
124
  <text>
91
- <span fg={statusColor}>{` ${clip(label, Math.max(10, props.width - 26))}`}</span>
92
- {props.elapsed ? <span fg={theme.faint}>{` ${props.elapsed}`}</span> : null}
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>
93
127
  </text>
94
128
  </box>
95
- <box
96
- onMouseDown={props.running ? props.onStop : undefined}
97
- style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: props.running ? theme.warn : theme.button }}
98
- >
99
- <text fg={props.running ? theme.onButton : theme.muted}>{"■ Stop"}</text>
100
- </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}
101
137
  </box>
102
138
  )
103
139
  }
@@ -115,148 +151,198 @@ const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
115
151
  skipped: ["■", theme.faint],
116
152
  }
117
153
 
118
- /** The agent's to-do list, then the browser steps or suite cases; only the latest that fit in `rows`. */
119
- export function PlanPanel({ s, width, rows }: { s: Inspector; width: number; rows: number }) {
120
- const lines: { mark: string; color: string; text: string; textColor: string }[] = []
154
+ type PlanLine = { mark: string; color: string; text: string; textColor: string }
155
+
156
+ function planLines(s: Inspector): PlanLine[] {
157
+ const lines: PlanLine[] = []
121
158
  for (const p of s.plan) {
122
159
  const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
123
160
  lines.push({ mark, color, text: p.text, textColor: p.status === "completed" || p.status === "cancelled" ? theme.muted : theme.text })
124
161
  }
125
- if (s.progress.items.length) {
126
- if (s.plan.length) lines.push({ mark: " ", color: theme.faint, text: s.progress.title, textColor: theme.faint })
127
- for (const i of s.progress.items) {
128
- const [mark, color] = PROGRESS_MARK[i.status]
129
- 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 })
130
- }
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 })
131
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)
132
179
  const done = s.plan.filter((p) => p.status === "completed").length
133
- const right = s.plan.length ? `${done} of ${s.plan.length}` : s.progress.items.length ? s.progress.title : ""
134
- const shown = lines.slice(-Math.max(1, rows))
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))
135
182
  return (
136
- <box style={{ flexDirection: "column", flexShrink: 0 }}>
137
- <SectionTitle title="PLAN" color={SECTION.plan} right={right} width={width} />
138
- {shown.length ? (
139
- shown.map((l, i) => (
140
- <text key={i}>
141
- <span fg={l.color}>{` ${l.mark} `}</span>
142
- <span fg={l.textColor}>{clip(l.text, Math.max(10, width - 4))}</span>
143
- </text>
144
- ))
145
- ) : (
146
- <text fg={theme.faint}>{clip(" The agent's plan and each browser step or test case show here.", Math.max(10, width))}</text>
147
- )}
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>
148
217
  </box>
149
218
  )
150
219
  }
151
220
 
152
221
  /** Everything the agents do, newest at the bottom. */
153
- export function ActivityLog(props: { lines: Line[]; scrollRef: RefObject<ScrollBoxRenderable | null>; height: number; width: number }) {
222
+ export function ActivityPanel(props: { lines: Line[]; scrollRef: RefObject<ScrollBoxRenderable | null>; width: number; height: number }) {
154
223
  return (
155
- <box style={{ flexDirection: "column", height: props.height, flexShrink: 0 }}>
156
- <SectionTitle title="ACTIVITY" color={SECTION.activity} right={props.lines.length ? `${props.lines.length} events` : ""} width={props.width} />
157
- <scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
158
- {props.lines.length ? (
159
- props.lines.map((line) => <TranscriptLine key={line.id} line={line} />)
160
- ) : (
161
- <text fg={theme.faint}>{clip(" Tool calls, browser steps, and the agents' reasoning show here.", Math.max(10, props.width))}</text>
162
- )}
163
- </scrollbox>
164
- </box>
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>
165
237
  )
166
238
  }
167
239
 
168
- const FILE_MARK: Record<FileUse["last"], [string, string]> = { read: ["◉", theme.accent], write: ["✎", theme.ok], edit: ["✎", theme.warn] }
240
+ const FILE_MARK: Record<FileUse["last"], [string, string]> = { read: ["◉", theme.accent], write: ["✚", theme.ok], edit: ["✎", theme.warn] }
169
241
 
170
- export function FilesPanel({ s, width, rows }: { s: Inspector; width: number; rows: number }) {
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 }) {
171
254
  const files = filesByUse(s)
172
- const room = Math.max(8, width - 9)
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
173
258
  return (
174
- <box style={{ flexDirection: "column", width, paddingRight: 1 }}>
175
- <SectionTitle title="FILES" color={SECTION.files} right={files.length ? String(files.length) : ""} width={width} />
259
+ <Panel title={`Files${files.length ? ` · ${files.length}` : ""}`} color={SECTION.files} width={width} height={height}>
176
260
  {files.length ? (
177
- files.slice(0, rows).map((f) => {
261
+ listed.map((f) => {
178
262
  const [mark, color] = FILE_MARK[f.last]
263
+ const [name, dir] = fileLabel(f.path, room)
179
264
  const times = f.reads + f.changes
180
265
  return (
181
266
  <text key={f.path}>
182
267
  <span fg={color}>{`${mark} `}</span>
183
- <span fg={f.changes ? theme.text : theme.muted}>{tailCut(f.path, room)}</span>
268
+ <span fg={f.changes ? theme.text : theme.muted}>{name}</span>
269
+ {dir ? <span fg={theme.faint}>{` ${dir}`}</span> : null}
184
270
  {times > 1 ? <span fg={theme.faint}>{` ×${times}`}</span> : null}
185
271
  </text>
186
272
  )
187
273
  })
188
274
  ) : (
189
- <text fg={theme.faint}>{"No files yet"}</text>
275
+ <text fg={theme.faint}>{"None yet"}</text>
190
276
  )}
191
- {files.length > rows ? <text fg={theme.faint}>{`and ${files.length - rows} more`}</text> : null}
192
- {width < 22 ? null : <text>
193
- <span fg={theme.accent}>{"◉"}</span>
194
- <span fg={theme.faint}>{" read "}</span>
195
- <span fg={theme.ok}>{"✎"}</span>
196
- <span fg={theme.faint}>{" new "}</span>
197
- <span fg={theme.warn}>{"✎"}</span>
198
- <span fg={theme.faint}>{" edited"}</span>
199
- </text>}
200
- </box>
277
+ {files.length > listed.length ? <text fg={theme.faint}>{`+${files.length - listed.length} more`}</text> : null}
278
+ </Panel>
201
279
  )
202
280
  }
203
281
 
204
282
  export type ContextInfo = { tokens: number; limit: number; skills: string[]; knowledge: number; domain?: string; mcp: string[]; capabilities: number }
205
283
 
206
284
  /** How full the model's context window is, and what ForgeQE put into it. */
207
- export function ContextPanel({ c, width }: { c: ContextInfo; width: number }) {
208
- const barWidth = Math.max(6, width - 16)
285
+ export function ContextPanel({ c, width, height }: { c: ContextInfo; width: number; height: number }) {
286
+ const inner = Math.max(6, width - 4)
209
287
  const share = c.limit ? Math.min(1, c.tokens / c.limit) : 0
210
- const filled = Math.round(share * barWidth)
288
+ const filled = Math.round(share * inner)
211
289
  const color = share < 0.6 ? theme.ok : share < 0.85 ? theme.warn : theme.err
212
- const room = Math.max(6, width - 12)
290
+ const room = Math.max(6, inner - 11)
213
291
  const row = (label: string, value: string, valueColor: string = theme.text) => (
214
292
  <text key={label}>
215
- <span fg={theme.muted}>{label.padEnd(10)}</span>
293
+ <span fg={theme.muted}>{label.padEnd(11)}</span>
216
294
  <span fg={valueColor}>{clip(value, room)}</span>
217
295
  </text>
218
296
  )
219
297
  return (
220
- <box style={{ flexDirection: "column", width, paddingRight: 1 }}>
221
- <SectionTitle title="CONTEXT" color={SECTION.context} right={c.tokens ? `${Math.round(share * 100)}%` : ""} width={width} />
298
+ <Panel title={`Context · ${Math.round(share * 100)}%`} color={SECTION.context} width={width} height={height}>
222
299
  <text>
223
300
  <span fg={color}>{"█".repeat(filled)}</span>
224
- <span fg={theme.faint}>{"░".repeat(barWidth - filled)}</span>
225
- <span fg={theme.muted}>{` ${formatTokens(c.tokens)}/${formatTokens(c.limit)}`}</span>
301
+ <span fg={theme.border}>{"█".repeat(inner - filled)}</span>
226
302
  </text>
303
+ <text fg={theme.muted}>{`${formatTokens(c.tokens)} of ${formatTokens(c.limit)} tokens`}</text>
227
304
  {row("Skills", c.skills.length ? c.skills.join(", ") : "none loaded", c.skills.length ? theme.ok : theme.faint)}
228
305
  {row("Knowledge", c.knowledge ? `${c.knowledge} product file${c.knowledge === 1 ? "" : "s"}` : "none", c.knowledge ? theme.text : theme.faint)}
229
306
  {row("Domain", c.domain || "not declared", c.domain ? theme.memory : theme.faint)}
230
307
  {row("MCP", c.mcp.length ? c.mcp.join(", ") : "none", c.mcp.length ? theme.text : theme.faint)}
231
- {row("Tools", `${c.capabilities} capabilities`, theme.muted)}
232
- </box>
308
+ </Panel>
233
309
  )
234
310
  }
235
311
 
236
- export function TokensPanel({ roles, total, width }: { roles: { role: Role; usage: Usage; cost?: Cost }[]; total: { usage: Usage; cost?: Cost }; width: number }) {
237
- const amount = (u: Usage, c?: Cost) => `${formatTokens(u.input + u.output).padStart(6)}${c ? ` ${formatCost(c)}` : ""}`
238
- const labelWidth = Math.max(8, Math.min(16, width - 18))
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
+ )
239
331
  return (
240
- <box style={{ flexDirection: "column", width }}>
241
- <SectionTitle title="TOKENS" color={SECTION.tokens} right={total.usage.cached ? `${formatTokens(total.usage.cached)} cached` : ""} />
242
- {roles.length ? (
243
- roles.map((r) => (
244
- <text key={r.role}>
245
- <span fg={ROLE_COLOR[r.role] || theme.text}>{clip(r.role, labelWidth).padEnd(labelWidth)}</span>
246
- <span fg={theme.text}>{amount(r.usage, r.cost)}</span>
247
- </text>
248
- ))
249
- ) : (
250
- <text fg={theme.faint}>{"Nothing used yet"}</text>
251
- )}
252
- {roles.length > 1 ? (
332
+ <Panel title="Tokens" color={SECTION.tokens} width={width} height={props.height}>
333
+ {props.live ? (
253
334
  <text>
254
- <span fg={theme.muted}>{"Total".padEnd(labelWidth)}</span>
255
- <span fg={theme.ok}>{amount(total.usage, total.cost)}</span>
335
+ <span fg={theme.warn}>{"This request".padEnd(labelWidth)}</span>
336
+ <span fg={theme.warn}>{formatTokens(props.live).padStart(7)}</span>
256
337
  </text>
257
338
  ) : null}
258
- <text fg={theme.faint}>{`in ${formatTokens(total.usage.input)} · out ${formatTokens(total.usage.output)}`}</text>
259
- </box>
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>
260
347
  )
261
348
  }
262
-
@@ -8,7 +8,7 @@ import type { FileAction, PlanItem } from "../types.ts"
8
8
 
9
9
  export type FileUse = { path: string; reads: number; changes: number; last: FileAction; order: number }
10
10
 
11
- export type Progress = { label: string; status: "running" | "ok" | "fail" | "skipped"; detail?: string }
11
+ export type Progress = { label: string; status: "running" | "ok" | "fail" | "skipped"; detail?: string; actions?: string[] }
12
12
 
13
13
  export type Who = "ForgeQE's agent" | "Browser agent" | "Scribe"
14
14
 
@@ -74,10 +74,18 @@ export function stepStarted(s: Inspector, label: string): void {
74
74
  if (s.progress.items.length > MAX_PROGRESS) s.progress.items.splice(0, s.progress.items.length - MAX_PROGRESS)
75
75
  }
76
76
 
77
- /** The outcome of the running step: a failed action marks it, a later success does not clear a failure. */
78
- export function stepOutcome(s: Inspector, ok: boolean, detail?: string): void {
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 {
79
82
  const running = [...s.progress.items].reverse().find((i) => i.status === "running" || i.status === "fail")
80
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
+ }
81
89
  if (!ok) {
82
90
  running.status = "fail"
83
91
  if (detail) running.detail = detail
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