@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.
Files changed (65) hide show
  1. package/HOW_TO_USE.md +64 -17
  2. package/README.md +2 -1
  3. package/browserstack.example.yaml +14 -2
  4. package/docs/user-guide.html +78 -23
  5. package/forgeqe.example.yaml +18 -5
  6. package/package.json +2 -1
  7. package/prompts/README.md +1 -0
  8. package/prompts/brain.md +8 -1
  9. package/prompts/defect-rca.md +43 -0
  10. package/prompts/test-agent.md +13 -2
  11. package/prompts/test-reviewer.md +8 -3
  12. package/prompts/test-rules.md +10 -4
  13. package/py/forgeqe_engine/browser.py +1 -1
  14. package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
  15. package/skills/playwright-typescript/SKILL.md +2 -1
  16. package/src/brain/index.ts +1 -1
  17. package/src/capabilities/registry.ts +34 -6
  18. package/src/cases/bugs.ts +209 -22
  19. package/src/cases/defectHistory.ts +193 -0
  20. package/src/cases/defects.ts +255 -0
  21. package/src/cases/github.ts +162 -0
  22. package/src/cases/goal.ts +1 -1
  23. package/src/cases/index.ts +23 -4
  24. package/src/cases/predict.ts +372 -0
  25. package/src/cases/rca.ts +258 -0
  26. package/src/cases/readiness.ts +38 -1
  27. package/src/cases/repro.ts +202 -0
  28. package/src/cases/types.ts +8 -1
  29. package/src/cli/help.ts +8 -2
  30. package/src/cli/index.ts +36 -7
  31. package/src/code/graph/graph.ts +518 -0
  32. package/src/code/graph/locators.ts +398 -0
  33. package/src/code/graph/parse.ts +439 -0
  34. package/src/code/graph/tools.ts +113 -0
  35. package/src/code/handoff.ts +93 -18
  36. package/src/code/loop.ts +30 -5
  37. package/src/code/profile.ts +14 -3
  38. package/src/code/replay.ts +1 -1
  39. package/src/code/review.ts +22 -0
  40. package/src/code/runtime.ts +8 -1
  41. package/src/config.ts +142 -18
  42. package/src/mcp/attachtools.ts +223 -0
  43. package/src/mcp/commands.ts +1 -1
  44. package/src/mcp/defecttools.ts +106 -0
  45. package/src/mcp/devicetools.ts +125 -0
  46. package/src/mcp/external.ts +42 -1
  47. package/src/mcp/server.ts +3 -1
  48. package/src/mcp/tools.ts +95 -10
  49. package/src/monitor/run.ts +1 -1
  50. package/src/report/data.ts +14 -0
  51. package/src/report/html.ts +15 -3
  52. package/src/report/template.html +44 -43
  53. package/src/run.ts +4 -2
  54. package/src/suite/report.ts +44 -11
  55. package/src/suite/run.ts +163 -16
  56. package/src/tui/App.tsx +153 -94
  57. package/src/tui/components.tsx +27 -23
  58. package/src/tui/inspector.tsx +140 -83
  59. package/src/tui/landing.ts +3 -4
  60. package/src/tui/refine-panel.tsx +102 -52
  61. package/src/tui/shortcuts.tsx +2 -0
  62. package/src/tui/transcript.tsx +32 -2
  63. package/src/types.ts +3 -1
  64. package/src/usage.ts +1 -1
  65. package/testmu.example.yaml +11 -0
@@ -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 (Panels, Wide chat), before Stop; the first to give way when the line is short. */
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 what = props.running
133
- ? props.activity || "working…"
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 fixed = props.who.length + (props.running ? 30 : 6) + (bar ? cells + barText.length : 0)
145
- const actions = fitButtons(props.actions || [], Math.max(0, props.width - fixed - 28))
146
- const room = Math.max(10, props.width - fixed - actions.reduce((n, b) => n + b.icon.length + b.label.length + (b.keys ? b.keys.length + 1 : 0) + 4, 0))
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: "row", justifyContent: "space-between", height: 1, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
150
- <box style={{ flexDirection: "row" }}>
151
- {props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
152
- <text>
153
- {props.running ? <span fg={theme.text}>{` ${props.who}`}</span> : null}
154
- <span fg={props.running ? theme.muted : color}>{`${props.running ? " " : " "}${clip(what, room)}`}</span>
155
- </text>
156
- </box>
157
- <box style={{ flexDirection: "row" }}>
158
- {actions.map((b) => (
159
- <box key={b.label} onMouseDown={b.onPress} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginLeft: 1, backgroundColor: theme.button }}>
160
- <text>
161
- <span fg={b.color}>{`${b.icon} `}</span>
162
- <span fg={theme.text}>{b.label}</span>
163
- {b.keys ? <span fg={theme.faint}>{` ${b.keys}`}</span> : null}
164
- </text>
165
- </box>
166
- ))}
167
- {props.running ? (
168
- <box style={{ flexDirection: "row", marginLeft: 2 }}>
169
- {bar ? (
170
- <text>
171
- <span fg={theme.ok}>{full}</span>
172
- <span fg={theme.border}>{empty}</span>
173
- <span fg={theme.muted}>{barText}</span>
174
- </text>
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
- /** One activity line, its mark in a column of its own so wrapped text stays under the text. */
272
- export function ActivityRow({ line }: { line: Line }) {
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 style={{ flexGrow: 1, flexShrink: 1 }}>
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
+ }
@@ -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)"), and the model; or, where nothing is set up, that this folder has no forgeqe.yaml.
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: { model: string; signIns?: Record<string, string> }): Fact[] {
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
 
@@ -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
- export function RefinePanel(props: { state: RefineState; width: number; onChoose: (id: RefineChoice) => void; onHover: (i: number) => void }) {
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 title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : ` ✦ Refined request${ste}${cost} `
36
- // Facts Refine checked that are also context lines are marked there, not repeated under Checked.
37
- const contextLines = state.phase === "done" ? state.result.refined.split("\n").filter((l) => l.startsWith("- ")).map((l) => norm(l.slice(2))) : []
38
- const inContext = new Set(contextLines)
39
- const checked = new Set(state.phase === "done" ? state.result.grounded.filter((g) => g.checked).map((g) => norm(g.fact)).filter((f) => inContext.has(f)) : [])
40
- const section = (label: string, color: string, mark: string, items: string[]) =>
41
- items.length ? (
42
- <box style={{ flexDirection: "column", marginTop: 1 }}>
43
- <text fg={theme.muted}>{label}</text>
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 border borderStyle="rounded" borderColor={COLOR} title={title} titleColor={COLOR} style={{ flexDirection: "column", paddingLeft: 1, paddingRight: 1, backgroundColor: theme.panel }}>
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
- {/* The request as it is sent: its labelled parts (Role, Objective, Context, Steps, Rules, Output format, Summary) stand out. */}
69
- {state.result.refined.split("\n").map((line, i) => {
70
- const label = /^(Role|Objective|Context|Steps|Rules|Output format|Summary|Goal|Result):/.exec(line)
71
- // A context line Refine checked against the systems: ✓ in place of its dash, so it is not listed twice.
72
- if (line.startsWith("- ") && checked.has(norm(line.slice(2)))) {
73
- return (
74
- <text key={i}>
75
- <span fg={theme.ok}>{"✓ "}</span>
76
- <span fg={theme.text}>{line.slice(2)}</span>
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[0]}</span>
83
- <span fg={theme.text}>{line.slice(label[0].length)}</span>
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={line.trim() ? theme.text : undefined}>
87
- {line || " "}
128
+ <text key={i} fg={theme.text}>
129
+ {r.text || " "}
88
130
  </text>
89
- )
90
- })}
91
- {section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked && !inContext.has(norm(g.fact))).map((g) => g.fact))}
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, marginTop: 0 }}>
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
- <span fg={theme.text}>{"e"}</span>
118
- <span fg={theme.muted}>{" edit "}</span>
119
- <span fg={theme.text}>{"esc"}</span>
120
- <span fg={theme.muted}>{" keep mine"}</span>
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
  <>
@@ -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"],
@@ -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