@capagents/forgeqe 1.1.4 → 1.1.6
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 +64 -17
- package/README.md +2 -1
- package/browserstack.example.yaml +14 -2
- package/docs/user-guide.html +78 -23
- package/forgeqe.example.yaml +18 -5
- package/package.json +2 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +8 -1
- package/prompts/defect-rca.md +43 -0
- package/prompts/test-agent.md +13 -2
- package/prompts/test-reviewer.md +8 -3
- package/prompts/test-rules.md +10 -4
- package/py/forgeqe_engine/browser.py +1 -1
- package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
- package/skills/playwright-typescript/SKILL.md +2 -1
- package/src/brain/index.ts +1 -1
- package/src/capabilities/registry.ts +34 -6
- package/src/cases/bugs.ts +209 -22
- package/src/cases/defectHistory.ts +193 -0
- package/src/cases/defects.ts +255 -0
- package/src/cases/github.ts +162 -0
- package/src/cases/goal.ts +1 -1
- package/src/cases/index.ts +23 -4
- package/src/cases/predict.ts +372 -0
- package/src/cases/rca.ts +258 -0
- package/src/cases/readiness.ts +38 -1
- package/src/cases/repro.ts +202 -0
- package/src/cases/types.ts +8 -1
- package/src/cli/help.ts +8 -2
- package/src/cli/index.ts +36 -7
- package/src/code/graph/graph.ts +518 -0
- package/src/code/graph/locators.ts +398 -0
- package/src/code/graph/parse.ts +439 -0
- package/src/code/graph/tools.ts +113 -0
- package/src/code/handoff.ts +93 -18
- package/src/code/loop.ts +30 -5
- package/src/code/profile.ts +14 -3
- package/src/code/replay.ts +1 -1
- package/src/code/review.ts +22 -0
- package/src/code/runtime.ts +8 -1
- package/src/config.ts +142 -18
- package/src/mcp/attachtools.ts +223 -0
- package/src/mcp/commands.ts +1 -1
- package/src/mcp/defecttools.ts +106 -0
- package/src/mcp/devicetools.ts +125 -0
- package/src/mcp/external.ts +42 -1
- package/src/mcp/server.ts +3 -1
- package/src/mcp/tools.ts +95 -10
- package/src/monitor/run.ts +1 -1
- package/src/report/data.ts +14 -0
- package/src/report/html.ts +15 -3
- package/src/report/template.html +44 -43
- package/src/run.ts +4 -2
- package/src/suite/report.ts +44 -11
- package/src/suite/run.ts +163 -16
- package/src/tui/App.tsx +153 -94
- package/src/tui/components.tsx +27 -23
- package/src/tui/inspector.tsx +140 -83
- package/src/tui/landing.ts +3 -4
- package/src/tui/refine-panel.tsx +102 -52
- package/src/tui/shortcuts.tsx +2 -0
- package/src/tui/transcript.tsx +32 -2
- package/src/types.ts +3 -1
- package/src/usage.ts +1 -1
- package/testmu.example.yaml +11 -0
package/src/tui/inspector.tsx
CHANGED
|
@@ -66,41 +66,6 @@ export function Panel(props: { title: string; color: string; width?: number; hei
|
|
|
66
66
|
)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
export type SessionInfo = {
|
|
70
|
-
/** "gpt-5.4 (azure-gpt54)": the model, and the llms.json profile it comes from. */
|
|
71
|
-
model: string
|
|
72
|
-
cwd: string
|
|
73
|
-
domain?: string
|
|
74
|
-
started?: number
|
|
75
|
-
requests: number
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Rows SessionPanel takes, border included. */
|
|
79
|
-
export function sessionRows(info: SessionInfo): number {
|
|
80
|
-
return 3 + (info.domain ? 1 : 0) + PANEL_CHROME
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Top of the left pane: what the session works with, nothing more; tokens are in their own panel. */
|
|
84
|
-
export function SessionPanel({ info, width }: { info: SessionInfo; width: number }) {
|
|
85
|
-
const room = Math.max(10, width - 15)
|
|
86
|
-
const row = (label: string, value: string, color: string = theme.text) => (
|
|
87
|
-
<text key={label}>
|
|
88
|
-
<span fg={theme.muted}>{label.padEnd(10)}</span>
|
|
89
|
-
<span fg={color}>{clip(value, room)}</span>
|
|
90
|
-
</text>
|
|
91
|
-
)
|
|
92
|
-
const started = info.started ? new Date(info.started) : null
|
|
93
|
-
const since = started ? ` · since ${String(started.getHours()).padStart(2, "0")}:${String(started.getMinutes()).padStart(2, "0")}` : ""
|
|
94
|
-
return (
|
|
95
|
-
<Panel title={`Session${since}`} color={SECTION.session} width={width}>
|
|
96
|
-
{row("Model", info.model)}
|
|
97
|
-
{row("Folder", tailCut(info.cwd, room))}
|
|
98
|
-
{info.domain ? row("Domain", info.domain, theme.memory) : null}
|
|
99
|
-
{row("Requests", String(info.requests), theme.muted)}
|
|
100
|
-
</Panel>
|
|
101
|
-
)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
69
|
/** The right pane's first line: who is working on what and for how long, and Stop while something runs. */
|
|
105
70
|
/** How far the agent's own plan has got, while it has one of two steps or more. */
|
|
106
71
|
export function planProgress(s: Inspector): { done: number; total: number } | null {
|
|
@@ -113,6 +78,13 @@ export function progressBar(done: number, total: number, cells: number): [string
|
|
|
113
78
|
return ["█".repeat(filled), "░".repeat(cells - filled)]
|
|
114
79
|
}
|
|
115
80
|
|
|
81
|
+
/** Rows the status bar takes: who and how far on the first, what is happening on the second. */
|
|
82
|
+
export const STATUS_ROWS = 2
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The Activities column's first lines. The first: who is working, how far a plan or suite has got, for how long, and
|
|
86
|
+
* Stop. The second, the whole width: what is happening now, so it is never squeezed out by the rest.
|
|
87
|
+
*/
|
|
116
88
|
export function StatusBar(props: {
|
|
117
89
|
running: boolean
|
|
118
90
|
who: string
|
|
@@ -121,16 +93,16 @@ export function StatusBar(props: {
|
|
|
121
93
|
status: string
|
|
122
94
|
width: number
|
|
123
95
|
onStop: () => void
|
|
124
|
-
/** A suite's cases: done so far, of how many. */
|
|
96
|
+
/** A suite's cases or the plan's tasks: done so far, of how many. */
|
|
125
97
|
progress?: { done: number; total: number } | null
|
|
126
|
-
/** Buttons for the view (
|
|
98
|
+
/** Buttons for the view (Focus panels, Close), before Stop; the first to give way when the line is short. */
|
|
127
99
|
actions?: Button[]
|
|
128
100
|
}) {
|
|
129
101
|
const done = props.status === "done"
|
|
130
102
|
const failed = props.status === "fail"
|
|
131
103
|
const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
|
|
132
|
-
const
|
|
133
|
-
? props.
|
|
104
|
+
const state = props.running
|
|
105
|
+
? props.who
|
|
134
106
|
: done
|
|
135
107
|
? "Last request done"
|
|
136
108
|
: failed
|
|
@@ -141,45 +113,47 @@ export function StatusBar(props: {
|
|
|
141
113
|
const bar = props.running && props.progress && props.progress.total > 1 ? props.progress : null
|
|
142
114
|
const cells = 12
|
|
143
115
|
const barText = bar ? ` ${bar.done}/${bar.total} ` : ""
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
const
|
|
116
|
+
const stop = "■ Stop · esc"
|
|
117
|
+
const fixed = state.length + 4 + (props.running ? stop.length + 4 + props.elapsed.length + 2 + (bar ? cells + barText.length : 0) : 0)
|
|
118
|
+
const actions = fitButtons(props.actions || [], Math.max(0, props.width - fixed - 2))
|
|
147
119
|
const [full, empty] = bar ? progressBar(bar.done, bar.total, cells) : ["", ""]
|
|
148
120
|
return (
|
|
149
|
-
<box style={{ flexDirection: "
|
|
150
|
-
<box style={{ flexDirection: "row" }}>
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
{props.running ?
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
121
|
+
<box style={{ flexDirection: "column", height: STATUS_ROWS, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
|
|
122
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1 }}>
|
|
123
|
+
<box style={{ flexDirection: "row" }}>
|
|
124
|
+
{props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
|
|
125
|
+
<text fg={props.running ? theme.text : color}>{` ${clip(state, Math.max(8, props.width - fixed + state.length))}`}</text>
|
|
126
|
+
</box>
|
|
127
|
+
<box style={{ flexDirection: "row" }}>
|
|
128
|
+
{actions.map((b) => (
|
|
129
|
+
<box key={b.label} onMouseDown={b.onPress} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginLeft: 1, backgroundColor: theme.button }}>
|
|
130
|
+
<text>
|
|
131
|
+
<span fg={b.color}>{`${b.icon} `}</span>
|
|
132
|
+
<span fg={theme.text}>{b.label}</span>
|
|
133
|
+
{b.keys ? <span fg={theme.faint}>{` ${b.keys}`}</span> : null}
|
|
134
|
+
</text>
|
|
135
|
+
</box>
|
|
136
|
+
))}
|
|
137
|
+
{props.running ? (
|
|
138
|
+
<box style={{ flexDirection: "row", marginLeft: 2 }}>
|
|
139
|
+
{bar ? (
|
|
140
|
+
<text>
|
|
141
|
+
<span fg={theme.ok}>{full}</span>
|
|
142
|
+
<span fg={theme.border}>{empty}</span>
|
|
143
|
+
<span fg={theme.muted}>{barText}</span>
|
|
144
|
+
</text>
|
|
145
|
+
) : null}
|
|
146
|
+
<text fg={theme.faint}>{`${props.elapsed} `}</text>
|
|
147
|
+
<box onMouseDown={props.onStop} style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.warn }}>
|
|
148
|
+
<text fg={theme.onButton}>{stop}</text>
|
|
149
|
+
</box>
|
|
150
|
+
</box>
|
|
175
151
|
) : null}
|
|
176
|
-
<text fg={theme.faint}>{`${props.elapsed} `}</text>
|
|
177
|
-
<box onMouseDown={props.onStop} style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.warn }}>
|
|
178
|
-
<text fg={theme.onButton}>{"■ Stop · esc"}</text>
|
|
179
|
-
</box>
|
|
180
152
|
</box>
|
|
181
|
-
) : null}
|
|
182
153
|
</box>
|
|
154
|
+
<text fg={props.running ? theme.muted : theme.faint} style={{ height: 1 }}>
|
|
155
|
+
{` ${clip(props.running ? props.activity || "Working…" : "", Math.max(10, props.width - 4))}`}
|
|
156
|
+
</text>
|
|
183
157
|
</box>
|
|
184
158
|
)
|
|
185
159
|
}
|
|
@@ -252,6 +226,7 @@ export function PlanPanel(props: { s: Inspector; width: number; height: number;
|
|
|
252
226
|
|
|
253
227
|
/** Each kind of activity line: its mark, the mark's colour, and the text's colour. */
|
|
254
228
|
const ACTIVITY_STYLE: Record<string, [string, string, string]> = {
|
|
229
|
+
running: ["◐", theme.warn, theme.text],
|
|
255
230
|
tool: ["⚙", theme.faint, theme.muted],
|
|
256
231
|
file: ["✎", theme.accent, theme.text],
|
|
257
232
|
code: ["▸", theme.memory, theme.memory],
|
|
@@ -268,23 +243,40 @@ export function toolArgs(text: string): [string, string] | null {
|
|
|
268
243
|
return at > 0 && text.endsWith(")") ? [text.slice(0, at), text.slice(at + 1)] : null
|
|
269
244
|
}
|
|
270
245
|
|
|
271
|
-
/**
|
|
272
|
-
|
|
246
|
+
/** A call on one row: its name in full where it fits, then as much of its arguments as is left, dimmed. */
|
|
247
|
+
function ToolCall({ parts: [name, args], width }: { parts: [string, string]; width: number }) {
|
|
248
|
+
const shown = width > 0 ? clip(name, width) : name
|
|
249
|
+
const left = width > 0 ? width - shown.length - 1 : Infinity
|
|
250
|
+
return (
|
|
251
|
+
<text style={{ flexGrow: 1, flexShrink: 1, height: width > 0 ? 1 : undefined }}>
|
|
252
|
+
<span fg={theme.text}>{shown}</span>
|
|
253
|
+
{left >= 4 ? <span fg={theme.faint}>{` ${width > 0 ? clip(args, left) : args}`}</span> : null}
|
|
254
|
+
</text>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Rows an activity line may take: a call is one row; narration and results up to three. */
|
|
259
|
+
const NOTE_ROWS = 3
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* One activity line, its mark in a column of its own so wrapped text stays under the text. `width` is the text's
|
|
263
|
+
* columns: a call is cut to one row and anything else to three, so a burst of output never takes the panel over.
|
|
264
|
+
*/
|
|
265
|
+
export function ActivityRow({ line, width = 0 }: { line: Line; width?: number }) {
|
|
273
266
|
const [mark, markColor, textColor] = ACTIVITY_STYLE[line.kind] || ["·", theme.faint, theme.muted]
|
|
267
|
+
const oneRow = line.kind === "tool" || line.kind === "running" || line.kind === "file"
|
|
268
|
+
const fit = (t: string, rows: number) => (width > 0 ? clip(t.replace(/\s+/g, " "), width * rows) : t)
|
|
274
269
|
return (
|
|
275
270
|
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
276
271
|
<text fg={markColor} style={{ width: 2, flexShrink: 0 }}>
|
|
277
272
|
{mark}
|
|
278
273
|
</text>
|
|
279
|
-
{line.kind === "tool" && toolArgs(line.text) ? (
|
|
274
|
+
{(line.kind === "tool" || line.kind === "running") && toolArgs(line.text) ? (
|
|
280
275
|
// "Azure DevOps · pipelines build (action list, project QI-Claims)": the call readable, its arguments quiet.
|
|
281
|
-
<text
|
|
282
|
-
<span fg={theme.text}>{toolArgs(line.text)![0]}</span>
|
|
283
|
-
<span fg={theme.faint}>{` ${toolArgs(line.text)![1]}`}</span>
|
|
284
|
-
</text>
|
|
276
|
+
<ToolCall parts={toolArgs(line.text)!} width={width} />
|
|
285
277
|
) : (
|
|
286
278
|
<text fg={textColor} style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
287
|
-
{line.text || " "}
|
|
279
|
+
{fit(line.text, oneRow ? 1 : NOTE_ROWS) || " "}
|
|
288
280
|
</text>
|
|
289
281
|
)}
|
|
290
282
|
</box>
|
|
@@ -296,7 +288,7 @@ export const ACTIVITY_FILTERS = [
|
|
|
296
288
|
{ id: "all", label: "All", key: "a", kinds: null },
|
|
297
289
|
{ id: "errors", label: "Errors", key: "e", kinds: ["err"] },
|
|
298
290
|
{ id: "steps", label: "Steps", key: "s", kinds: ["ok", "err", "thought", "memory", "timing"] },
|
|
299
|
-
{ id: "tools", label: "Tools", key: "t", kinds: ["tool", "file", "code"] },
|
|
291
|
+
{ id: "tools", label: "Tools", key: "t", kinds: ["running", "tool", "file", "code"] },
|
|
300
292
|
] as const
|
|
301
293
|
export type ActivityFilter = (typeof ACTIVITY_FILTERS)[number]["id"]
|
|
302
294
|
|
|
@@ -305,6 +297,27 @@ export function filterActivity(lines: Line[], filter: ActivityFilter): Line[] {
|
|
|
305
297
|
return kinds ? lines.filter((l) => (kinds as readonly string[]).includes(l.kind)) : lines
|
|
306
298
|
}
|
|
307
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Where the work stands, from the agent's own plan and the run's steps: the task in progress (or the step running) and
|
|
302
|
+
* the next task. Nothing new is planned here; with no plan and no steps there is nothing to say.
|
|
303
|
+
*/
|
|
304
|
+
export function nowNext(s: Inspector): { now?: string; next?: string; done: number; total: number } {
|
|
305
|
+
const at = s.plan.findIndex((p) => p.status === "in_progress")
|
|
306
|
+
const step = [...s.progress.items].reverse().find((i) => i.status === "running")
|
|
307
|
+
const pending = s.plan.find((p, i) => p.status === "pending" && i > at)
|
|
308
|
+
return {
|
|
309
|
+
now: at >= 0 ? s.plan[at]!.text : step?.label,
|
|
310
|
+
next: pending?.text,
|
|
311
|
+
done: s.plan.filter((p) => p.status === "completed").length,
|
|
312
|
+
total: s.plan.length,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Rows the Now/Next header takes: one for each it has. */
|
|
317
|
+
export function nowNextRows(n: ReturnType<typeof nowNext>): number {
|
|
318
|
+
return (n.now ? 1 : 0) + (n.next ? 1 : 0)
|
|
319
|
+
}
|
|
320
|
+
|
|
308
321
|
/** Everything the agents do, newest at the bottom; focused, it scrolls and narrows to errors, steps, or tools. */
|
|
309
322
|
export function ActivityPanel(props: {
|
|
310
323
|
lines: Line[]
|
|
@@ -315,8 +328,11 @@ export function ActivityPanel(props: {
|
|
|
315
328
|
focused?: boolean
|
|
316
329
|
onFocus?: () => void
|
|
317
330
|
onFilter?: (f: ActivityFilter) => void
|
|
331
|
+
/** The task in progress and the next one, above the lines. */
|
|
332
|
+
now?: ReturnType<typeof nowNext>
|
|
318
333
|
}) {
|
|
319
334
|
const filter = props.filter || "all"
|
|
335
|
+
const room = Math.max(10, props.width - 12)
|
|
320
336
|
const shown = filterActivity(props.lines, filter)
|
|
321
337
|
const label = ACTIVITY_FILTERS.find((f) => f.id === filter)!.label
|
|
322
338
|
const count = filter === "all" ? (shown.length ? ` · ${shown.length}` : "") : ` · ${label} ${shown.length} of ${props.lines.length}`
|
|
@@ -337,10 +353,24 @@ export function ActivityPanel(props: {
|
|
|
337
353
|
})}
|
|
338
354
|
</box>
|
|
339
355
|
) : null}
|
|
356
|
+
{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>
|
|
362
|
+
) : null}
|
|
363
|
+
{props.now?.next ? (
|
|
364
|
+
<text style={{ height: 1, flexShrink: 0 }}>
|
|
365
|
+
<span fg={theme.muted}>{"Next "}</span>
|
|
366
|
+
<span fg={theme.faint}>{"○ "}</span>
|
|
367
|
+
<span fg={theme.muted}>{clip(props.now.next, room)}</span>
|
|
368
|
+
</text>
|
|
369
|
+
) : null}
|
|
340
370
|
{shown.length ? (
|
|
341
371
|
<scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
|
|
342
372
|
{shown.map((line) => (
|
|
343
|
-
<ActivityRow key={line.id} line={line} />
|
|
373
|
+
<ActivityRow key={line.id} line={line} width={Math.max(10, props.width - 8)} />
|
|
344
374
|
))}
|
|
345
375
|
</scrollbox>
|
|
346
376
|
) : (
|
|
@@ -450,3 +480,30 @@ export function TokensPanel(props: { total: Usage; width: number; height: number
|
|
|
450
480
|
</Panel>
|
|
451
481
|
)
|
|
452
482
|
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Files, context, and tokens in one line, for a column too narrow for their three panels:
|
|
486
|
+
* "Files 4 (1 written) · Context 26% · Skills 2 · 52.9K tokens".
|
|
487
|
+
*/
|
|
488
|
+
export function InfoStrip(props: { s: Inspector; c: ContextInfo; total: Usage; width: number }) {
|
|
489
|
+
const files = filesByUse(props.s)
|
|
490
|
+
const changed = files.filter((f) => f.changes).length
|
|
491
|
+
const share = props.c.limit ? Math.round(Math.min(1, props.c.tokens / props.c.limit) * 100) : 0
|
|
492
|
+
const parts: [string, string, string][] = [
|
|
493
|
+
["Files ", `${files.length}${changed ? ` (${changed} changed)` : ""}`, SECTION.files],
|
|
494
|
+
["Context ", `${share}%`, share < 60 ? theme.ok : share < 85 ? theme.warn : theme.err],
|
|
495
|
+
...(props.c.skills.length ? [["Skills ", String(props.c.skills.length), SECTION.context] as [string, string, string]] : []),
|
|
496
|
+
["", `${formatTokens(props.total.input + props.total.output)} tokens`, SECTION.tokens],
|
|
497
|
+
]
|
|
498
|
+
return (
|
|
499
|
+
<box style={{ flexDirection: "row", height: 1, flexShrink: 0, width: props.width, paddingLeft: 1 }}>
|
|
500
|
+
<text>
|
|
501
|
+
{parts.flatMap(([label, value, color], i) => [
|
|
502
|
+
<span key={`s${i}`} fg={theme.faint}>{i ? " · " : ""}</span>,
|
|
503
|
+
<span key={`l${i}`} fg={theme.muted}>{label}</span>,
|
|
504
|
+
<span key={`v${i}`} fg={color}>{value}</span>,
|
|
505
|
+
])}
|
|
506
|
+
</text>
|
|
507
|
+
</box>
|
|
508
|
+
)
|
|
509
|
+
}
|
package/src/tui/landing.ts
CHANGED
|
@@ -75,9 +75,10 @@ export type Fact = { text: string; tone: "name" | "plain" | "warn" }
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* The project line: the folder, its stack, each system ForgeQE works with here and how it is signed in ("Azure DevOps
|
|
78
|
-
* (PAT)")
|
|
78
|
+
* (PAT)"); or, where nothing is set up, that this folder has no forgeqe.yaml. The model and profile are the status
|
|
79
|
+
* line's, and a missing model is Setup's, so neither is repeated here.
|
|
79
80
|
*/
|
|
80
|
-
export function projectLine(cfg: AppConfig | null, cwd: string, opts: {
|
|
81
|
+
export function projectLine(cfg: AppConfig | null, cwd: string, opts: { signIns?: Record<string, string> } = {}): Fact[] {
|
|
81
82
|
const facts: Fact[] = [{ text: path.basename(cwd), tone: "name" }, ...projectStack(cwd).map((t) => ({ text: t, tone: "plain" as const }))]
|
|
82
83
|
if (!cfg) return facts
|
|
83
84
|
if (!cfg.configPath) return [...facts, { text: "no forgeqe.yaml here", tone: "warn" }]
|
|
@@ -86,8 +87,6 @@ export function projectLine(cfg: AppConfig | null, cwd: string, opts: { model: s
|
|
|
86
87
|
const how = opts.signIns?.[SYSTEM_LABEL[s]]
|
|
87
88
|
facts.push({ text: how ? `${SYSTEM_LABEL[s]} (${how})` : SYSTEM_LABEL[s], tone: "plain" })
|
|
88
89
|
}
|
|
89
|
-
const live = cfg.llm.provider !== "none" && cfg.llm.provider !== "mock"
|
|
90
|
-
facts.push(live && opts.model ? { text: opts.model, tone: "plain" } : { text: "no model", tone: "warn" })
|
|
91
90
|
return facts
|
|
92
91
|
}
|
|
93
92
|
|
package/src/tui/refine-panel.tsx
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import type { RefObject } from "react"
|
|
3
|
+
|
|
1
4
|
import type { Refined } from "../refine.ts"
|
|
2
|
-
import { Spinner } from "./components.tsx"
|
|
5
|
+
import { Spinner, wrappedLines } from "./components.tsx"
|
|
3
6
|
import { formatDuration, formatTokens, theme } from "./theme.ts"
|
|
4
7
|
|
|
5
8
|
/** Refine's panel, in the prompt's place: working, the result with what was checked, or why it could not refine. */
|
|
@@ -27,35 +30,72 @@ export function initialChoice(missing: string[]): number {
|
|
|
27
30
|
return REFINE_CHOICES.findIndex((c) => c.id === (missing.length ? "edit" : "use"))
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
/** A row of the refined request as the panel draws it: a label in the panel's colour, a ✓ for a checked fact, or plain. */
|
|
34
|
+
type Row = { text: string; mark?: string; markColor?: string; label?: string; color?: string; heading?: boolean }
|
|
35
|
+
|
|
36
|
+
/** Everything the panel's body shows, in order: what is still missing first, since it decides whether to send. */
|
|
37
|
+
export function refineRows(r: Refined): Row[] {
|
|
38
|
+
const contextLines = r.refined.split("\n").filter((l) => l.startsWith("- ")).map((l) => norm(l.slice(2)))
|
|
39
|
+
const inContext = new Set(contextLines)
|
|
40
|
+
// Facts Refine checked that are also context lines are marked there, not repeated under Checked.
|
|
41
|
+
const checked = new Set(r.grounded.filter((g) => g.checked).map((g) => norm(g.fact)).filter((f) => inContext.has(f)))
|
|
42
|
+
const section = (label: string, mark: string, markColor: string, items: string[]): Row[] =>
|
|
43
|
+
items.length ? [{ text: label, heading: true, color: markColor === theme.err ? theme.err : theme.muted }, ...items.map((text) => ({ text, mark, markColor })), { text: "" }] : []
|
|
44
|
+
const request: Row[] = r.refined.split("\n").map((line) => {
|
|
45
|
+
if (line.startsWith("- ") && checked.has(norm(line.slice(2)))) return { text: line.slice(2), mark: "✓", markColor: theme.ok }
|
|
46
|
+
const label = /^(Role|Objective|Context|Steps|Rules|Output format|Summary|Goal|Result):/.exec(line)
|
|
47
|
+
return label ? { text: line.slice(label[0].length), label: label[0] } : { text: line }
|
|
48
|
+
})
|
|
49
|
+
return [
|
|
50
|
+
...section("Missing — answer these in your request", "?", theme.err, r.missing),
|
|
51
|
+
...request,
|
|
52
|
+
...section("Checked", "✓", theme.ok, r.grounded.filter((g) => g.checked && !inContext.has(norm(g.fact))).map((g) => g.fact)),
|
|
53
|
+
...section("Assumed", "~", theme.warn, [...r.grounded.filter((g) => !g.checked).map((g) => g.fact), ...r.assumed]),
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The screen rows `rows` take at `width` columns, wrapped as the panel wraps them. */
|
|
58
|
+
export function rowsHeight(rows: Row[], width: number): number {
|
|
59
|
+
return rows.reduce((n, r) => n + (r.text ? wrappedLines((r.label || "") + r.text, width - (r.mark ? 2 : 0)) : 1), 0)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Refine's panel, in the prompt's place. `maxHeight` is the rows it may take with its action row; a longer request
|
|
64
|
+
* scrolls inside it (↑↓ pgup pgdn, or the wheel) while the title and the actions stay on screen.
|
|
65
|
+
*/
|
|
66
|
+
export function RefinePanel(props: {
|
|
67
|
+
state: RefineState
|
|
68
|
+
width: number
|
|
69
|
+
maxHeight: number
|
|
70
|
+
scrollRef?: RefObject<ScrollBoxRenderable | null>
|
|
71
|
+
onChoose: (id: RefineChoice) => void
|
|
72
|
+
onHover: (i: number) => void
|
|
73
|
+
}) {
|
|
31
74
|
const { state, width } = props
|
|
32
75
|
const cost = state.phase === "done" && state.tokens ? ` · ${formatTokens(state.tokens)} tokens${state.ms ? ` · ${formatDuration(state.ms)}` : ""}` : ""
|
|
33
76
|
// How much of the request keeps the ASD-STE100 rules, as Refine's prompt asks.
|
|
34
77
|
const ste = state.phase === "done" && state.result.ste !== undefined ? ` · STE ${Math.round(state.result.ste * 100)}%` : ""
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
{items.map((it, i) => (
|
|
45
|
-
<box key={i} style={{ flexDirection: "row" }}>
|
|
46
|
-
<text fg={color} style={{ width: 2, flexShrink: 0 }}>
|
|
47
|
-
{mark}
|
|
48
|
-
</text>
|
|
49
|
-
<text fg={theme.text} style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
50
|
-
{it}
|
|
51
|
-
</text>
|
|
52
|
-
</box>
|
|
53
|
-
))}
|
|
54
|
-
</box>
|
|
55
|
-
) : null
|
|
78
|
+
const missing = state.phase === "done" && state.result.missing.length ? ` · ${state.result.missing.length} to answer` : ""
|
|
79
|
+
const title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : ` ✦ Refined request${missing}${ste}${cost} `
|
|
80
|
+
const rows = state.phase === "done" ? refineRows(state.result) : []
|
|
81
|
+
const inner = Math.max(10, width - 4)
|
|
82
|
+
// The border takes two rows and the action row one; the body gets the rest, and scrolls when it needs more.
|
|
83
|
+
const room = Math.max(3, props.maxHeight - 3)
|
|
84
|
+
const needed = rowsHeight(rows, inner)
|
|
85
|
+
const scrolls = needed > room
|
|
86
|
+
const bodyHeight = Math.min(needed, room)
|
|
56
87
|
return (
|
|
57
88
|
<box style={{ flexDirection: "column", width, flexShrink: 0 }}>
|
|
58
|
-
<box
|
|
89
|
+
<box
|
|
90
|
+
border
|
|
91
|
+
borderStyle="rounded"
|
|
92
|
+
borderColor={COLOR}
|
|
93
|
+
title={title}
|
|
94
|
+
titleColor={COLOR}
|
|
95
|
+
bottomTitle={scrolls ? " ↑↓ pgup pgdn scroll " : undefined}
|
|
96
|
+
bottomTitleAlignment="right"
|
|
97
|
+
style={{ flexDirection: "column", paddingLeft: 1, paddingRight: 1, backgroundColor: theme.panel, flexShrink: 0 }}
|
|
98
|
+
>
|
|
59
99
|
{state.phase === "working" ? (
|
|
60
100
|
<box style={{ flexDirection: "row" }}>
|
|
61
101
|
<Spinner kind="dots" color={COLOR} />
|
|
@@ -64,37 +104,36 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
|
|
|
64
104
|
) : state.phase === "error" ? (
|
|
65
105
|
<text fg={theme.err}>{state.message}</text>
|
|
66
106
|
) : (
|
|
67
|
-
|
|
68
|
-
{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
107
|
+
<scrollbox ref={props.scrollRef} style={{ height: bodyHeight, flexShrink: 0 }} focused={false}>
|
|
108
|
+
{rows.map((r, i) =>
|
|
109
|
+
r.heading ? (
|
|
110
|
+
<text key={i} fg={r.color}>
|
|
111
|
+
{r.text}
|
|
112
|
+
</text>
|
|
113
|
+
) : r.mark ? (
|
|
114
|
+
<box key={i} style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
115
|
+
<text fg={r.markColor} style={{ width: 2, flexShrink: 0 }}>
|
|
116
|
+
{r.mark}
|
|
117
|
+
</text>
|
|
118
|
+
<text fg={theme.text} style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
119
|
+
{r.text}
|
|
77
120
|
</text>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return label ? (
|
|
121
|
+
</box>
|
|
122
|
+
) : r.label ? (
|
|
81
123
|
<text key={i}>
|
|
82
|
-
<span fg={COLOR}>{label
|
|
83
|
-
<span fg={theme.text}>{
|
|
124
|
+
<span fg={COLOR}>{r.label}</span>
|
|
125
|
+
<span fg={theme.text}>{r.text}</span>
|
|
84
126
|
</text>
|
|
85
127
|
) : (
|
|
86
|
-
<text key={i} fg={
|
|
87
|
-
{
|
|
128
|
+
<text key={i} fg={theme.text}>
|
|
129
|
+
{r.text || " "}
|
|
88
130
|
</text>
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
{section("Assumed", theme.warn, "~", [...state.result.grounded.filter((g) => !g.checked).map((g) => g.fact), ...state.result.assumed])}
|
|
93
|
-
{section("Missing — answer these in your request", theme.err, "?", state.result.missing)}
|
|
94
|
-
</>
|
|
131
|
+
),
|
|
132
|
+
)}
|
|
133
|
+
</scrollbox>
|
|
95
134
|
)}
|
|
96
135
|
</box>
|
|
97
|
-
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1,
|
|
136
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0 }}>
|
|
98
137
|
{state.phase === "done" ? (
|
|
99
138
|
<box style={{ flexDirection: "row" }}>
|
|
100
139
|
{REFINE_CHOICES.map((c, i) => {
|
|
@@ -112,12 +151,23 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
|
|
|
112
151
|
<text>
|
|
113
152
|
{state.phase === "done" ? (
|
|
114
153
|
<>
|
|
154
|
+
<span fg={theme.text}>{"⇆"}</span>
|
|
155
|
+
<span fg={theme.muted}>{" choose "}</span>
|
|
115
156
|
<span fg={theme.text}>{"enter"}</span>
|
|
116
157
|
<span fg={theme.muted}>{` ${REFINE_CHOICES[state.choice]!.id === "keep" ? "keep mine" : REFINE_CHOICES[state.choice]!.id} `}</span>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
158
|
+
{/* Each action's key once: e and esc are not repeated when enter already does them. */}
|
|
159
|
+
{REFINE_CHOICES[state.choice]!.id !== "edit" ? (
|
|
160
|
+
<>
|
|
161
|
+
<span fg={theme.text}>{"e"}</span>
|
|
162
|
+
<span fg={theme.muted}>{" edit "}</span>
|
|
163
|
+
</>
|
|
164
|
+
) : null}
|
|
165
|
+
{REFINE_CHOICES[state.choice]!.id !== "keep" ? (
|
|
166
|
+
<>
|
|
167
|
+
<span fg={theme.text}>{"esc"}</span>
|
|
168
|
+
<span fg={theme.muted}>{" keep mine"}</span>
|
|
169
|
+
</>
|
|
170
|
+
) : null}
|
|
121
171
|
</>
|
|
122
172
|
) : (
|
|
123
173
|
<>
|
package/src/tui/shortcuts.tsx
CHANGED
|
@@ -16,6 +16,8 @@ export const SHORTCUTS: { group: string; keys: [string, string][] }[] = [
|
|
|
16
16
|
group: "Find and move",
|
|
17
17
|
keys: [
|
|
18
18
|
["ctrl+p", "search: actions, commands, profiles, examples"],
|
|
19
|
+
["pgup pgdn", "scroll the conversation (the mouse wheel works too)"],
|
|
20
|
+
["↑↓ pgup pgdn", "scroll a long refined request; ⇆ picks what to do with it"],
|
|
19
21
|
["shift+tab", "move between the chat and the plan, activity, and files panels"],
|
|
20
22
|
["ctrl+o", "open or close the panels: plan, activity, files, context, tokens"],
|
|
21
23
|
["alt+1 to alt+4", "a start action, or a next step"],
|
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"
|
|
20
|
+
kind: "sys" | "user" | "thought" | "timing" | "memory" | "tool" | "file" | "code" | "ok" | "err" | "summary" | "suitesummary" | "reply" | "card" | "running"
|
|
21
21
|
text: string
|
|
22
22
|
summary?: RunSummary
|
|
23
23
|
suite?: SuiteResult
|
|
@@ -25,6 +25,29 @@ export type Line = {
|
|
|
25
25
|
outcome?: "done" | "failed" | "needs_input"
|
|
26
26
|
/** Which side of the split view shows it: the conversation, or what the agents are doing. */
|
|
27
27
|
pane?: "chat" | "activity"
|
|
28
|
+
/** Only the Activity panel shows it: detail the conversation leaves out at this level (/details), such as reads. */
|
|
29
|
+
quiet?: boolean
|
|
30
|
+
/** A tool call's id: its "running" line becomes the call's result line when the call finishes. */
|
|
31
|
+
call?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Lines kept: the oldest quiet ones go first, so a busy run never pushes the conversation out. */
|
|
35
|
+
export const MAX_LINES = 1500
|
|
36
|
+
|
|
37
|
+
export function trimLines(lines: Line[], max = MAX_LINES): Line[] {
|
|
38
|
+
if (lines.length <= max) return lines
|
|
39
|
+
let drop = lines.length - max
|
|
40
|
+
const kept = lines.filter((l) => (drop > 0 && l.quiet ? (drop--, false) : true))
|
|
41
|
+
return kept.length > max ? kept.slice(-max) : kept
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A call has finished: its running line takes the result in place, or the result is added when there was none. */
|
|
45
|
+
export function settleCall(lines: Line[], call: string | undefined, line: Omit<Line, "id" | "call">, id: number): Line[] {
|
|
46
|
+
const at = call ? lines.findIndex((l) => l.call === call && l.kind === "running") : -1
|
|
47
|
+
if (at < 0) return [...lines, { ...line, id, ...(call ? { call } : {}) }]
|
|
48
|
+
const next = [...lines]
|
|
49
|
+
next[at] = { ...next[at]!, ...line, call }
|
|
50
|
+
return next
|
|
28
51
|
}
|
|
29
52
|
|
|
30
53
|
/** What one request produced: browser runs with their files, suites, and the tests the coding agent wrote. */
|
|
@@ -97,6 +120,9 @@ export function producedCard(p: Produced, tests: TestsSetting): Card | null {
|
|
|
97
120
|
* The conversation reads top to bottom, as in Claude Code: what you asked after ">", each reply, tool call, or result
|
|
98
121
|
* after "⏺", and what came of it indented under the call after "⎿". No boxes or backgrounds: marks and colour carry it.
|
|
99
122
|
*/
|
|
123
|
+
/** The lines a request shows in the conversation before it folds. */
|
|
124
|
+
export const USER_ROWS = 8
|
|
125
|
+
|
|
100
126
|
export const MARK = { item: "⏺", child: "⎿", user: ">" }
|
|
101
127
|
|
|
102
128
|
/** A mark in its own column and the content beside it, so wrapped lines stay under the content. */
|
|
@@ -404,9 +430,13 @@ export function TranscriptLine({ line, prev }: { line: Line; prev?: Line }) {
|
|
|
404
430
|
if (line.kind === "summary" && line.summary) return <SummaryCard summary={line.summary} />
|
|
405
431
|
if (line.kind === "suitesummary" && line.suite) return <SuiteCard suite={line.suite} />
|
|
406
432
|
if (line.kind === "user") {
|
|
433
|
+
// A long request (a refined one, a pasted spec) folds to its first lines; it was sent in full.
|
|
434
|
+
const rows = line.text.split("\n")
|
|
435
|
+
const folded = rows.length > USER_ROWS
|
|
407
436
|
return (
|
|
408
437
|
<Marked mark={MARK.user} color={theme.muted} gap>
|
|
409
|
-
<text fg={theme.muted}>{line.text}</text>
|
|
438
|
+
<text fg={theme.muted}>{folded ? rows.slice(0, USER_ROWS - 1).join("\n") : line.text}</text>
|
|
439
|
+
{folded ? <text fg={theme.faint}>{`… ${rows.length - USER_ROWS + 1} more lines, sent in full`}</text> : null}
|
|
410
440
|
</Marked>
|
|
411
441
|
)
|
|
412
442
|
}
|
package/src/types.ts
CHANGED
|
@@ -127,7 +127,9 @@ export type PlanItem = { text: string; status: "pending" | "in_progress" | "comp
|
|
|
127
127
|
export type CodeEvent =
|
|
128
128
|
| { type: "code"; kind: "info"; text: string }
|
|
129
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 }
|
|
130
|
+
| { type: "code"; kind: "tool"; text: string; ok: boolean; tool?: string; file?: { path: string; action: FileAction }; skill?: string; id?: string }
|
|
131
|
+
/** A tool call that has started and not finished yet; its `kind: "tool"` event with the same `id` follows. */
|
|
132
|
+
| { type: "code"; kind: "start"; text: string; tool: string; id: string }
|
|
131
133
|
| { type: "code"; kind: "say"; text: string }
|
|
132
134
|
| { type: "code"; kind: "test"; text: string; ok: boolean; attempt: number }
|
|
133
135
|
/** The session's usage so far; `context` is the last model call's input, how full its context window is. */
|
package/src/usage.ts
CHANGED
|
@@ -98,7 +98,7 @@ export function usageLine(usage: Usage, cost?: Cost | null): string {
|
|
|
98
98
|
// The meter: every model session reports here, by role
|
|
99
99
|
// ---------------------------------------------------------------------------------------------
|
|
100
100
|
|
|
101
|
-
export type Role = "ForgeQE's agent" | "browser agent" | "Scribe" | "reviewer" | "judge" | "refiner"
|
|
101
|
+
export type Role = "ForgeQE's agent" | "browser agent" | "Scribe" | "reviewer" | "judge" | "refiner" | "root-cause analyst"
|
|
102
102
|
|
|
103
103
|
type Entry = { usage: Usage; cost?: Cost; priced: boolean }
|
|
104
104
|
|