@capagents/forgeqe 1.0.4 → 1.0.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 +60 -3
- package/docs/user-guide.html +63 -5
- package/package.json +1 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +4 -1
- package/prompts/refine.md +17 -0
- package/src/brain/index.ts +4 -3
- package/src/brain/prompt.ts +34 -2
- package/src/capabilities/registry.ts +12 -0
- package/src/capabilities/status.ts +4 -3
- package/src/mcp/reporttools.ts +101 -0
- package/src/mcp/tools.ts +4 -0
- package/src/refine.ts +116 -0
- package/src/tui/App.tsx +477 -47
- package/src/tui/components.tsx +78 -3
- package/src/tui/inspector.tsx +181 -39
- package/src/tui/next.ts +43 -0
- package/src/tui/palette.tsx +173 -0
- package/src/tui/refine-panel.tsx +94 -0
- package/src/tui/setup.ts +37 -0
- package/src/tui/suggest.ts +111 -0
- package/src/tui/transcript.tsx +125 -7
- package/src/usage.ts +1 -1
package/src/tui/components.tsx
CHANGED
|
@@ -50,6 +50,12 @@ export type PromptProps = {
|
|
|
50
50
|
masked?: number
|
|
51
51
|
/** Beside a session panel that names the model and profile, and a status bar: no bottom row. */
|
|
52
52
|
compact?: boolean
|
|
53
|
+
/** Text the box starts with when `inputKey` changes (a refined request, an example from the palette). */
|
|
54
|
+
initialValue?: string
|
|
55
|
+
/** False while an overlay (the palette, a suggestion list) takes the keyboard. Default true. */
|
|
56
|
+
focused?: boolean
|
|
57
|
+
/** Called with the text as it changes, for actions on what is being typed (Refine). */
|
|
58
|
+
onText?: (text: string) => void
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
/** Enter sends; Shift+Enter, Alt+Enter or Ctrl+J start a new line. */
|
|
@@ -66,7 +72,12 @@ export function Prompt(props: PromptProps) {
|
|
|
66
72
|
const ref = useRef<TextareaRenderable>(null)
|
|
67
73
|
const [rows, setRows] = useState(props.minRows)
|
|
68
74
|
useEffect(() => setRows(props.minRows), [props.inputKey, props.minRows])
|
|
75
|
+
// Inserted text (a completion, an example, a refined request) is continued from its end.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (props.initialValue) ref.current?.gotoBufferEnd()
|
|
78
|
+
}, [props.inputKey])
|
|
69
79
|
const fit = () => {
|
|
80
|
+
props.onText?.(ref.current?.plainText ?? "")
|
|
70
81
|
const lines = ref.current?.virtualLineCount ?? 1
|
|
71
82
|
setRows(Math.min(props.maxRows, Math.max(props.minRows, lines)))
|
|
72
83
|
}
|
|
@@ -95,7 +106,8 @@ export function Prompt(props: PromptProps) {
|
|
|
95
106
|
key={props.inputKey}
|
|
96
107
|
ref={ref}
|
|
97
108
|
placeholder={props.placeholder}
|
|
98
|
-
focused
|
|
109
|
+
focused={props.focused !== false}
|
|
110
|
+
initialValue={props.initialValue || ""}
|
|
99
111
|
wrapMode="word"
|
|
100
112
|
keyBindings={PROMPT_KEYS}
|
|
101
113
|
onSubmit={() => props.onSubmit(ref.current?.plainText ?? "")}
|
|
@@ -162,9 +174,23 @@ export function Spinner({ kind = "bar", color = theme.warn }: { kind?: "bar" | "
|
|
|
162
174
|
)
|
|
163
175
|
}
|
|
164
176
|
|
|
165
|
-
|
|
177
|
+
/** `drop`: the order hints give way when the row is too narrow, highest first; without it a hint always stays. */
|
|
178
|
+
export type Hint = { key: string; label: string; onPress?: () => void; drop?: number }
|
|
179
|
+
|
|
180
|
+
/** The hints that fit in `room` columns: whole hints give way, by their `drop`, rather than every label being squeezed. */
|
|
181
|
+
export function fitHints(items: Hint[], room: number): Hint[] {
|
|
182
|
+
const cells = (list: Hint[]) => list.reduce((n, h, i) => n + h.key.length + (h.label ? h.label.length + 1 : 0) + (i ? 2 : 0), 0)
|
|
183
|
+
let kept = [...items]
|
|
184
|
+
const order = items.filter((h) => h.drop !== undefined).sort((a, b) => b.drop! - a.drop!)
|
|
185
|
+
for (const h of order) {
|
|
186
|
+
if (cells(kept) <= room) break
|
|
187
|
+
kept = kept.filter((k) => k !== h)
|
|
188
|
+
}
|
|
189
|
+
return kept
|
|
190
|
+
}
|
|
166
191
|
|
|
167
|
-
export function Hints({ width, items, left }: { width: number; items: Hint[]; left?: ReactNode }) {
|
|
192
|
+
export function Hints({ width, items: all, left, reserve = 0 }: { width: number; items: Hint[]; left?: ReactNode; reserve?: number }) {
|
|
193
|
+
const items = fitHints(all, width - reserve)
|
|
168
194
|
return (
|
|
169
195
|
<box style={{ width, height: 1, flexShrink: 0, flexDirection: "row", justifyContent: "space-between" }}>
|
|
170
196
|
<box style={{ flexDirection: "row" }}>{left}</box>
|
|
@@ -180,6 +206,55 @@ export function Hints({ width, items, left }: { width: number; items: Hint[]; le
|
|
|
180
206
|
)
|
|
181
207
|
}
|
|
182
208
|
|
|
209
|
+
/**
|
|
210
|
+
* A button under the prompt: an icon, a label, and its key, on a raised background; `active` fills it with its colour
|
|
211
|
+
* (Refine once there is text to refine). `drop`: when it gives way on a narrow row, highest first.
|
|
212
|
+
*/
|
|
213
|
+
export type Button = { icon: string; label: string; keys?: string; color: string; onPress: () => void; active?: boolean; drop?: number }
|
|
214
|
+
|
|
215
|
+
const buttonCells = (b: Button) => b.icon.length + 1 + b.label.length + (b.keys ? b.keys.length + 1 : 0) + 3
|
|
216
|
+
|
|
217
|
+
/** The buttons that fit in `room` columns; a narrow row first loses whole buttons by their `drop`, then the keys. */
|
|
218
|
+
export function fitButtons(items: Button[], room: number): Button[] {
|
|
219
|
+
const cells = (list: Button[]) => list.reduce((n, b) => n + buttonCells(b), 0)
|
|
220
|
+
let kept = [...items]
|
|
221
|
+
for (const b of items.filter((x) => x.drop !== undefined).sort((x, y) => y.drop! - x.drop!)) {
|
|
222
|
+
if (cells(kept) <= room) return kept
|
|
223
|
+
kept = kept.filter((k) => k !== b)
|
|
224
|
+
}
|
|
225
|
+
return cells(kept) <= room ? kept : kept.map((b) => ({ ...b, keys: undefined }))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The row under the prompt: buttons on the left, the hints (close, a note) on the right. */
|
|
229
|
+
export function ButtonBar({ width, buttons, hints, left }: { width: number; buttons: Button[]; hints: Hint[]; left?: ReactNode }) {
|
|
230
|
+
const hintCells = hints.reduce((n, h, i) => n + h.key.length + (h.label ? h.label.length + 1 : 0) + (i ? 2 : 0), 0)
|
|
231
|
+
const shown = fitButtons(buttons, Math.max(0, width - hintCells - (left ? 22 : 0) - 1))
|
|
232
|
+
return (
|
|
233
|
+
<box style={{ width, height: 1, flexShrink: 0, flexDirection: "row", justifyContent: "space-between" }}>
|
|
234
|
+
<box style={{ flexDirection: "row" }}>
|
|
235
|
+
{left}
|
|
236
|
+
{shown.map((b) => (
|
|
237
|
+
<box key={b.label} onMouseDown={b.onPress} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: b.active ? b.color : theme.button }}>
|
|
238
|
+
<text>
|
|
239
|
+
<span fg={b.active ? theme.onButton : b.color}>{`${b.icon} `}</span>
|
|
240
|
+
<span fg={b.active ? theme.onButton : theme.text}>{b.label}</span>
|
|
241
|
+
{b.keys ? <span fg={b.active ? theme.onButton : theme.faint}>{` ${b.keys}`}</span> : null}
|
|
242
|
+
</text>
|
|
243
|
+
</box>
|
|
244
|
+
))}
|
|
245
|
+
</box>
|
|
246
|
+
<box style={{ flexDirection: "row" }}>
|
|
247
|
+
{hints.map((item, i) => (
|
|
248
|
+
<text key={`${item.key}-${item.label}`} onMouseDown={item.onPress}>
|
|
249
|
+
<span fg={theme.text}>{`${i ? " " : ""}${item.key}`}</span>
|
|
250
|
+
<span fg={item.onPress ? theme.muted : theme.faint}>{item.label ? ` ${item.label}` : ""}</span>
|
|
251
|
+
</text>
|
|
252
|
+
))}
|
|
253
|
+
</box>
|
|
254
|
+
</box>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
183
258
|
export type Choice = { id: string; label: string }
|
|
184
259
|
|
|
185
260
|
export type ChoicePanelProps = {
|
package/src/tui/inspector.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import type { ScrollBoxRenderable } from "@opentui/core"
|
|
|
2
2
|
import type { ReactNode, RefObject } from "react"
|
|
3
3
|
|
|
4
4
|
import type { Usage } from "../types.ts"
|
|
5
|
-
import { Spinner } from "./components.tsx"
|
|
5
|
+
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"
|
|
@@ -25,15 +25,33 @@ export const SECTION = {
|
|
|
25
25
|
/** Rows a panel's border takes: top and bottom. */
|
|
26
26
|
export const PANEL_CHROME = 2
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
export
|
|
28
|
+
/** The panels keys can move between (shift+tab), after the chat; the order they sit in on screen. */
|
|
29
|
+
export type Focus = "chat" | "plan" | "activity" | "files"
|
|
30
|
+
export const FOCUS_ORDER: Focus[] = ["chat", "plan", "activity", "files"]
|
|
31
|
+
|
|
32
|
+
/** The next panel shift+tab moves to, past any that are not on screen. */
|
|
33
|
+
export function nextFocus(at: Focus, shown: (f: Focus) => boolean): Focus {
|
|
34
|
+
for (let i = 1; i <= FOCUS_ORDER.length; i++) {
|
|
35
|
+
const f = FOCUS_ORDER[(FOCUS_ORDER.indexOf(at) + i) % FOCUS_ORDER.length]!
|
|
36
|
+
if (shown(f)) return f
|
|
37
|
+
}
|
|
38
|
+
return "chat"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A titled, rounded panel; `width` and `height` include the border. Focused, its border takes the section's colour and
|
|
43
|
+
* its keys show in the bottom border.
|
|
44
|
+
*/
|
|
45
|
+
export function Panel(props: { title: string; color: string; width?: number; height?: number; focused?: boolean; keys?: string; onFocus?: () => void; children: ReactNode }) {
|
|
30
46
|
return (
|
|
31
47
|
<box
|
|
32
48
|
border
|
|
33
49
|
borderStyle="rounded"
|
|
34
|
-
borderColor={theme.border}
|
|
50
|
+
borderColor={props.focused ? props.color : theme.border}
|
|
35
51
|
title={` ${props.title} `}
|
|
36
52
|
titleColor={props.color}
|
|
53
|
+
{...(props.focused && props.keys ? { bottomTitle: ` ${props.keys} `, bottomTitleAlignment: "right" as const } : {})}
|
|
54
|
+
{...(props.onFocus ? { onMouseDown: props.onFocus } : {})}
|
|
37
55
|
style={{
|
|
38
56
|
flexDirection: "column",
|
|
39
57
|
paddingLeft: 1,
|
|
@@ -84,7 +102,30 @@ export function SessionPanel({ info, width }: { info: SessionInfo; width: number
|
|
|
84
102
|
}
|
|
85
103
|
|
|
86
104
|
/** The right pane's first line: who is working on what and for how long, and Stop while something runs. */
|
|
87
|
-
|
|
105
|
+
/** How far the agent's own plan has got, while it has one of two steps or more. */
|
|
106
|
+
export function planProgress(s: Inspector): { done: number; total: number } | null {
|
|
107
|
+
return s.plan.length > 1 ? { done: s.plan.filter((p) => p.status === "completed").length, total: s.plan.length } : null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** "███████░░░░░ 5/8": how far a suite has got, in `cells` cells. */
|
|
111
|
+
export function progressBar(done: number, total: number, cells: number): [string, string] {
|
|
112
|
+
const filled = total ? Math.round((Math.min(done, total) / total) * cells) : 0
|
|
113
|
+
return ["█".repeat(filled), "░".repeat(cells - filled)]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function StatusBar(props: {
|
|
117
|
+
running: boolean
|
|
118
|
+
who: string
|
|
119
|
+
activity: string
|
|
120
|
+
elapsed: string
|
|
121
|
+
status: string
|
|
122
|
+
width: number
|
|
123
|
+
onStop: () => void
|
|
124
|
+
/** A suite's cases: done so far, of how many. */
|
|
125
|
+
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. */
|
|
127
|
+
actions?: Button[]
|
|
128
|
+
}) {
|
|
88
129
|
const done = props.status === "done"
|
|
89
130
|
const failed = props.status === "fail"
|
|
90
131
|
const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
|
|
@@ -97,7 +138,13 @@ export function StatusBar(props: { running: boolean; who: string; activity: stri
|
|
|
97
138
|
: props.status === "stopped"
|
|
98
139
|
? "Last request stopped"
|
|
99
140
|
: "Ready for your next request"
|
|
100
|
-
const
|
|
141
|
+
const bar = props.running && props.progress && props.progress.total > 1 ? props.progress : null
|
|
142
|
+
const cells = 12
|
|
143
|
+
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))
|
|
147
|
+
const [full, empty] = bar ? progressBar(bar.done, bar.total, cells) : ["", ""]
|
|
101
148
|
return (
|
|
102
149
|
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
|
|
103
150
|
<box style={{ flexDirection: "row" }}>
|
|
@@ -107,14 +154,32 @@ export function StatusBar(props: { running: boolean; who: string; activity: stri
|
|
|
107
154
|
<span fg={props.running ? theme.muted : color}>{`${props.running ? " " : " "}${clip(what, room)}`}</span>
|
|
108
155
|
</text>
|
|
109
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
|
+
))}
|
|
110
167
|
{props.running ? (
|
|
111
|
-
<box style={{ flexDirection: "row" }}>
|
|
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>
|
|
175
|
+
) : null}
|
|
112
176
|
<text fg={theme.faint}>{`${props.elapsed} `}</text>
|
|
113
177
|
<box onMouseDown={props.onStop} style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.warn }}>
|
|
114
178
|
<text fg={theme.onButton}>{"■ Stop · esc"}</text>
|
|
115
179
|
</box>
|
|
116
180
|
</box>
|
|
117
181
|
) : null}
|
|
182
|
+
</box>
|
|
118
183
|
</box>
|
|
119
184
|
)
|
|
120
185
|
}
|
|
@@ -132,9 +197,9 @@ const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
|
|
|
132
197
|
skipped: ["■", theme.faint],
|
|
133
198
|
}
|
|
134
199
|
|
|
135
|
-
type PlanLine = { mark: string; color: string; text: string; textColor: string }
|
|
200
|
+
export type PlanLine = { mark: string; color: string; text: string; textColor: string }
|
|
136
201
|
|
|
137
|
-
function planLines(s: Inspector): PlanLine[] {
|
|
202
|
+
export function planLines(s: Inspector): PlanLine[] {
|
|
138
203
|
const lines: PlanLine[] = []
|
|
139
204
|
for (const p of s.plan) {
|
|
140
205
|
const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
|
|
@@ -154,20 +219,33 @@ export function planRows(s: Inspector, cap: number): number {
|
|
|
154
219
|
return n ? Math.min(cap, n) + PANEL_CHROME : 0
|
|
155
220
|
}
|
|
156
221
|
|
|
157
|
-
/** The
|
|
158
|
-
export function
|
|
222
|
+
/** The rows of `total` a panel of `rows` shows: the latest, or, while a row is selected, a window that keeps it in view. */
|
|
223
|
+
export function windowRows(total: number, rows: number, selected?: number): [number, number] {
|
|
224
|
+
if (selected === undefined) return [Math.max(0, total - rows), total]
|
|
225
|
+
const first = Math.max(0, Math.min(selected - Math.floor(rows / 2), total - rows))
|
|
226
|
+
return [first, Math.min(total, first + rows)]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 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 }) {
|
|
231
|
+
const { s, width, height } = props
|
|
159
232
|
const lines = planLines(s)
|
|
160
233
|
const done = s.plan.filter((p) => p.status === "completed").length
|
|
161
234
|
const count = s.plan.length ? ` · ${done} of ${s.plan.length}` : s.progress.items.length ? ` · ${clip(s.progress.title, 40)}` : ""
|
|
162
|
-
const
|
|
235
|
+
const [first, last] = windowRows(lines.length, Math.max(1, height - PANEL_CHROME), props.selected)
|
|
163
236
|
return (
|
|
164
|
-
<Panel title={`Plan${count}`} color={SECTION.plan} width={width} height={height}>
|
|
165
|
-
{
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
<
|
|
169
|
-
|
|
170
|
-
|
|
237
|
+
<Panel title={`Plan${count}`} color={SECTION.plan} width={width} height={height} focused={props.selected !== undefined} keys="↑↓ select · enter details · esc chat" onFocus={props.onFocus}>
|
|
238
|
+
{lines.slice(first, last).map((l, i) => {
|
|
239
|
+
const active = first + i === props.selected
|
|
240
|
+
return (
|
|
241
|
+
<box key={first + i} style={{ flexDirection: "row", height: 1, flexShrink: 0, backgroundColor: active ? theme.button : undefined }}>
|
|
242
|
+
<text>
|
|
243
|
+
<span fg={l.color}>{`${l.mark} `}</span>
|
|
244
|
+
<span fg={active ? theme.text : l.textColor}>{clip(l.text, Math.max(10, width - 8))}</span>
|
|
245
|
+
</text>
|
|
246
|
+
</box>
|
|
247
|
+
)
|
|
248
|
+
})}
|
|
171
249
|
</Panel>
|
|
172
250
|
)
|
|
173
251
|
}
|
|
@@ -184,6 +262,12 @@ const ACTIVITY_STYLE: Record<string, [string, string, string]> = {
|
|
|
184
262
|
err: ["✗", theme.err, theme.text],
|
|
185
263
|
}
|
|
186
264
|
|
|
265
|
+
/** A tool line's call and its arguments in brackets, or null when it has none. */
|
|
266
|
+
export function toolArgs(text: string): [string, string] | null {
|
|
267
|
+
const at = text.indexOf(" (")
|
|
268
|
+
return at > 0 && text.endsWith(")") ? [text.slice(0, at), text.slice(at + 1)] : null
|
|
269
|
+
}
|
|
270
|
+
|
|
187
271
|
/** One activity line, its mark in a column of its own so wrapped text stays under the text. */
|
|
188
272
|
export function ActivityRow({ line }: { line: Line }) {
|
|
189
273
|
const [mark, markColor, textColor] = ACTIVITY_STYLE[line.kind] || ["·", theme.faint, theme.muted]
|
|
@@ -192,26 +276,78 @@ export function ActivityRow({ line }: { line: Line }) {
|
|
|
192
276
|
<text fg={markColor} style={{ width: 2, flexShrink: 0 }}>
|
|
193
277
|
{mark}
|
|
194
278
|
</text>
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
279
|
+
{line.kind === "tool" && toolArgs(line.text) ? (
|
|
280
|
+
// "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>
|
|
285
|
+
) : (
|
|
286
|
+
<text fg={textColor} style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
287
|
+
{line.text || " "}
|
|
288
|
+
</text>
|
|
289
|
+
)}
|
|
198
290
|
</box>
|
|
199
291
|
)
|
|
200
292
|
}
|
|
201
293
|
|
|
202
|
-
/**
|
|
203
|
-
export
|
|
294
|
+
/** What the activity panel can narrow to, and the key for each while it has focus. */
|
|
295
|
+
export const ACTIVITY_FILTERS = [
|
|
296
|
+
{ id: "all", label: "All", key: "a", kinds: null },
|
|
297
|
+
{ id: "errors", label: "Errors", key: "e", kinds: ["err"] },
|
|
298
|
+
{ id: "steps", label: "Steps", key: "s", kinds: ["ok", "err", "thought", "memory", "timing"] },
|
|
299
|
+
{ id: "tools", label: "Tools", key: "t", kinds: ["tool", "file", "code"] },
|
|
300
|
+
] as const
|
|
301
|
+
export type ActivityFilter = (typeof ACTIVITY_FILTERS)[number]["id"]
|
|
302
|
+
|
|
303
|
+
export function filterActivity(lines: Line[], filter: ActivityFilter): Line[] {
|
|
304
|
+
const kinds = ACTIVITY_FILTERS.find((f) => f.id === filter)?.kinds
|
|
305
|
+
return kinds ? lines.filter((l) => (kinds as readonly string[]).includes(l.kind)) : lines
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Everything the agents do, newest at the bottom; focused, it scrolls and narrows to errors, steps, or tools. */
|
|
309
|
+
export function ActivityPanel(props: {
|
|
310
|
+
lines: Line[]
|
|
311
|
+
scrollRef: RefObject<ScrollBoxRenderable | null>
|
|
312
|
+
width: number
|
|
313
|
+
height: number
|
|
314
|
+
filter?: ActivityFilter
|
|
315
|
+
focused?: boolean
|
|
316
|
+
onFocus?: () => void
|
|
317
|
+
onFilter?: (f: ActivityFilter) => void
|
|
318
|
+
}) {
|
|
319
|
+
const filter = props.filter || "all"
|
|
320
|
+
const shown = filterActivity(props.lines, filter)
|
|
321
|
+
const label = ACTIVITY_FILTERS.find((f) => f.id === filter)!.label
|
|
322
|
+
const count = filter === "all" ? (shown.length ? ` · ${shown.length}` : "") : ` · ${label} ${shown.length} of ${props.lines.length}`
|
|
204
323
|
return (
|
|
205
|
-
<Panel title={`Activity${
|
|
206
|
-
{props.
|
|
324
|
+
<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
|
+
{props.focused || filter !== "all" ? (
|
|
326
|
+
<box style={{ flexDirection: "row", height: 1, flexShrink: 0 }}>
|
|
327
|
+
{ACTIVITY_FILTERS.map((f) => {
|
|
328
|
+
const on = f.id === filter
|
|
329
|
+
return (
|
|
330
|
+
<box key={f.id} onMouseDown={() => props.onFilter?.(f.id)} style={{ paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: on ? SECTION.activity : theme.button }}>
|
|
331
|
+
<text>
|
|
332
|
+
<span fg={on ? theme.onButton : theme.text}>{f.key}</span>
|
|
333
|
+
<span fg={on ? theme.onButton : theme.muted}>{` ${f.label}`}</span>
|
|
334
|
+
</text>
|
|
335
|
+
</box>
|
|
336
|
+
)
|
|
337
|
+
})}
|
|
338
|
+
</box>
|
|
339
|
+
) : null}
|
|
340
|
+
{shown.length ? (
|
|
207
341
|
<scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
|
|
208
|
-
{
|
|
342
|
+
{shown.map((line) => (
|
|
209
343
|
<ActivityRow key={line.id} line={line} />
|
|
210
344
|
))}
|
|
211
345
|
</scrollbox>
|
|
212
346
|
) : (
|
|
213
347
|
<box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
|
|
214
|
-
<text fg={theme.faint}>
|
|
348
|
+
<text fg={theme.faint}>
|
|
349
|
+
{clip(props.lines.length ? `Nothing under ${label} yet` : "Tool calls, browser steps, and the agents' reasoning show here", Math.max(10, props.width - 4))}
|
|
350
|
+
</text>
|
|
215
351
|
</box>
|
|
216
352
|
)}
|
|
217
353
|
</Panel>
|
|
@@ -231,31 +367,37 @@ function fileLabel(path: string, room: number): [string, string] {
|
|
|
231
367
|
return [name, dir && (dir.length <= space || space >= 12) ? tailCut(dir, space) : ""]
|
|
232
368
|
}
|
|
233
369
|
|
|
234
|
-
export function FilesPanel(
|
|
370
|
+
export function FilesPanel(props: { s: Inspector; width: number; height: number; selected?: number; onFocus?: () => void; onOpen?: (file: string) => void }) {
|
|
371
|
+
const { s, width, height } = props
|
|
235
372
|
const files = filesByUse(s)
|
|
236
373
|
const rows = height - PANEL_CHROME
|
|
237
374
|
const room = Math.max(8, width - 10)
|
|
238
|
-
const
|
|
375
|
+
const focused = props.selected !== undefined
|
|
376
|
+
// Unfocused: the most recent, and how many more; focused: a window that follows the selection through all of them.
|
|
377
|
+
const [first, last] = focused ? windowRows(files.length, rows, props.selected) : [0, files.length > rows ? rows - 1 : files.length]
|
|
239
378
|
return (
|
|
240
|
-
<Panel title={`Files${files.length ? ` · ${files.length}` : ""}`} color={SECTION.files} width={width} height={height}>
|
|
379
|
+
<Panel title={`Files${files.length ? ` · ${files.length}` : ""}`} color={SECTION.files} width={width} height={height} focused={focused} keys="enter open" onFocus={props.onFocus}>
|
|
241
380
|
{files.length ? (
|
|
242
|
-
|
|
381
|
+
files.slice(first, last).map((f, i) => {
|
|
243
382
|
const [mark, color] = FILE_MARK[f.last]
|
|
244
383
|
const [name, dir] = fileLabel(f.path, room)
|
|
245
384
|
const times = f.reads + f.changes
|
|
385
|
+
const active = first + i === props.selected
|
|
246
386
|
return (
|
|
247
|
-
<
|
|
248
|
-
<
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
387
|
+
<box key={f.path} onMouseDown={() => props.onOpen?.(f.path)} style={{ flexDirection: "row", height: 1, flexShrink: 0, backgroundColor: active ? theme.button : undefined }}>
|
|
388
|
+
<text>
|
|
389
|
+
<span fg={color}>{`${mark} `}</span>
|
|
390
|
+
<span fg={f.changes || active ? theme.text : theme.muted}>{name}</span>
|
|
391
|
+
{dir ? <span fg={theme.faint}>{` ${dir}`}</span> : null}
|
|
392
|
+
{times > 1 ? <span fg={theme.faint}>{` ×${times}`}</span> : null}
|
|
393
|
+
</text>
|
|
394
|
+
</box>
|
|
253
395
|
)
|
|
254
396
|
})
|
|
255
397
|
) : (
|
|
256
398
|
<text fg={theme.faint}>{"None yet"}</text>
|
|
257
399
|
)}
|
|
258
|
-
{files.length >
|
|
400
|
+
{!focused && files.length > last ? <text fg={theme.faint}>{`+${files.length - last} more`}</text> : null}
|
|
259
401
|
</Panel>
|
|
260
402
|
)
|
|
261
403
|
}
|
package/src/tui/next.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { FollowUp } from "../brain/prompt.ts"
|
|
5
|
+
import type { Produced } from "./transcript.tsx"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What to do next, after a request: a few one-click follow-ups from what it actually produced (a run that passed and
|
|
9
|
+
* has no test yet, a failed run, a report to open). Requests are put in the prompt to adjust; nothing is sent by a chip.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type NextStep = { label: string; run: { kind: "insert"; text: string } | { kind: "open"; path: string } }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `followUps`: what ForgeQE's agent suggests from its answer ("Write a release report"), after what the request
|
|
16
|
+
* produced; a follow-up that repeats a step already offered is left out.
|
|
17
|
+
*/
|
|
18
|
+
export function nextSteps(p: Produced, opts: { testsOn: boolean; followUps?: FollowUp[] }): NextStep[] {
|
|
19
|
+
const steps: NextStep[] = []
|
|
20
|
+
const report = p.reports.at(-1)
|
|
21
|
+
if (report && existsSync(report.html)) steps.push({ label: "Open the report", run: { kind: "open", path: report.html } })
|
|
22
|
+
const run = p.runs.at(-1)
|
|
23
|
+
const suite = p.suites.at(-1)
|
|
24
|
+
if (suite) {
|
|
25
|
+
if (existsSync(suite.html)) steps.push({ label: "Open the suite report", run: { kind: "open", path: suite.html } })
|
|
26
|
+
steps.push({ label: "Rerun the cases that failed", run: { kind: "insert", text: "Run the cases that failed in the last suite again" } })
|
|
27
|
+
} else if (run) {
|
|
28
|
+
const report = path.join(run.runDir, "report.html")
|
|
29
|
+
if (run.status === "complete" && !p.tests.some((t) => t.runDir === run.runDir)) steps.push({ label: "Write a test for this run", run: { kind: "insert", text: "Write a test for the last run" } })
|
|
30
|
+
if (run.status !== "complete") {
|
|
31
|
+
steps.push({ label: "Why did it fail?", run: { kind: "insert", text: "Why did the last run fail, and what should change?" } })
|
|
32
|
+
steps.push({ label: "Run it again, visible", run: { kind: "insert", text: "Run the last goal again in a visible browser" } })
|
|
33
|
+
}
|
|
34
|
+
if (existsSync(report)) steps.push({ label: "Open the report", run: { kind: "open", path: report } })
|
|
35
|
+
}
|
|
36
|
+
const test = p.tests.at(-1)
|
|
37
|
+
if (test?.result?.testFile && test.result.status === "passed") steps.unshift({ label: "Open the new test", run: { kind: "open", path: test.result.testFile } })
|
|
38
|
+
for (const f of opts.followUps || []) {
|
|
39
|
+
if (steps.some((s) => s.label.toLowerCase() === f.label.toLowerCase())) continue
|
|
40
|
+
steps.push({ label: f.label, run: { kind: "insert", text: f.request } })
|
|
41
|
+
}
|
|
42
|
+
return steps.slice(0, 3)
|
|
43
|
+
}
|