@capagents/forgeqe 1.1.6 → 1.1.7

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/HOW_TO_USE.md CHANGED
@@ -463,8 +463,8 @@ The chat gets a short summary and a card with both paths as links, and **Open th
463
463
  The Activities column shows what the agents do as they do it:
464
464
 
465
465
  - the status line: who is working, a progress bar for the plan or a suite's cases, for how long, and **■ Stop**; under it, the whole width, what is happening now (*Agent: Azure DevOps API · QI-Payments/_apis/build/builds…*);
466
- - **Plan**: the agent's own task list as soon as it writes one, then each browser step or suite case: ○ pending, ◐ in progress, ✓ done, ✗ failed and why. ForgeQE's agent writes a plan for any request of three steps or more and updates it as it goes;
467
- - **Activity**: **Now** (the task in progress) and **Next** (the next task), then every tool call, browser step, and note. A call shows ◐ the moment it starts and becomes ✓ or ✗ in the same row when it ends, so a long API query or test run is never invisible. Reads and searches that the conversation leaves out at the brief detail level are listed here. A call takes one row and a note up to three; focus the panel to scroll through everything or filter it.
466
+ - **Plan**: the agent's own task list as soon as it writes one, then each browser step or suite case: ○ pending, a spinner while in progress, ✓ done, ✗ failed and why. ForgeQE's agent writes a plan for any request of three steps or more and updates it as it goes;
467
+ - **Activity**: **Now** (the task in progress) and **Next** (the next task), then every tool call, browser step, and note. A call shows a spinner the moment it starts and becomes ✓ or ✗ in the same row when it ends, so a long API query or test run is never invisible. Reads and searches that the conversation leaves out at the brief detail level are listed here. A call takes one row and a note up to three; focus the panel to scroll through everything or filter it. Each request starts under a line with its time and what you asked (*── 14:02 can you generate a defect report… ──*) and ends with how it went (*✓ Done in 1m 12s · 16 activities*, or ✗ Failed, ■ Stopped, ? Waiting for your answer), so one request's activities are never read as another's; earlier requests stay above, to scroll back to. The Plan shows the current request only.
468
468
 
469
469
  Over the conversation: **Files** (each file read ◉, written ✚, or edited ✎, newest first), **Context** (how full the model's context window is, and the skills, product knowledge, domain, and MCP servers in it), and **Tokens** (total, input, output, cached). When that half is under 80 columns they fold into one line: *Files 4 (1 changed) · Context 26% · Skills 2 · 52.9K tokens*.
470
470
 
@@ -719,8 +719,8 @@ Add a GitHub Actions workflow that runs tests/cases.yaml on every PR, and make i
719
719
  <p>The Activities column shows what the agents do as they do it:</p>
720
720
  <ul>
721
721
  <li>the status line: who is working, a progress bar for the plan or a suite's cases, for how long, and <strong>■ Stop</strong>; under it, the whole width, what is happening now (<em>Agent: Azure DevOps API · QI-Payments/_apis/build/builds…</em>);</li>
722
- <li><strong>Plan</strong>: the agent's own task list as soon as it writes one, then each browser step or suite case: ○ pending, ◐ in progress, ✓ done, ✗ failed and why. ForgeQE's agent writes a plan for any request of three steps or more and updates it as it goes;</li>
723
- <li><strong>Activity</strong>: <strong>Now</strong> (the task in progress) and <strong>Next</strong> (the next task), then every tool call, browser step, and note. A call shows ◐ the moment it starts and becomes ✓ or ✗ in the same row when it ends, so a long API query or test run is never invisible. Reads and searches that the conversation leaves out at the brief detail level are listed here. A call takes one row and a note up to three; focus the panel to scroll through everything or filter it.</li>
722
+ <li><strong>Plan</strong>: the agent's own task list as soon as it writes one, then each browser step or suite case: ○ pending, a spinner while in progress, ✓ done, ✗ failed and why. ForgeQE's agent writes a plan for any request of three steps or more and updates it as it goes;</li>
723
+ <li><strong>Activity</strong>: <strong>Now</strong> (the task in progress) and <strong>Next</strong> (the next task), then every tool call, browser step, and note. A call shows a spinner the moment it starts and becomes ✓ or ✗ in the same row when it ends, so a long API query or test run is never invisible. Reads and searches that the conversation leaves out at the brief detail level are listed here. A call takes one row and a note up to three; focus the panel to scroll through everything or filter it. Each request starts under a line with its time and what you asked (<em>── 14:02 can you generate a defect report… ──</em>) and ends with how it went (<em>✓ Done in 1m 12s · 16 activities</em>, or ✗ Failed, ■ Stopped, ? Waiting for your answer), so one request's activities are never read as another's; earlier requests stay above, to scroll back to. The Plan shows the current request only.</li>
724
724
  </ul>
725
725
  <p>Over the conversation: <strong>Files</strong> (each file read ◉, written ✚, or edited ✎, newest first), <strong>Context</strong> (how full the model's context window is, and the skills, product knowledge, domain, and MCP servers in it), and <strong>Tokens</strong> (total, input, output, cached). When that half is under 80 columns they fold into one line: <em>Files 4 (1 changed) · Context 26% · Skills 2 · 52.9K tokens</em>.</p>
726
726
  <p><strong>Buttons.</strong> Under the input: <strong>✦ Refine</strong> (Ctrl-R; it fills in once there is text to refine), <strong>⌕ Search</strong> (Ctrl-P, the command palette), and <strong>▦ Panels</strong> (Ctrl-O); while a request runs, <strong>■ Stop</strong> (Esc) takes Refine's place. With the panels open, <strong>▦ Focus panels</strong> (Shift-Tab) and <strong>✕ Close</strong> (Ctrl-O) are in their status line. On a narrow terminal, <strong>◧ Details</strong> (Ctrl-O) and <strong>⇄ Profile</strong> (Tab) are under the input. Each is clickable.</p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
4
  "description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/mcp/server.ts CHANGED
@@ -24,6 +24,13 @@ export type McpServerOptions = {
24
24
  progressMs?: number
25
25
  }
26
26
 
27
+ /** A ForgeQE tool call that has no line of its own, named for the Activity panel by its tool and its subject. */
28
+ export function callLabel(name: string, args: Record<string, unknown>): string {
29
+ const subject = [args.title, args.goal, args.source, args.sources, args.run_dir, args.command].find((v) => typeof v === "string" && v.trim()) as string | undefined
30
+ const flat = subject?.replace(/\s+/g, " ").trim()
31
+ return `ForgeQE · ${name.replace(/_/g, " ")}${flat ? ` (${flat.length > 70 ? `${flat.slice(0, 69)}…` : flat})` : ""}`
32
+ }
33
+
27
34
  /** JSON-RPC dispatch for MCP: initialize, ping, tools/list, tools/call, and cancellation. */
28
35
  export function createMcpServer(opts: McpServerOptions) {
29
36
  const running = new Map<string | number, AbortController>()
@@ -63,10 +70,13 @@ export function createMcpServer(opts: McpServerOptions) {
63
70
  }
64
71
  : undefined
65
72
  const callId = `${tool.name}-${String(id)}-${started}`
66
- const said = (ok: boolean) => {
67
- if (tool.line && emit) emit({ type: "call", text: tool.line(params.arguments || {}), ok, id: callId })
68
- }
69
- if (tool.line && emit) emit({ type: "call-start", text: tool.line(params.arguments || {}), id: callId })
73
+ // Every call is said as it starts and ends. A tool with no line of its own shows its result another way (a card,
74
+ // a run), so its call is named for the Activity panel only: "ForgeQE · write report (Open defects, 13 October)".
75
+ const args = params.arguments || {}
76
+ const label = tool.line ? tool.line(args) : callLabel(tool.name, args)
77
+ const quiet = tool.line ? {} : { quiet: true }
78
+ const said = (ok: boolean) => emit?.({ type: "call", text: label, ok, id: callId, ...quiet })
79
+ emit?.({ type: "call-start", text: label, id: callId, ...quiet })
70
80
  let out: unknown
71
81
  try {
72
82
  out = await tool.run(params.arguments || {}, { ...base, emit, signal: controller.signal })
package/src/mcp/tools.ts CHANGED
@@ -102,9 +102,12 @@ export type ToolEvent =
102
102
  /** write_report saved a report: its page and its Markdown. */
103
103
  | { type: "report"; title: string; html: string; markdown: string }
104
104
  /** A finished call of a tool that has no card of its own, as one line: "Azure DevOps API · QI-Claims/_apis/build/builds (top 5)". */
105
- | { type: "call"; text: string; ok: boolean; id?: string }
106
- /** The same call as it starts, so a long one is visible while it runs; its `call` with the same `id` follows. */
107
- | { type: "call-start"; text: string; id: string }
105
+ | { type: "call"; text: string; ok: boolean; id?: string; quiet?: boolean }
106
+ /**
107
+ * The same call as it starts, so a long one is visible while it runs; its `call` with the same `id` follows.
108
+ * `quiet`: a tool that shows its own result (a card, a run): the call is for the Activity panel only.
109
+ */
110
+ | { type: "call-start"; text: string; id: string; quiet?: boolean }
108
111
 
109
112
  const API_LABEL: Record<string, string> = { azure_devops: "Azure DevOps", jira: "Jira", xray: "Xray", confluence: "Confluence" }
110
113
 
@@ -114,7 +117,12 @@ const API_LABEL: Record<string, string> = { azure_devops: "Azure DevOps", jira:
114
117
  */
115
118
  export function queryLine(args: Record<string, any>): string {
116
119
  const name = `${API_LABEL[String(args.system)] || String(args.system || "API")} API`
117
- if (args.wiql) return `${name} · WIQL (${clip(String(args.wiql).replace(/\s+/g, " ").replace(/^SELECT .*? FROM WorkItems WHERE /i, ""), 90)})`
120
+ if (args.wiql) {
121
+ // The project first: it is what tells one search from the next, and the @project clause then says nothing more.
122
+ const where = String(args.wiql).replace(/\s+/g, " ").replace(/^SELECT .*? FROM WorkItems WHERE /i, "")
123
+ const query = args.project ? where.replace(/^\[System\.TeamProject\] = @project AND /i, "") : where
124
+ return `${name} · WIQL${args.project ? ` · ${args.project}` : ""} (${clip(query, 90)})`
125
+ }
118
126
  if (args.query) return `${name} · GraphQL (${clip(String(args.query).replace(/\s+/g, " "), 90)})`
119
127
  const raw = String(args.path || "")
120
128
  const url = /^https?:\/\//.test(raw) ? raw.replace(/^https?:\/\/[^/]+\/(?:[^/]+\/(?=[^/]+\/_apis\/))?/, "") : raw.replace(/^\//, "")
package/src/tui/App.tsx CHANGED
@@ -30,7 +30,7 @@ import { initWorkspace } from "../init.ts"
30
30
  import { ShortcutsPanel } from "./shortcuts.tsx"
31
31
  import { ago, getStarted, projectLine, setupSentence, type Fact, type GetStarted, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
32
32
  import { TIPS, formatDuration, formatTokens, packageVersion, shortCwd, theme } from "./theme.ts"
33
- import { emptyProduced, Line, Produced, producedCard, settleCall, TestsSetting, TranscriptLine, trimLines } from "./transcript.tsx"
33
+ import { emptyProduced, Line, Produced, producedCard, settleCall, sinceRequest, TestsSetting, TranscriptLine, trimLines } from "./transcript.tsx"
34
34
  import { Dialog, dialogSpec } from "./dialogs.ts"
35
35
  import { activityFromLog, clip, modelLabel } from "./text.ts"
36
36
  import { featuresText } from "../capabilities/render.ts"
@@ -444,7 +444,7 @@ export function App({ options }: { options: TuiOptions }) {
444
444
  reloadAfterAuth()
445
445
  return
446
446
  }
447
- const controller = begin("Signing in…")
447
+ const controller = begin("Signing in…", system ? `Sign in to ${system}` : "Sign in")
448
448
  controller.signal.addEventListener("abort", () => settleEntry(null), { once: true })
449
449
  let finalStatus = "fail"
450
450
  try {
@@ -972,7 +972,7 @@ export function App({ options }: { options: TuiOptions }) {
972
972
  if (!aborted()) setActivity(`Agent: ${clip(e.text, 60)}…`)
973
973
  return
974
974
  case "call":
975
- pushTool(e.text, e.ok, e.id)
975
+ pushTool(e.text, e.ok, e.id, !e.quiet)
976
976
  if (!aborted()) setActivity(nowWorking())
977
977
  return
978
978
  case "agent": {
@@ -1037,6 +1037,9 @@ export function App({ options }: { options: TuiOptions }) {
1037
1037
  return
1038
1038
  case "report":
1039
1039
  producedRef.current.reports.push({ title: e.title, html: e.html })
1040
+ // What the report wrote is a file the request made: it is in Files with the rest.
1041
+ for (const f of [e.markdown, e.html]) useFile(inspRef.current, { path: path.relative(process.cwd(), f) || f, action: "write" })
1042
+ bump()
1040
1043
  return
1041
1044
  }
1042
1045
  }
@@ -1078,10 +1081,12 @@ export function App({ options }: { options: TuiOptions }) {
1078
1081
  ask,
1079
1082
  })
1080
1083
 
1081
- const begin = (activityText: string) => {
1084
+ /** `request`: what was asked, which heads this request's lines in Activity so they are not read as the last one's. */
1085
+ const begin = (activityText: string, request: string) => {
1082
1086
  const controller = new AbortController()
1083
1087
  abortRef.current = controller
1084
1088
  started.current = Date.now()
1089
+ pushLine({ kind: "request", text: request.replace(/\s+/g, " ").trim(), pane: "activity", quiet: true, at: started.current })
1085
1090
  usedRef.current = new Map()
1086
1091
  producedRef.current = emptyProduced()
1087
1092
  newRequest(inspRef.current)
@@ -1095,8 +1100,16 @@ export function App({ options }: { options: TuiOptions }) {
1095
1100
  }
1096
1101
 
1097
1102
  const finish = (finalStatus: string, followUps: FollowUp[] = []) => {
1098
- // A call the request ended inside of (stopped, or its result never came) is not left spinning in Activity.
1099
- setLines((prev) => (prev.some((l) => l.kind === "running") ? prev.map((l) => (l.kind === "running" ? { ...l, kind: "tool" as const, text: `${l.text} · ${finalStatus === "stopped" ? "stopped" : "no result"}` } : l)) : prev))
1103
+ // A call the request ended inside of (stopped, or its result never came) is not left spinning in Activity; then the
1104
+ // request's closing line says how it ended, how long it took, and how many activities it had.
1105
+ const took = started.current != null ? formatDuration(Date.now() - started.current) : ""
1106
+ const ended = (["done", "fail", "stopped", "idle"] as const).find((x) => x === finalStatus) || "fail"
1107
+ const endId = idRef.current++
1108
+ setLines((prev) => {
1109
+ const settled = prev.some((l) => l.kind === "running") ? prev.map((l) => (l.kind === "running" ? { ...l, kind: "tool" as const, text: `${l.text} · ${finalStatus === "stopped" ? "stopped" : "no result"}` } : l)) : prev
1110
+ const n = sinceRequest(settled)
1111
+ return trimLines([...settled, { id: endId, kind: "requestEnd", text: `${took ? `in ${took} · ` : ""}${n} ${n === 1 ? "activity" : "activities"}`, pane: "activity", quiet: true, ended }])
1112
+ })
1100
1113
  setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on, followUps }))
1101
1114
  progressDone(inspRef.current, finalStatus === "stopped")
1102
1115
  bump()
@@ -1120,7 +1133,7 @@ export function App({ options }: { options: TuiOptions }) {
1120
1133
  directNoted.current = true
1121
1134
  push("sys", `ForgeQE's agent is off (${why}), so the text runs as a browser goal.${why.includes("agent runtime") ? ` ${INSTALL_HINT}` : " Press Tab for a live profile."} /features lists what ForgeQE can do.`)
1122
1135
  }
1123
- const controller = begin(cfg.llm.provider === "mock" ? "Running the offline demo…" : "Starting…")
1136
+ const controller = begin(cfg.llm.provider === "mock" ? "Running the offline demo…" : "Starting…", goal)
1124
1137
  testsRef.current = { on: false, why: why.includes("agent runtime") ? "the agent runtime Scribe needs is not installed" : "ForgeQE's agent is off in the offline profile" }
1125
1138
  let finalStatus = "fail"
1126
1139
  try {
@@ -1135,7 +1148,7 @@ export function App({ options }: { options: TuiOptions }) {
1135
1148
  }
1136
1149
 
1137
1150
  const askAgent = async (text: string, cfg: AppConfig) => {
1138
- const controller = begin("Thinking…")
1151
+ const controller = begin("Thinking…", text)
1139
1152
  const meterStart = meterSnapshot()
1140
1153
  setThinking(true)
1141
1154
  const setup = sessionSetup(cfg, options.repo)
@@ -1784,7 +1797,7 @@ export function App({ options }: { options: TuiOptions }) {
1784
1797
  <box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
1785
1798
  <StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress || planProgress(insp)} actions={paneButtons} />
1786
1799
  <box style={{ height: 1, flexShrink: 0 }} />
1787
- {plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel} onFocus={focusOn("plan")} /> : null}
1800
+ {plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel} onFocus={focusOn("plan")} animate={running} /> : null}
1788
1801
  <ActivityPanel
1789
1802
  lines={activityLines}
1790
1803
  scrollRef={activityScroll}
@@ -6,7 +6,7 @@ import { fitButtons, Spinner, type Button } from "./components.tsx"
6
6
  import { filesByUse, type FileUse, type Inspector, type Progress } from "./session.ts"
7
7
  import { clip, tailCut } from "./text.ts"
8
8
  import { formatTokens, theme } from "./theme.ts"
9
- import type { Line } from "./transcript.tsx"
9
+ import { isMarker, type Line } from "./transcript.tsx"
10
10
 
11
11
  /**
12
12
  * The split view's panels. Each is a rounded box with its title in the border, in its section's colour from the
@@ -171,18 +171,19 @@ const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
171
171
  skipped: ["■", theme.faint],
172
172
  }
173
173
 
174
- export type PlanLine = { mark: string; color: string; text: string; textColor: string }
174
+ /** `live`: a task in progress or a step running, drawn with a spinner while the request runs. */
175
+ export type PlanLine = { mark: string; color: string; text: string; textColor: string; live?: boolean }
175
176
 
176
177
  export function planLines(s: Inspector): PlanLine[] {
177
178
  const lines: PlanLine[] = []
178
179
  for (const p of s.plan) {
179
180
  const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
180
- lines.push({ mark, color, text: p.text, textColor: p.status === "completed" || p.status === "cancelled" ? theme.muted : theme.text })
181
+ lines.push({ mark, color, text: p.text, textColor: p.status === "completed" || p.status === "cancelled" ? theme.muted : theme.text, ...(p.status === "in_progress" ? { live: true } : {}) })
181
182
  }
182
183
  if (s.progress.items.length && s.plan.length) lines.push({ mark: "─", color: theme.border, text: s.progress.title, textColor: theme.faint })
183
184
  for (const i of s.progress.items) {
184
185
  const [mark, color] = PROGRESS_MARK[i.status]
185
- 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 })
186
+ 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, ...(i.status === "running" ? { live: true } : {}) })
186
187
  }
187
188
  return lines
188
189
  }
@@ -201,7 +202,7 @@ export function windowRows(total: number, rows: number, selected?: number): [num
201
202
  }
202
203
 
203
204
  /** The agent's to-do list, then the browser steps or suite cases; the latest that fit, or around the selected one. */
204
- export function PlanPanel(props: { s: Inspector; width: number; height: number; selected?: number; onFocus?: () => void }) {
205
+ export function PlanPanel(props: { s: Inspector; width: number; height: number; selected?: number; onFocus?: () => void; animate?: boolean }) {
205
206
  const { s, width, height } = props
206
207
  const lines = planLines(s)
207
208
  const done = s.plan.filter((p) => p.status === "completed").length
@@ -213,10 +214,9 @@ export function PlanPanel(props: { s: Inspector; width: number; height: number;
213
214
  const active = first + i === props.selected
214
215
  return (
215
216
  <box key={first + i} style={{ flexDirection: "row", height: 1, flexShrink: 0, backgroundColor: active ? theme.button : undefined }}>
216
- <text>
217
- <span fg={l.color}>{`${l.mark} `}</span>
218
- <span fg={active ? theme.text : l.textColor}>{clip(l.text, Math.max(10, width - 8))}</span>
219
- </text>
217
+ {/* What is being worked on now spins, so it reads as live; a task left open after the request does not. */}
218
+ <box style={{ width: 2, flexShrink: 0 }}>{l.live && props.animate ? <Spinner kind="dots" color={l.color} /> : <text fg={l.color}>{l.mark}</text>}</box>
219
+ <text fg={active ? theme.text : l.textColor}>{clip(l.text, Math.max(10, width - 8))}</text>
220
220
  </box>
221
221
  )
222
222
  })}
@@ -227,7 +227,8 @@ export function PlanPanel(props: { s: Inspector; width: number; height: number;
227
227
  /** Each kind of activity line: its mark, the mark's colour, and the text's colour. */
228
228
  const ACTIVITY_STYLE: Record<string, [string, string, string]> = {
229
229
  running: ["◐", theme.warn, theme.text],
230
- tool: ["⚙", theme.faint, theme.muted],
230
+ // A finished call: ✓, so it never reads as the ○ of a task still to do.
231
+ tool: ["✓", theme.ok, theme.muted],
231
232
  file: ["✎", theme.accent, theme.text],
232
233
  code: ["▸", theme.memory, theme.memory],
233
234
  timing: ["+", theme.warn, theme.warn],
@@ -255,6 +256,42 @@ function ToolCall({ parts: [name, args], width }: { parts: [string, string]; wid
255
256
  )
256
257
  }
257
258
 
259
+ const ENDED: Record<NonNullable<Line["ended"]>, [string, string, string]> = {
260
+ done: ["✓", "Done", theme.ok],
261
+ fail: ["✗", "Failed", theme.err],
262
+ stopped: ["■", "Stopped", theme.warn],
263
+ idle: ["?", "Waiting for your answer", theme.warn],
264
+ }
265
+
266
+ /** "── 14:02 can you generate a defect report… ─────": where a request's activities start. */
267
+ function RequestStart({ line, width, first }: { line: Line; width: number; first: boolean }) {
268
+ const d = new Date(line.at || Date.now())
269
+ const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
270
+ // The rule runs the whole row: the mark column and the text's.
271
+ const room = Math.max(12, (width || 60) + 3)
272
+ const text = clip(line.text, Math.max(8, room - time.length - 8))
273
+ const fill = Math.max(2, room - 3 - time.length - 2 - text.length - 1)
274
+ return (
275
+ <text style={{ height: 1, flexShrink: 0, marginTop: first ? 0 : 1 }}>
276
+ <span fg={theme.border}>{"── "}</span>
277
+ <span fg={theme.accent}>{time}</span>
278
+ <span fg={theme.text}>{` ${text} `}</span>
279
+ <span fg={theme.border}>{"─".repeat(fill)}</span>
280
+ </text>
281
+ )
282
+ }
283
+
284
+ /** "✓ Done in 1m 12s · 16 activities": how a request ended. */
285
+ function RequestEnd({ line, width }: { line: Line; width: number }) {
286
+ const [mark, label, color] = ENDED[line.ended || "done"]
287
+ return (
288
+ <text style={{ height: 1, flexShrink: 0 }}>
289
+ <span fg={color}>{`${mark} ${label}`}</span>
290
+ <span fg={theme.faint}>{` ${clip(line.text, Math.max(8, (width || 60) - label.length))}`}</span>
291
+ </text>
292
+ )
293
+ }
294
+
258
295
  /** Rows an activity line may take: a call is one row; narration and results up to three. */
259
296
  const NOTE_ROWS = 3
260
297
 
@@ -262,15 +299,24 @@ const NOTE_ROWS = 3
262
299
  * One activity line, its mark in a column of its own so wrapped text stays under the text. `width` is the text's
263
300
  * columns: a call is cut to one row and anything else to three, so a burst of output never takes the panel over.
264
301
  */
265
- export function ActivityRow({ line, width = 0 }: { line: Line; width?: number }) {
302
+ export function ActivityRow({ line, width = 0, first = false }: { line: Line; width?: number; first?: boolean }) {
303
+ if (line.kind === "request") return <RequestStart line={line} width={width} first={first} />
304
+ if (line.kind === "requestEnd") return <RequestEnd line={line} width={width} />
266
305
  const [mark, markColor, textColor] = ACTIVITY_STYLE[line.kind] || ["·", theme.faint, theme.muted]
267
306
  const oneRow = line.kind === "tool" || line.kind === "running" || line.kind === "file"
268
307
  const fit = (t: string, rows: number) => (width > 0 ? clip(t.replace(/\s+/g, " "), width * rows) : t)
269
308
  return (
270
309
  <box style={{ flexDirection: "row", flexShrink: 0 }}>
271
- <text fg={markColor} style={{ width: 2, flexShrink: 0 }}>
272
- {mark}
273
- </text>
310
+ {line.kind === "running" ? (
311
+ // A call in progress spins until its result takes the row.
312
+ <box style={{ width: 2, flexShrink: 0 }}>
313
+ <Spinner kind="dots" color={markColor} />
314
+ </box>
315
+ ) : (
316
+ <text fg={markColor} style={{ width: 2, flexShrink: 0 }}>
317
+ {mark}
318
+ </text>
319
+ )}
274
320
  {(line.kind === "tool" || line.kind === "running") && toolArgs(line.text) ? (
275
321
  // "Azure DevOps · pipelines build (action list, project QI-Claims)": the call readable, its arguments quiet.
276
322
  <ToolCall parts={toolArgs(line.text)!} width={width} />
@@ -292,9 +338,10 @@ export const ACTIVITY_FILTERS = [
292
338
  ] as const
293
339
  export type ActivityFilter = (typeof ACTIVITY_FILTERS)[number]["id"]
294
340
 
341
+ /** The lines a filter keeps; where each request starts and ends always stays, so a filtered line is never orphaned. */
295
342
  export function filterActivity(lines: Line[], filter: ActivityFilter): Line[] {
296
343
  const kinds = ACTIVITY_FILTERS.find((f) => f.id === filter)?.kinds
297
- return kinds ? lines.filter((l) => (kinds as readonly string[]).includes(l.kind)) : lines
344
+ return kinds ? lines.filter((l) => isMarker(l) || (kinds as readonly string[]).includes(l.kind)) : lines
298
345
  }
299
346
 
300
347
  /**
@@ -335,7 +382,9 @@ export function ActivityPanel(props: {
335
382
  const room = Math.max(10, props.width - 12)
336
383
  const shown = filterActivity(props.lines, filter)
337
384
  const label = ACTIVITY_FILTERS.find((f) => f.id === filter)!.label
338
- const count = filter === "all" ? (shown.length ? ` · ${shown.length}` : "") : ` · ${label} ${shown.length} of ${props.lines.length}`
385
+ // Counts are of activities: the lines marking where requests start and end are not.
386
+ const real = (ls: Line[]) => ls.filter((l) => !isMarker(l)).length
387
+ const count = filter === "all" ? (real(shown) ? ` · ${real(shown)}` : "") : ` · ${label} ${real(shown)} of ${real(props.lines)}`
339
388
  return (
340
389
  <Panel title={`Activity${count}`} color={SECTION.activity} width={props.width} height={props.height} focused={props.focused} keys="↑↓ pgup pgdn scroll · esc chat" onFocus={props.onFocus}>
341
390
  {props.focused || filter !== "all" ? (
@@ -354,11 +403,13 @@ export function ActivityPanel(props: {
354
403
  </box>
355
404
  ) : null}
356
405
  {props.now?.now ? (
357
- <text style={{ height: 1, flexShrink: 0 }}>
358
- <span fg={theme.muted}>{"Now "}</span>
359
- <span fg={theme.warn}>{"◐ "}</span>
360
- <span fg={theme.text}>{clip(props.now.now, room)}</span>
361
- </text>
406
+ <box style={{ flexDirection: "row", height: 1, flexShrink: 0 }}>
407
+ <text fg={theme.muted}>{"Now "}</text>
408
+ <box style={{ width: 2, flexShrink: 0 }}>
409
+ <Spinner kind="dots" color={theme.warn} />
410
+ </box>
411
+ <text fg={theme.text}>{clip(props.now.now, room)}</text>
412
+ </box>
362
413
  ) : null}
363
414
  {props.now?.next ? (
364
415
  <text style={{ height: 1, flexShrink: 0 }}>
@@ -367,16 +418,16 @@ export function ActivityPanel(props: {
367
418
  <span fg={theme.muted}>{clip(props.now.next, room)}</span>
368
419
  </text>
369
420
  ) : null}
370
- {shown.length ? (
421
+ {real(shown) ? (
371
422
  <scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
372
- {shown.map((line) => (
373
- <ActivityRow key={line.id} line={line} width={Math.max(10, props.width - 8)} />
423
+ {shown.map((line, i) => (
424
+ <ActivityRow key={line.id} line={line} width={Math.max(10, props.width - 8)} first={i === 0} />
374
425
  ))}
375
426
  </scrollbox>
376
427
  ) : (
377
428
  <box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
378
429
  <text fg={theme.faint}>
379
- {clip(props.lines.length ? `Nothing under ${label} yet` : "Tool calls, browser steps, and the agents' reasoning show here", Math.max(10, props.width - 4))}
430
+ {clip(real(props.lines) ? `Nothing under ${label} yet` : "Tool calls, browser steps, and the agents' reasoning show here", Math.max(10, props.width - 4))}
380
431
  </text>
381
432
  </box>
382
433
  )}
@@ -17,7 +17,7 @@ import { clip } from "./text.ts"
17
17
 
18
18
  export type Line = {
19
19
  id: number
20
- kind: "sys" | "user" | "thought" | "timing" | "memory" | "tool" | "file" | "code" | "ok" | "err" | "summary" | "suitesummary" | "reply" | "card" | "running"
20
+ kind: "sys" | "user" | "thought" | "timing" | "memory" | "tool" | "file" | "code" | "ok" | "err" | "summary" | "suitesummary" | "reply" | "card" | "running" | "request" | "requestEnd"
21
21
  text: string
22
22
  summary?: RunSummary
23
23
  suite?: SuiteResult
@@ -29,6 +29,24 @@ export type Line = {
29
29
  quiet?: boolean
30
30
  /** A tool call's id: its "running" line becomes the call's result line when the call finishes. */
31
31
  call?: string
32
+ /** A request's first line in Activity ("request"): when it started. */
33
+ at?: number
34
+ /** A request's last line in Activity ("requestEnd"): how it ended. */
35
+ ended?: "done" | "fail" | "stopped" | "idle"
36
+ }
37
+
38
+ /** Activity lines that mark where a request starts and ends; they are not activities themselves. */
39
+ export const isMarker = (l: Line) => l.kind === "request" || l.kind === "requestEnd"
40
+
41
+ /** The activities since the last request started, markers left out: what the request did. */
42
+ export function sinceRequest(lines: Line[]): number {
43
+ let n = 0
44
+ for (let i = lines.length - 1; i >= 0; i--) {
45
+ const l = lines[i]!
46
+ if (l.kind === "request") break
47
+ if (l.pane === "activity" && !isMarker(l)) n++
48
+ }
49
+ return n
32
50
  }
33
51
 
34
52
  /** Lines kept: the oldest quiet ones go first, so a busy run never pushes the conversation out. */