@capagents/forgeqe 1.1.5 → 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 +19 -14
- package/docs/user-guide.html +17 -14
- package/package.json +1 -1
- package/prompts/brain.md +1 -0
- package/prompts/test-agent.md +1 -0
- package/prompts/test-reviewer.md +3 -1
- package/prompts/test-rules.md +2 -0
- package/src/code/graph/graph.ts +7 -0
- package/src/code/runtime.ts +8 -1
- package/src/mcp/server.ts +15 -3
- package/src/mcp/tools.ts +12 -2
- package/src/tui/App.tsx +161 -95
- package/src/tui/components.tsx +27 -23
- package/src/tui/inspector.tsx +209 -101
- 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 +50 -2
- package/src/types.ts +3 -1
package/src/tui/components.tsx
CHANGED
|
@@ -42,14 +42,8 @@ export type PromptProps = {
|
|
|
42
42
|
maxRows: number
|
|
43
43
|
placeholder: string
|
|
44
44
|
modeColor: string
|
|
45
|
-
profile: string
|
|
46
|
-
model: string
|
|
47
|
-
right?: string
|
|
48
|
-
rightColor?: string
|
|
49
45
|
/** A secret being entered: the box shows one dot per character instead of a text field, so the value is never drawn. */
|
|
50
46
|
masked?: number
|
|
51
|
-
/** Beside a session panel that names the model and profile, and a status bar: no bottom row. */
|
|
52
|
-
compact?: boolean
|
|
53
47
|
/** Text the box starts with when `inputKey` changes (a refined request, an example from the palette). */
|
|
54
48
|
initialValue?: string
|
|
55
49
|
/** False while an overlay (the palette, a suggestion list) takes the keyboard. Default true. */
|
|
@@ -94,7 +88,6 @@ const PROMPT_KEYS: KeyBinding[] = [
|
|
|
94
88
|
]
|
|
95
89
|
|
|
96
90
|
export function Prompt(props: PromptProps) {
|
|
97
|
-
const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
|
|
98
91
|
const ref = useRef<TextareaRenderable>(null)
|
|
99
92
|
// The rows the text needs; the box shows that many between minRows and maxRows, so it also grows when the screen
|
|
100
93
|
// around it makes more room (the start screen folds a section for a long request).
|
|
@@ -158,20 +151,6 @@ export function Prompt(props: PromptProps) {
|
|
|
158
151
|
)}
|
|
159
152
|
</box>
|
|
160
153
|
</box>
|
|
161
|
-
{props.compact ? null : (
|
|
162
|
-
<box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
|
|
163
|
-
<text>
|
|
164
|
-
<span fg={theme.muted}>{"Model "}</span>
|
|
165
|
-
<span fg={theme.text}>{props.model || "…"}</span>
|
|
166
|
-
</text>
|
|
167
|
-
<text>
|
|
168
|
-
{props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
|
|
169
|
-
{props.right ? <span fg={theme.faint}>{" · "}</span> : null}
|
|
170
|
-
<span fg={theme.muted}>{"profile "}</span>
|
|
171
|
-
<span fg={theme.text}>{props.profile || "none"}</span>
|
|
172
|
-
</text>
|
|
173
|
-
</box>
|
|
174
|
-
)}
|
|
175
154
|
</box>
|
|
176
155
|
)
|
|
177
156
|
}
|
|
@@ -301,11 +280,33 @@ export type ChoicePanelProps = {
|
|
|
301
280
|
onHover: (index: number) => void
|
|
302
281
|
/** One option per line, for many or long options. */
|
|
303
282
|
vertical?: boolean
|
|
283
|
+
/** Rows the panel may take; a long detail is cut and a long list shows a window around the selected option. */
|
|
284
|
+
maxHeight?: number
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The options a vertical list shows in `room` rows, around the selected one: [first, last) and what is hidden. */
|
|
288
|
+
export function optionWindow(count: number, selected: number, room: number): { from: number; to: number } {
|
|
289
|
+
if (count <= room) return { from: 0, to: count }
|
|
290
|
+
// Two rows go to "↑ n more" and "↓ n more"; the selected option sits in the middle of the rest when it can.
|
|
291
|
+
const shown = Math.max(1, room - 2)
|
|
292
|
+
const from = Math.max(0, Math.min(count - shown, selected - Math.floor(shown / 2)))
|
|
293
|
+
return { from, to: from + shown }
|
|
304
294
|
}
|
|
305
295
|
|
|
306
296
|
/** Question with buttons, as the agent runtime shows for permissions: arrows or tab move, enter confirms, click works too. */
|
|
307
297
|
export function ChoicePanel(props: ChoicePanelProps) {
|
|
308
298
|
const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
|
|
299
|
+
// The detail's columns: the bar and padding take five, its own indent two, and one more for a word that wraps early.
|
|
300
|
+
const inner = Math.max(10, props.width - 8)
|
|
301
|
+
// Padding, title, the gap, the keys row under a vertical list, and the edge below: the rest is detail and options.
|
|
302
|
+
const budget = Math.max(6, (props.maxHeight ?? 999) - 6 - (props.vertical ? 1 : 0))
|
|
303
|
+
const optionRows = props.vertical ? Math.min(props.options.length, Math.max(3, budget - 2)) : 1
|
|
304
|
+
const detailRoom = Math.max(1, budget - optionRows)
|
|
305
|
+
const detailRows = props.detail ? wrappedLines(props.detail, inner) : 0
|
|
306
|
+
// A detail longer than its room is cut at a word, so the options stay on screen.
|
|
307
|
+
const detail = detailRows > detailRoom ? `${props.detail.slice(0, detailRoom * inner - 2).replace(/\s+\S*$/, "")}…` : props.detail
|
|
308
|
+
const win = props.vertical ? optionWindow(props.options.length, props.selected, optionRows) : { from: 0, to: props.options.length }
|
|
309
|
+
const more = (n: number, arrow: string) => (n > 0 ? <text fg={theme.faint}>{` ${arrow} ${n} more`}</text> : null)
|
|
309
310
|
return (
|
|
310
311
|
<box style={{ flexDirection: "column", width: props.width, flexShrink: 0 }}>
|
|
311
312
|
<box
|
|
@@ -326,10 +327,12 @@ export function ChoicePanel(props: ChoicePanelProps) {
|
|
|
326
327
|
<span fg={props.color}>{"△ "}</span>
|
|
327
328
|
<span fg={theme.text}>{props.title}</span>
|
|
328
329
|
</text>
|
|
329
|
-
{
|
|
330
|
+
{detail ? <text fg={theme.muted}>{` ${detail}`}</text> : null}
|
|
330
331
|
<box style={{ flexDirection: props.vertical ? "column" : "row", justifyContent: "space-between", marginTop: 1 }}>
|
|
331
332
|
<box style={{ flexDirection: props.vertical ? "column" : "row", alignItems: "flex-start" }}>
|
|
332
|
-
{props.
|
|
333
|
+
{props.vertical ? more(win.from, "↑") : null}
|
|
334
|
+
{props.options.slice(win.from, win.to).map((option, j) => {
|
|
335
|
+
const i = win.from + j
|
|
333
336
|
const active = i === props.selected
|
|
334
337
|
return (
|
|
335
338
|
<box
|
|
@@ -342,6 +345,7 @@ export function ChoicePanel(props: ChoicePanelProps) {
|
|
|
342
345
|
</box>
|
|
343
346
|
)
|
|
344
347
|
})}
|
|
348
|
+
{props.vertical ? more(props.options.length - win.to, "↓") : null}
|
|
345
349
|
</box>
|
|
346
350
|
<text style={{ marginTop: props.vertical ? 1 : 0 }}>
|
|
347
351
|
<span fg={theme.text}>{props.vertical ? "↑↓" : "⇆"}</span>
|
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
|
|
@@ -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
|
}
|
|
@@ -197,18 +171,19 @@ const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
|
|
|
197
171
|
skipped: ["■", theme.faint],
|
|
198
172
|
}
|
|
199
173
|
|
|
200
|
-
|
|
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 }
|
|
201
176
|
|
|
202
177
|
export function planLines(s: Inspector): PlanLine[] {
|
|
203
178
|
const lines: PlanLine[] = []
|
|
204
179
|
for (const p of s.plan) {
|
|
205
180
|
const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
|
|
206
|
-
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 } : {}) })
|
|
207
182
|
}
|
|
208
183
|
if (s.progress.items.length && s.plan.length) lines.push({ mark: "─", color: theme.border, text: s.progress.title, textColor: theme.faint })
|
|
209
184
|
for (const i of s.progress.items) {
|
|
210
185
|
const [mark, color] = PROGRESS_MARK[i.status]
|
|
211
|
-
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 } : {}) })
|
|
212
187
|
}
|
|
213
188
|
return lines
|
|
214
189
|
}
|
|
@@ -227,7 +202,7 @@ export function windowRows(total: number, rows: number, selected?: number): [num
|
|
|
227
202
|
}
|
|
228
203
|
|
|
229
204
|
/** The agent's to-do list, then the browser steps or suite cases; the latest that fit, or around the selected one. */
|
|
230
|
-
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 }) {
|
|
231
206
|
const { s, width, height } = props
|
|
232
207
|
const lines = planLines(s)
|
|
233
208
|
const done = s.plan.filter((p) => p.status === "completed").length
|
|
@@ -239,10 +214,9 @@ export function PlanPanel(props: { s: Inspector; width: number; height: number;
|
|
|
239
214
|
const active = first + i === props.selected
|
|
240
215
|
return (
|
|
241
216
|
<box key={first + i} style={{ flexDirection: "row", height: 1, flexShrink: 0, backgroundColor: active ? theme.button : undefined }}>
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
</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>
|
|
246
220
|
</box>
|
|
247
221
|
)
|
|
248
222
|
})}
|
|
@@ -252,7 +226,9 @@ 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]> = {
|
|
255
|
-
|
|
229
|
+
running: ["◐", theme.warn, theme.text],
|
|
230
|
+
// A finished call: ✓, so it never reads as the ○ of a task still to do.
|
|
231
|
+
tool: ["✓", theme.ok, theme.muted],
|
|
256
232
|
file: ["✎", theme.accent, theme.text],
|
|
257
233
|
code: ["▸", theme.memory, theme.memory],
|
|
258
234
|
timing: ["+", theme.warn, theme.warn],
|
|
@@ -268,23 +244,85 @@ export function toolArgs(text: string): [string, string] | null {
|
|
|
268
244
|
return at > 0 && text.endsWith(")") ? [text.slice(0, at), text.slice(at + 1)] : null
|
|
269
245
|
}
|
|
270
246
|
|
|
271
|
-
/**
|
|
272
|
-
|
|
247
|
+
/** A call on one row: its name in full where it fits, then as much of its arguments as is left, dimmed. */
|
|
248
|
+
function ToolCall({ parts: [name, args], width }: { parts: [string, string]; width: number }) {
|
|
249
|
+
const shown = width > 0 ? clip(name, width) : name
|
|
250
|
+
const left = width > 0 ? width - shown.length - 1 : Infinity
|
|
251
|
+
return (
|
|
252
|
+
<text style={{ flexGrow: 1, flexShrink: 1, height: width > 0 ? 1 : undefined }}>
|
|
253
|
+
<span fg={theme.text}>{shown}</span>
|
|
254
|
+
{left >= 4 ? <span fg={theme.faint}>{` ${width > 0 ? clip(args, left) : args}`}</span> : null}
|
|
255
|
+
</text>
|
|
256
|
+
)
|
|
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
|
+
|
|
295
|
+
/** Rows an activity line may take: a call is one row; narration and results up to three. */
|
|
296
|
+
const NOTE_ROWS = 3
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* One activity line, its mark in a column of its own so wrapped text stays under the text. `width` is the text's
|
|
300
|
+
* columns: a call is cut to one row and anything else to three, so a burst of output never takes the panel over.
|
|
301
|
+
*/
|
|
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} />
|
|
273
305
|
const [mark, markColor, textColor] = ACTIVITY_STYLE[line.kind] || ["·", theme.faint, theme.muted]
|
|
306
|
+
const oneRow = line.kind === "tool" || line.kind === "running" || line.kind === "file"
|
|
307
|
+
const fit = (t: string, rows: number) => (width > 0 ? clip(t.replace(/\s+/g, " "), width * rows) : t)
|
|
274
308
|
return (
|
|
275
309
|
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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}
|
|
284
318
|
</text>
|
|
319
|
+
)}
|
|
320
|
+
{(line.kind === "tool" || line.kind === "running") && toolArgs(line.text) ? (
|
|
321
|
+
// "Azure DevOps · pipelines build (action list, project QI-Claims)": the call readable, its arguments quiet.
|
|
322
|
+
<ToolCall parts={toolArgs(line.text)!} width={width} />
|
|
285
323
|
) : (
|
|
286
324
|
<text fg={textColor} style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
287
|
-
{line.text || " "}
|
|
325
|
+
{fit(line.text, oneRow ? 1 : NOTE_ROWS) || " "}
|
|
288
326
|
</text>
|
|
289
327
|
)}
|
|
290
328
|
</box>
|
|
@@ -296,13 +334,35 @@ export const ACTIVITY_FILTERS = [
|
|
|
296
334
|
{ id: "all", label: "All", key: "a", kinds: null },
|
|
297
335
|
{ id: "errors", label: "Errors", key: "e", kinds: ["err"] },
|
|
298
336
|
{ id: "steps", label: "Steps", key: "s", kinds: ["ok", "err", "thought", "memory", "timing"] },
|
|
299
|
-
{ id: "tools", label: "Tools", key: "t", kinds: ["tool", "file", "code"] },
|
|
337
|
+
{ id: "tools", label: "Tools", key: "t", kinds: ["running", "tool", "file", "code"] },
|
|
300
338
|
] as const
|
|
301
339
|
export type ActivityFilter = (typeof ACTIVITY_FILTERS)[number]["id"]
|
|
302
340
|
|
|
341
|
+
/** The lines a filter keeps; where each request starts and ends always stays, so a filtered line is never orphaned. */
|
|
303
342
|
export function filterActivity(lines: Line[], filter: ActivityFilter): Line[] {
|
|
304
343
|
const kinds = ACTIVITY_FILTERS.find((f) => f.id === filter)?.kinds
|
|
305
|
-
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
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Where the work stands, from the agent's own plan and the run's steps: the task in progress (or the step running) and
|
|
349
|
+
* the next task. Nothing new is planned here; with no plan and no steps there is nothing to say.
|
|
350
|
+
*/
|
|
351
|
+
export function nowNext(s: Inspector): { now?: string; next?: string; done: number; total: number } {
|
|
352
|
+
const at = s.plan.findIndex((p) => p.status === "in_progress")
|
|
353
|
+
const step = [...s.progress.items].reverse().find((i) => i.status === "running")
|
|
354
|
+
const pending = s.plan.find((p, i) => p.status === "pending" && i > at)
|
|
355
|
+
return {
|
|
356
|
+
now: at >= 0 ? s.plan[at]!.text : step?.label,
|
|
357
|
+
next: pending?.text,
|
|
358
|
+
done: s.plan.filter((p) => p.status === "completed").length,
|
|
359
|
+
total: s.plan.length,
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Rows the Now/Next header takes: one for each it has. */
|
|
364
|
+
export function nowNextRows(n: ReturnType<typeof nowNext>): number {
|
|
365
|
+
return (n.now ? 1 : 0) + (n.next ? 1 : 0)
|
|
306
366
|
}
|
|
307
367
|
|
|
308
368
|
/** Everything the agents do, newest at the bottom; focused, it scrolls and narrows to errors, steps, or tools. */
|
|
@@ -315,11 +375,16 @@ export function ActivityPanel(props: {
|
|
|
315
375
|
focused?: boolean
|
|
316
376
|
onFocus?: () => void
|
|
317
377
|
onFilter?: (f: ActivityFilter) => void
|
|
378
|
+
/** The task in progress and the next one, above the lines. */
|
|
379
|
+
now?: ReturnType<typeof nowNext>
|
|
318
380
|
}) {
|
|
319
381
|
const filter = props.filter || "all"
|
|
382
|
+
const room = Math.max(10, props.width - 12)
|
|
320
383
|
const shown = filterActivity(props.lines, filter)
|
|
321
384
|
const label = ACTIVITY_FILTERS.find((f) => f.id === filter)!.label
|
|
322
|
-
|
|
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)}`
|
|
323
388
|
return (
|
|
324
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}>
|
|
325
390
|
{props.focused || filter !== "all" ? (
|
|
@@ -337,16 +402,32 @@ export function ActivityPanel(props: {
|
|
|
337
402
|
})}
|
|
338
403
|
</box>
|
|
339
404
|
) : null}
|
|
340
|
-
{
|
|
405
|
+
{props.now?.now ? (
|
|
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>
|
|
413
|
+
) : null}
|
|
414
|
+
{props.now?.next ? (
|
|
415
|
+
<text style={{ height: 1, flexShrink: 0 }}>
|
|
416
|
+
<span fg={theme.muted}>{"Next "}</span>
|
|
417
|
+
<span fg={theme.faint}>{"○ "}</span>
|
|
418
|
+
<span fg={theme.muted}>{clip(props.now.next, room)}</span>
|
|
419
|
+
</text>
|
|
420
|
+
) : null}
|
|
421
|
+
{real(shown) ? (
|
|
341
422
|
<scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
|
|
342
|
-
{shown.map((line) => (
|
|
343
|
-
<ActivityRow key={line.id} line={line} />
|
|
423
|
+
{shown.map((line, i) => (
|
|
424
|
+
<ActivityRow key={line.id} line={line} width={Math.max(10, props.width - 8)} first={i === 0} />
|
|
344
425
|
))}
|
|
345
426
|
</scrollbox>
|
|
346
427
|
) : (
|
|
347
428
|
<box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
|
|
348
429
|
<text fg={theme.faint}>
|
|
349
|
-
{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))}
|
|
350
431
|
</text>
|
|
351
432
|
</box>
|
|
352
433
|
)}
|
|
@@ -450,3 +531,30 @@ export function TokensPanel(props: { total: Usage; width: number; height: number
|
|
|
450
531
|
</Panel>
|
|
451
532
|
)
|
|
452
533
|
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Files, context, and tokens in one line, for a column too narrow for their three panels:
|
|
537
|
+
* "Files 4 (1 written) · Context 26% · Skills 2 · 52.9K tokens".
|
|
538
|
+
*/
|
|
539
|
+
export function InfoStrip(props: { s: Inspector; c: ContextInfo; total: Usage; width: number }) {
|
|
540
|
+
const files = filesByUse(props.s)
|
|
541
|
+
const changed = files.filter((f) => f.changes).length
|
|
542
|
+
const share = props.c.limit ? Math.round(Math.min(1, props.c.tokens / props.c.limit) * 100) : 0
|
|
543
|
+
const parts: [string, string, string][] = [
|
|
544
|
+
["Files ", `${files.length}${changed ? ` (${changed} changed)` : ""}`, SECTION.files],
|
|
545
|
+
["Context ", `${share}%`, share < 60 ? theme.ok : share < 85 ? theme.warn : theme.err],
|
|
546
|
+
...(props.c.skills.length ? [["Skills ", String(props.c.skills.length), SECTION.context] as [string, string, string]] : []),
|
|
547
|
+
["", `${formatTokens(props.total.input + props.total.output)} tokens`, SECTION.tokens],
|
|
548
|
+
]
|
|
549
|
+
return (
|
|
550
|
+
<box style={{ flexDirection: "row", height: 1, flexShrink: 0, width: props.width, paddingLeft: 1 }}>
|
|
551
|
+
<text>
|
|
552
|
+
{parts.flatMap(([label, value, color], i) => [
|
|
553
|
+
<span key={`s${i}`} fg={theme.faint}>{i ? " · " : ""}</span>,
|
|
554
|
+
<span key={`l${i}`} fg={theme.muted}>{label}</span>,
|
|
555
|
+
<span key={`v${i}`} fg={color}>{value}</span>,
|
|
556
|
+
])}
|
|
557
|
+
</text>
|
|
558
|
+
</box>
|
|
559
|
+
)
|
|
560
|
+
}
|
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
|
|