@capagents/forgeqe 1.0.0 → 1.0.2

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.
@@ -0,0 +1,262 @@
1
+ import type { ScrollBoxRenderable } from "@opentui/core"
2
+ import type { 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 { TranscriptLine, type Line } from "./transcript.tsx"
11
+
12
+ /** Each section of the split view has its own colour from the shell's palette; status colours keep their meaning. */
13
+ export const SECTION = {
14
+ session: theme.accent,
15
+ plan: theme.thought,
16
+ activity: theme.accent,
17
+ files: theme.memory,
18
+ context: theme.warn,
19
+ tokens: theme.ok,
20
+ }
21
+
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
+
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))
32
+ 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
+ )
41
+ }
42
+
43
+ export type SessionInfo = {
44
+ profile: string
45
+ model: string
46
+ auth: string
47
+ cwd: string
48
+ tests: string
49
+ browser: string
50
+ domain?: string
51
+ started?: number
52
+ requests: number
53
+ total?: { usage: Usage; cost?: Cost }
54
+ }
55
+
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)
59
+ const row = (label: string, value: string, color: string = theme.text) => (
60
+ <text key={label}>
61
+ <span fg={theme.muted}>{label.padEnd(9)}</span>
62
+ <span fg={color}>{clip(value, room)}</span>
63
+ </text>
64
+ )
65
+ 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"
68
+ 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)}
73
+ {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
+ {info.domain ? row("Domain", info.domain, theme.memory) : null}
77
+ {row("Used", `${info.requests} request${info.requests === 1 ? "" : "s"} · ${used}`, theme.muted)}
78
+ </box>
79
+ )
80
+ }
81
+
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
+ return (
87
+ <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0 }}>
88
+ <box style={{ flexDirection: "row" }}>
89
+ {props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={statusColor}>{"●"}</text>}
90
+ <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}
93
+ </text>
94
+ </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>
101
+ </box>
102
+ )
103
+ }
104
+
105
+ const PLAN_MARK: Record<string, [string, string]> = {
106
+ completed: ["✓", theme.ok],
107
+ in_progress: ["◐", theme.warn],
108
+ pending: ["○", theme.faint],
109
+ cancelled: ["✗", theme.faint],
110
+ }
111
+ const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
112
+ ok: ["✓", theme.ok],
113
+ running: ["◐", theme.warn],
114
+ fail: ["✗", theme.err],
115
+ skipped: ["■", theme.faint],
116
+ }
117
+
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 }[] = []
121
+ for (const p of s.plan) {
122
+ const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
123
+ lines.push({ mark, color, text: p.text, textColor: p.status === "completed" || p.status === "cancelled" ? theme.muted : theme.text })
124
+ }
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
+ }
131
+ }
132
+ 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))
135
+ 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
+ )}
148
+ </box>
149
+ )
150
+ }
151
+
152
+ /** Everything the agents do, newest at the bottom. */
153
+ export function ActivityLog(props: { lines: Line[]; scrollRef: RefObject<ScrollBoxRenderable | null>; height: number; width: number }) {
154
+ 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>
165
+ )
166
+ }
167
+
168
+ const FILE_MARK: Record<FileUse["last"], [string, string]> = { read: ["◉", theme.accent], write: ["✎", theme.ok], edit: ["✎", theme.warn] }
169
+
170
+ export function FilesPanel({ s, width, rows }: { s: Inspector; width: number; rows: number }) {
171
+ const files = filesByUse(s)
172
+ const room = Math.max(8, width - 9)
173
+ return (
174
+ <box style={{ flexDirection: "column", width, paddingRight: 1 }}>
175
+ <SectionTitle title="FILES" color={SECTION.files} right={files.length ? String(files.length) : ""} width={width} />
176
+ {files.length ? (
177
+ files.slice(0, rows).map((f) => {
178
+ const [mark, color] = FILE_MARK[f.last]
179
+ const times = f.reads + f.changes
180
+ return (
181
+ <text key={f.path}>
182
+ <span fg={color}>{`${mark} `}</span>
183
+ <span fg={f.changes ? theme.text : theme.muted}>{tailCut(f.path, room)}</span>
184
+ {times > 1 ? <span fg={theme.faint}>{` ×${times}`}</span> : null}
185
+ </text>
186
+ )
187
+ })
188
+ ) : (
189
+ <text fg={theme.faint}>{"No files yet"}</text>
190
+ )}
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>
201
+ )
202
+ }
203
+
204
+ export type ContextInfo = { tokens: number; limit: number; skills: string[]; knowledge: number; domain?: string; mcp: string[]; capabilities: number }
205
+
206
+ /** 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)
209
+ const share = c.limit ? Math.min(1, c.tokens / c.limit) : 0
210
+ const filled = Math.round(share * barWidth)
211
+ const color = share < 0.6 ? theme.ok : share < 0.85 ? theme.warn : theme.err
212
+ const room = Math.max(6, width - 12)
213
+ const row = (label: string, value: string, valueColor: string = theme.text) => (
214
+ <text key={label}>
215
+ <span fg={theme.muted}>{label.padEnd(10)}</span>
216
+ <span fg={valueColor}>{clip(value, room)}</span>
217
+ </text>
218
+ )
219
+ 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} />
222
+ <text>
223
+ <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>
226
+ </text>
227
+ {row("Skills", c.skills.length ? c.skills.join(", ") : "none loaded", c.skills.length ? theme.ok : theme.faint)}
228
+ {row("Knowledge", c.knowledge ? `${c.knowledge} product file${c.knowledge === 1 ? "" : "s"}` : "none", c.knowledge ? theme.text : theme.faint)}
229
+ {row("Domain", c.domain || "not declared", c.domain ? theme.memory : theme.faint)}
230
+ {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>
233
+ )
234
+ }
235
+
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))
239
+ 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>
260
+ )
261
+ }
262
+
@@ -0,0 +1,115 @@
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 }
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
+ /** 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 {
79
+ const running = [...s.progress.items].reverse().find((i) => i.status === "running" || i.status === "fail")
80
+ if (!running) return
81
+ if (!ok) {
82
+ running.status = "fail"
83
+ if (detail) running.detail = detail
84
+ }
85
+ }
86
+
87
+ /** Every step still running is over: done, or stopped when the run was. */
88
+ export function progressDone(s: Inspector, stopped = false): void {
89
+ for (const i of s.progress.items) if (i.status === "running") i.status = stopped ? "skipped" : "ok"
90
+ }
91
+
92
+ export function caseStarted(s: Inspector, id: string, title: string): void {
93
+ s.progress.items.push({ label: `${id} ${title}`, status: "running" })
94
+ }
95
+
96
+ export function caseDone(s: Inspector, id: string, status: "passed" | "failed" | "error" | "stopped", detail?: string): void {
97
+ const item = s.progress.items.find((i) => i.label.startsWith(`${id} `) && i.status === "running")
98
+ if (!item) return
99
+ item.status = status === "passed" ? "ok" : status === "stopped" ? "skipped" : "fail"
100
+ if (detail) item.detail = detail
101
+ }
102
+
103
+ /** The files, most recently used first. */
104
+ export function filesByUse(s: Inspector): FileUse[] {
105
+ return [...s.files.values()].sort((a, b) => b.order - a.order)
106
+ }
107
+
108
+ /** Which pane a transcript line belongs in: the conversation, or what the agents are doing. */
109
+ export type Pane = "chat" | "activity"
110
+ const CHAT_KINDS = new Set(["user", "reply", "card", "summary", "suitesummary"])
111
+
112
+ /** Results and the conversation go to the chat; while a request runs, everything else is activity. */
113
+ export function paneFor(kind: string, running: boolean): Pane {
114
+ return CHAT_KINDS.has(kind) || !running ? "chat" : "activity"
115
+ }
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"
@@ -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
@@ -118,12 +118,22 @@ export type CodeResult = {
118
118
  attention?: { dir: string; files: string[]; patch?: string; readme: string }
119
119
  }
120
120
 
121
+ /** What an agent did to a file, for the shell's file list. */
122
+ export type FileAction = "read" | "write" | "edit"
123
+
124
+ /** One item of an agent's own to-do list (the agent runtime's todowrite). */
125
+ export type PlanItem = { text: string; status: "pending" | "in_progress" | "completed" | "cancelled" }
126
+
121
127
  export type CodeEvent =
122
128
  | { type: "code"; kind: "info"; text: string }
123
- | { type: "code"; kind: "tool"; text: string; ok: boolean }
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 }; skill?: string }
124
131
  | { type: "code"; kind: "say"; text: string }
125
132
  | { type: "code"; kind: "test"; text: string; ok: boolean; attempt: number }
126
- | { type: "code"; kind: "usage"; usage: Usage }
133
+ /** The session's usage so far; `context` is the last model call's input, how full its context window is. */
134
+ | { type: "code"; kind: "usage"; usage: Usage; context?: number }
135
+ /** The agent's to-do list, each time it changes. */
136
+ | { type: "code"; kind: "plan"; items: PlanItem[] }
127
137
 
128
138
  export type AgentEvent =
129
139
  | { type: "log"; level: "info" | "warn" | "error"; text: string }