@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 +2 -2
- package/docs/user-guide.html +2 -2
- package/package.json +1 -1
- package/src/mcp/server.ts +14 -4
- package/src/mcp/tools.ts +12 -4
- package/src/tui/App.tsx +22 -9
- package/src/tui/inspector.tsx +76 -25
- package/src/tui/transcript.tsx +19 -1
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,
|
|
467
|
-
- **Activity**: **Now** (the task in progress) and **Next** (the next task), then every tool call, browser step, and note. A call shows
|
|
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
|
|
package/docs/user-guide.html
CHANGED
|
@@ -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,
|
|
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
|
|
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.
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
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
|
-
/**
|
|
107
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
package/src/tui/inspector.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
358
|
-
<
|
|
359
|
-
<
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
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
|
|
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
|
)}
|
package/src/tui/transcript.tsx
CHANGED
|
@@ -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. */
|