@capagents/forgeqe 1.0.2 → 1.0.4

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.4",
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,
@@ -83,6 +83,9 @@ export const SPLIT_MIN = 120
83
83
  export function App({ options }: { options: TuiOptions }) {
84
84
  const renderer = useRenderer()
85
85
  const { width, height } = useTerminalDimensions()
86
+ /** The terminal's width when a request ends (callbacks outlive the render they were made in). */
87
+ const widthRef = useRef(width)
88
+ widthRef.current = width
86
89
  const scrollRef = useRef<ScrollBoxRenderable>(null)
87
90
  const activityScroll = useRef<ScrollBoxRenderable>(null)
88
91
  const submitRef = useRef<(value: string) => void>(() => {})
@@ -488,7 +491,9 @@ export function App({ options }: { options: TuiOptions }) {
488
491
  }
489
492
  if (event.type === "outcome") {
490
493
  push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
491
- stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(event.text, 80))
494
+ // The action itself, without the result after a colon or the locator in brackets: "click "Search"".
495
+ const action = event.text.split(/: | {2}\[/)[0]!.trim()
496
+ stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(event.text, 80), clip(action, 60))
492
497
  bump()
493
498
  }
494
499
  return
@@ -773,7 +778,8 @@ export function App({ options }: { options: TuiOptions }) {
773
778
  } finally {
774
779
  pushProduced()
775
780
  // What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
776
- const spent = requestLine(meterStart)
781
+ // The split view's session and tokens panels show this; only the one-pane view needs it in the chat.
782
+ const spent = widthRef.current < SPLIT_MIN ? requestLine(meterStart) : ""
777
783
  if (spent) push("sys", spent, "chat")
778
784
  finish(finalStatus)
779
785
  }
@@ -846,8 +852,10 @@ export function App({ options }: { options: TuiOptions }) {
846
852
  return
847
853
  }
848
854
  if (!text) return
855
+ // Setup notes come before the request, so they do not read as its answer; the split view shows them in its session panel.
856
+ const notes = priceNotesRef.current.splice(0)
857
+ if (width < SPLIT_MIN) for (const w of notes) push("sys", `Price check: ${w}`, "chat")
849
858
  push("user", text)
850
- for (const w of priceNotesRef.current.splice(0)) push("sys", `Price check: ${w}`)
851
859
  const cmd = parseCommand(text)
852
860
  if (cmd.type === "help") {
853
861
  push("sys", HELP)
@@ -1060,22 +1068,18 @@ export function App({ options }: { options: TuiOptions }) {
1060
1068
  if (cfgNow && contextInfo.current.key !== contextKey) {
1061
1069
  let mcp: string[] = []
1062
1070
  try {
1063
- mcp = externalMcp(cfgNow).servers.map((x) => x.label)
1071
+ // "Azure DevOps (az login)": which account a server works as decides what it may see, so the panel says it.
1072
+ mcp = externalMcp(cfgNow).servers.map((x) => (x.signIn ? `${x.label.replace(/ MCP$/, "")} (${x.signIn.startsWith("PAT") ? "PAT" : "az login"})` : x.label))
1064
1073
  } catch {}
1065
1074
  contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
1066
1075
  }
1067
1076
  const llm = cfgNow?.llm
1068
1077
  const sessionInfo: SessionInfo = {
1069
- profile,
1070
- model: model || (cfgNow ? modelLabel(cfgNow) : ""),
1071
- auth: !llm ? "–" : llm.auth === "entra" ? "Entra ID, no key" : llm.apiKeyEnv ? `API key in ${llm.apiKeyEnv}` : llm.provider === "mock" ? "offline demo" : llm.provider,
1078
+ model: cfgNow ? `${llm?.provider === "mock" ? "offline demo" : llm?.deployment || llm?.model || "none"}${profile ? ` (${profile})` : ""}` : "…",
1072
1079
  cwd: shortCwd(),
1073
- tests: testsRef.current.on ? "written to this folder" : requests.current ? `not written (${testsRef.current.why || "off"})` : "set on the first request",
1074
- 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(", ")})` : ""}` } : {}),
1076
1081
  started: sessionStarted.current,
1077
1082
  requests: requests.current,
1078
- total: spent.roles.length ? { usage: spent.total, cost: spent.cost } : undefined,
1079
1083
  }
1080
1084
  const contextPanel: ContextInfo = {
1081
1085
  tokens: insp.context,
@@ -1096,28 +1100,24 @@ export function App({ options }: { options: TuiOptions }) {
1096
1100
  const stop = () => openDialog("stop")
1097
1101
 
1098
1102
  /**
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.
1103
+ * The right pane: the status bar, then bordered panels for the plan, the activity, and files, context, and tokens.
1104
+ * Rows are counted, not left to flex: the activity gets what the others leave, so nothing is pushed off screen.
1101
1105
  */
1102
1106
  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)
1107
+ const bottom = 6 + 2 // the context panel's six rows, and the border
1108
+ const plan = planRows(insp, Math.max(3, Math.min(8, Math.floor(rowsAvailable / 5))))
1109
+ const activityRows = Math.max(5, rowsAvailable - 2 - plan - bottom)
1110
+ const third = Math.floor(paneWidth / 3)
1110
1111
  return (
1111
1112
  <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} />
1113
+ <StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} />
1113
1114
  <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} />
1115
+ {plan ? <PlanPanel s={insp} width={paneWidth} height={plan} /> : null}
1116
+ <ActivityPanel lines={activityLines} scrollRef={activityScroll} width={paneWidth} height={activityRows} />
1117
+ <box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
1118
+ <FilesPanel s={insp} width={third} height={bottom} />
1119
+ <ContextPanel c={contextPanel} width={third} height={bottom} />
1120
+ <TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
1121
1121
  </box>
1122
1122
  </box>
1123
1123
  )
@@ -1141,17 +1141,16 @@ export function App({ options }: { options: TuiOptions }) {
1141
1141
  const split = width >= SPLIT_MIN
1142
1142
  if (split) {
1143
1143
  const leftWidth = Math.floor(width * 0.4)
1144
- const rightWidth = width - leftWidth - 3
1145
1144
  return (
1146
1145
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1147
1146
  <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1148
1147
  <box style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1149
- <SessionBlock info={sessionInfo} width={leftWidth - 3} />
1148
+ <SessionPanel info={sessionInfo} width={leftWidth - 3} />
1150
1149
  {transcript(chatLines, {})}
1151
1150
  <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1152
1151
  </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)}
1152
+ <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1153
+ {details(width - leftWidth - 3, height - 2)}
1155
1154
  </box>
1156
1155
  </box>
1157
1156
  <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,15 +1,17 @@
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
- import { formatCost, type Cost, type Role } from "../usage.ts"
6
5
  import { Spinner } from "./components.tsx"
7
6
  import { filesByUse, type FileUse, type Inspector, type Progress } from "./session.ts"
8
7
  import { clip, tailCut } from "./text.ts"
9
8
  import { formatTokens, theme } from "./theme.ts"
10
- import { TranscriptLine, type Line } from "./transcript.tsx"
9
+ import type { Line } from "./transcript.tsx"
11
10
 
12
- /** Each section of the split view has its own colour from the shell's palette; status colours keep their meaning. */
11
+ /**
12
+ * The split view's panels. Each is a rounded box with its title in the border, in its section's colour from the
13
+ * shell's palette; status colours keep their meaning inside (ok done, warn running or attention, err failed).
14
+ */
13
15
  export const SECTION = {
14
16
  session: theme.accent,
15
17
  plan: theme.thought,
@@ -19,85 +21,100 @@ export const SECTION = {
19
21
  tokens: theme.ok,
20
22
  }
21
23
 
22
- const ROLE_COLOR: Record<Role, string> = {
23
- "ForgeQE's agent": theme.accent,
24
- "browser agent": theme.thought,
25
- Scribe: theme.ok,
26
- reviewer: theme.warn,
27
- judge: theme.memory,
28
- }
29
24
 
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))
25
+ /** Rows a panel's border takes: top and bottom. */
26
+ export const PANEL_CHROME = 2
27
+
28
+ /** A titled, rounded panel; `width` and `height` include the border. */
29
+ export function Panel(props: { title: string; color: string; width?: number; height?: number; children: ReactNode }) {
32
30
  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}
31
+ <box
32
+ border
33
+ borderStyle="rounded"
34
+ borderColor={theme.border}
35
+ title={` ${props.title} `}
36
+ titleColor={props.color}
37
+ style={{
38
+ flexDirection: "column",
39
+ paddingLeft: 1,
40
+ paddingRight: 1,
41
+ flexShrink: 0,
42
+ ...(props.width !== undefined ? { width: props.width } : {}),
43
+ ...(props.height !== undefined ? { height: props.height } : {}),
44
+ }}
45
+ >
46
+ {props.children}
39
47
  </box>
40
48
  )
41
49
  }
42
50
 
43
51
  export type SessionInfo = {
44
- profile: string
52
+ /** "gpt-5.4 (azure-gpt54)": the model, and the llms.json profile it comes from. */
45
53
  model: string
46
- auth: string
47
54
  cwd: string
48
- tests: string
49
- browser: string
50
55
  domain?: string
51
56
  started?: number
52
57
  requests: number
53
- total?: { usage: Usage; cost?: Cost }
54
58
  }
55
59
 
56
- /** 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)
60
+ /** Rows SessionPanel takes, border included. */
61
+ export function sessionRows(info: SessionInfo): number {
62
+ return 3 + (info.domain ? 1 : 0) + PANEL_CHROME
63
+ }
64
+
65
+ /** Top of the left pane: what the session works with, nothing more; tokens are in their own panel. */
66
+ export function SessionPanel({ info, width }: { info: SessionInfo; width: number }) {
67
+ const room = Math.max(10, width - 15)
59
68
  const row = (label: string, value: string, color: string = theme.text) => (
60
69
  <text key={label}>
61
- <span fg={theme.muted}>{label.padEnd(9)}</span>
70
+ <span fg={theme.muted}>{label.padEnd(10)}</span>
62
71
  <span fg={color}>{clip(value, room)}</span>
63
72
  </text>
64
73
  )
65
74
  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")}` : "–"
67
- const used = info.total ? `${formatTokens(info.total.usage.input + info.total.usage.output)} tokens${info.total.cost ? ` · ${formatCost(info.total.cost)}` : ""}` : "nothing yet"
75
+ const since = started ? ` · since ${String(started.getHours()).padStart(2, "0")}:${String(started.getMinutes()).padStart(2, "0")}` : ""
68
76
  return (
69
- <box style={{ flexDirection: "column", flexShrink: 0, paddingBottom: 1 }}>
70
- <SectionTitle title="SESSION" color={SECTION.session} right={`started ${clock}`} width={width} />
71
- {row("Model", `${info.profile || "none"} · ${info.model || "…"}`)}
72
- {row("Sign-in", info.auth, info.auth.startsWith("Entra") ? theme.ok : theme.text)}
77
+ <Panel title={`Session${since}`} color={SECTION.session} width={width}>
78
+ {row("Model", info.model)}
73
79
  {row("Folder", tailCut(info.cwd, room))}
74
- {row("Tests", info.tests, info.tests.startsWith("written") ? theme.ok : theme.muted)}
75
- {row("Browser", info.browser)}
76
80
  {info.domain ? row("Domain", info.domain, theme.memory) : null}
77
- {row("Used", `${info.requests} request${info.requests === 1 ? "" : "s"} · ${used}`, theme.muted)}
78
- </box>
81
+ {row("Requests", String(info.requests), theme.muted)}
82
+ </Panel>
79
83
  )
80
84
  }
81
85
 
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}`
86
+ /** The right pane's first line: who is working on what and for how long, and Stop while something runs. */
87
+ export function StatusBar(props: { running: boolean; who: string; activity: string; elapsed: string; status: string; width: number; onStop: () => void }) {
88
+ const done = props.status === "done"
89
+ const failed = props.status === "fail"
90
+ const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
91
+ const what = props.running
92
+ ? props.activity || "working…"
93
+ : done
94
+ ? "Last request done"
95
+ : failed
96
+ ? "Last request failed"
97
+ : props.status === "stopped"
98
+ ? "Last request stopped"
99
+ : "Ready for your next request"
100
+ const room = Math.max(10, props.width - props.who.length - (props.running ? 30 : 6))
86
101
  return (
87
- <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0 }}>
102
+ <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
88
103
  <box style={{ flexDirection: "row" }}>
89
- {props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={statusColor}>{"●"}</text>}
104
+ {props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
90
105
  <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}
106
+ {props.running ? <span fg={theme.text}>{` ${props.who}`}</span> : null}
107
+ <span fg={props.running ? theme.muted : color}>{`${props.running ? " " : " "}${clip(what, room)}`}</span>
93
108
  </text>
94
109
  </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>
110
+ {props.running ? (
111
+ <box style={{ flexDirection: "row" }}>
112
+ <text fg={theme.faint}>{`${props.elapsed} `}</text>
113
+ <box onMouseDown={props.onStop} style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.warn }}>
114
+ <text fg={theme.onButton}>{"■ Stop · esc"}</text>
115
+ </box>
116
+ </box>
117
+ ) : null}
101
118
  </box>
102
119
  )
103
120
  }
@@ -115,148 +132,179 @@ const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
115
132
  skipped: ["■", theme.faint],
116
133
  }
117
134
 
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 }[] = []
135
+ type PlanLine = { mark: string; color: string; text: string; textColor: string }
136
+
137
+ function planLines(s: Inspector): PlanLine[] {
138
+ const lines: PlanLine[] = []
121
139
  for (const p of s.plan) {
122
140
  const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
123
141
  lines.push({ mark, color, text: p.text, textColor: p.status === "completed" || p.status === "cancelled" ? theme.muted : theme.text })
124
142
  }
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
- }
143
+ if (s.progress.items.length && s.plan.length) lines.push({ mark: "─", color: theme.border, text: s.progress.title, textColor: theme.faint })
144
+ for (const i of s.progress.items) {
145
+ const [mark, color] = PROGRESS_MARK[i.status]
146
+ 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
147
  }
148
+ return lines
149
+ }
150
+
151
+ /** Rows the plan panel takes, border included; 0 while there is nothing to plan or follow. */
152
+ export function planRows(s: Inspector, cap: number): number {
153
+ const n = planLines(s).length
154
+ return n ? Math.min(cap, n) + PANEL_CHROME : 0
155
+ }
156
+
157
+ /** The agent's to-do list, then the browser steps or suite cases; the latest that fit. */
158
+ export function PlanPanel({ s, width, height }: { s: Inspector; width: number; height: number }) {
159
+ const lines = planLines(s)
132
160
  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))
161
+ const count = s.plan.length ? ` · ${done} of ${s.plan.length}` : s.progress.items.length ? ` · ${clip(s.progress.title, 40)}` : ""
162
+ const shown = lines.slice(-Math.max(1, height - PANEL_CHROME))
135
163
  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
- )}
164
+ <Panel title={`Plan${count}`} color={SECTION.plan} width={width} height={height}>
165
+ {shown.map((l, i) => (
166
+ <text key={i}>
167
+ <span fg={l.color}>{`${l.mark} `}</span>
168
+ <span fg={l.textColor}>{clip(l.text, Math.max(10, width - 8))}</span>
169
+ </text>
170
+ ))}
171
+ </Panel>
172
+ )
173
+ }
174
+
175
+ /** Each kind of activity line: its mark, the mark's colour, and the text's colour. */
176
+ const ACTIVITY_STYLE: Record<string, [string, string, string]> = {
177
+ tool: ["⚙", theme.faint, theme.muted],
178
+ file: ["✎", theme.accent, theme.text],
179
+ code: ["▸", theme.memory, theme.memory],
180
+ timing: ["+", theme.warn, theme.warn],
181
+ memory: ["↻", theme.memory, theme.memory],
182
+ thought: [" ", theme.thought, theme.thought],
183
+ ok: ["✓", theme.ok, theme.text],
184
+ err: ["✗", theme.err, theme.text],
185
+ }
186
+
187
+ /** One activity line, its mark in a column of its own so wrapped text stays under the text. */
188
+ export function ActivityRow({ line }: { line: Line }) {
189
+ const [mark, markColor, textColor] = ACTIVITY_STYLE[line.kind] || ["·", theme.faint, theme.muted]
190
+ return (
191
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
192
+ <text fg={markColor} style={{ width: 2, flexShrink: 0 }}>
193
+ {mark}
194
+ </text>
195
+ <text fg={textColor} style={{ flexGrow: 1, flexShrink: 1 }}>
196
+ {line.text || " "}
197
+ </text>
148
198
  </box>
149
199
  )
150
200
  }
151
201
 
152
202
  /** Everything the agents do, newest at the bottom. */
153
- export function ActivityLog(props: { lines: Line[]; scrollRef: RefObject<ScrollBoxRenderable | null>; height: number; width: number }) {
203
+ export function ActivityPanel(props: { lines: Line[]; scrollRef: RefObject<ScrollBoxRenderable | null>; width: number; height: number }) {
154
204
  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>
205
+ <Panel title={`Activity${props.lines.length ? ` · ${props.lines.length}` : ""}`} color={SECTION.activity} width={props.width} height={props.height}>
206
+ {props.lines.length ? (
207
+ <scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
208
+ {props.lines.map((line) => (
209
+ <ActivityRow key={line.id} line={line} />
210
+ ))}
211
+ </scrollbox>
212
+ ) : (
213
+ <box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
214
+ <text fg={theme.faint}>{clip("Tool calls, browser steps, and the agents' reasoning show here", Math.max(10, props.width - 4))}</text>
215
+ </box>
216
+ )}
217
+ </Panel>
165
218
  )
166
219
  }
167
220
 
168
- const FILE_MARK: Record<FileUse["last"], [string, string]> = { read: ["◉", theme.accent], write: ["✎", theme.ok], edit: ["✎", theme.warn] }
221
+ const FILE_MARK: Record<FileUse["last"], [string, string]> = { read: ["◉", theme.accent], write: ["✚", theme.ok], edit: ["✎", theme.warn] }
169
222
 
170
- export function FilesPanel({ s, width, rows }: { s: Inspector; width: number; rows: number }) {
223
+ /** "PayBeneficiaryTests.cs tests/Payments": the name first, its folder after it, dimmed. */
224
+ function fileLabel(path: string, room: number): [string, string] {
225
+ const cut = path.lastIndexOf("/")
226
+ const name = cut < 0 ? path : path.slice(cut + 1)
227
+ const dir = cut < 0 ? "" : path.slice(0, cut)
228
+ if (name.length >= room - 2) return [clip(name, room), ""]
229
+ // A folder cut to a few characters says nothing: leave it out rather than show "…ts".
230
+ const space = room - name.length - 2
231
+ return [name, dir && (dir.length <= space || space >= 12) ? tailCut(dir, space) : ""]
232
+ }
233
+
234
+ export function FilesPanel({ s, width, height }: { s: Inspector; width: number; height: number }) {
171
235
  const files = filesByUse(s)
172
- const room = Math.max(8, width - 9)
236
+ const rows = height - PANEL_CHROME
237
+ const room = Math.max(8, width - 10)
238
+ const listed = files.length > rows ? files.slice(0, rows - 1) : files
173
239
  return (
174
- <box style={{ flexDirection: "column", width, paddingRight: 1 }}>
175
- <SectionTitle title="FILES" color={SECTION.files} right={files.length ? String(files.length) : ""} width={width} />
240
+ <Panel title={`Files${files.length ? ` · ${files.length}` : ""}`} color={SECTION.files} width={width} height={height}>
176
241
  {files.length ? (
177
- files.slice(0, rows).map((f) => {
242
+ listed.map((f) => {
178
243
  const [mark, color] = FILE_MARK[f.last]
244
+ const [name, dir] = fileLabel(f.path, room)
179
245
  const times = f.reads + f.changes
180
246
  return (
181
247
  <text key={f.path}>
182
248
  <span fg={color}>{`${mark} `}</span>
183
- <span fg={f.changes ? theme.text : theme.muted}>{tailCut(f.path, room)}</span>
249
+ <span fg={f.changes ? theme.text : theme.muted}>{name}</span>
250
+ {dir ? <span fg={theme.faint}>{` ${dir}`}</span> : null}
184
251
  {times > 1 ? <span fg={theme.faint}>{` ×${times}`}</span> : null}
185
252
  </text>
186
253
  )
187
254
  })
188
255
  ) : (
189
- <text fg={theme.faint}>{"No files yet"}</text>
256
+ <text fg={theme.faint}>{"None yet"}</text>
190
257
  )}
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>
258
+ {files.length > listed.length ? <text fg={theme.faint}>{`+${files.length - listed.length} more`}</text> : null}
259
+ </Panel>
201
260
  )
202
261
  }
203
262
 
204
263
  export type ContextInfo = { tokens: number; limit: number; skills: string[]; knowledge: number; domain?: string; mcp: string[]; capabilities: number }
205
264
 
206
265
  /** 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)
266
+ export function ContextPanel({ c, width, height }: { c: ContextInfo; width: number; height: number }) {
267
+ const inner = Math.max(6, width - 4)
209
268
  const share = c.limit ? Math.min(1, c.tokens / c.limit) : 0
210
- const filled = Math.round(share * barWidth)
269
+ const filled = Math.round(share * inner)
211
270
  const color = share < 0.6 ? theme.ok : share < 0.85 ? theme.warn : theme.err
212
- const room = Math.max(6, width - 12)
271
+ const room = Math.max(6, inner - 11)
213
272
  const row = (label: string, value: string, valueColor: string = theme.text) => (
214
273
  <text key={label}>
215
- <span fg={theme.muted}>{label.padEnd(10)}</span>
274
+ <span fg={theme.muted}>{label.padEnd(11)}</span>
216
275
  <span fg={valueColor}>{clip(value, room)}</span>
217
276
  </text>
218
277
  )
219
278
  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} />
279
+ <Panel title={`Context · ${Math.round(share * 100)}%`} color={SECTION.context} width={width} height={height}>
222
280
  <text>
223
281
  <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>
282
+ <span fg={theme.border}>{"█".repeat(inner - filled)}</span>
226
283
  </text>
284
+ <text fg={theme.muted}>{`${formatTokens(c.tokens)} of ${formatTokens(c.limit)} tokens`}</text>
227
285
  {row("Skills", c.skills.length ? c.skills.join(", ") : "none loaded", c.skills.length ? theme.ok : theme.faint)}
228
286
  {row("Knowledge", c.knowledge ? `${c.knowledge} product file${c.knowledge === 1 ? "" : "s"}` : "none", c.knowledge ? theme.text : theme.faint)}
229
287
  {row("Domain", c.domain || "not declared", c.domain ? theme.memory : theme.faint)}
230
288
  {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>
289
+ </Panel>
233
290
  )
234
291
  }
235
292
 
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))
293
+ /** What the session used, in tokens: the total and how it splits. Costs are left to reports and `forgeqe models`. */
294
+ export function TokensPanel(props: { total: Usage; width: number; height: number }) {
295
+ const { total, width } = props
296
+ const line = (label: string, n: number, color: string = theme.text) => (
297
+ <text key={label}>
298
+ <span fg={theme.muted}>{label.padEnd(10)}</span>
299
+ <span fg={color}>{formatTokens(n).padStart(Math.max(7, Math.min(10, width - 14)))}</span>
300
+ </text>
301
+ )
239
302
  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 ? (
253
- <text>
254
- <span fg={theme.muted}>{"Total".padEnd(labelWidth)}</span>
255
- <span fg={theme.ok}>{amount(total.usage, total.cost)}</span>
256
- </text>
257
- ) : null}
258
- <text fg={theme.faint}>{`in ${formatTokens(total.usage.input)} · out ${formatTokens(total.usage.output)}`}</text>
259
- </box>
303
+ <Panel title="Tokens" color={SECTION.tokens} width={width} height={props.height}>
304
+ {line("Total", total.input + total.output, theme.ok)}
305
+ {line("Input", total.input)}
306
+ {line("Output", total.output)}
307
+ {line("Cached", total.cached || 0, theme.muted)}
308
+ </Panel>
260
309
  )
261
310
  }
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