@capagents/forgeqe 1.0.3 → 1.0.5
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 +41 -3
- package/docs/user-guide.html +37 -4
- package/package.json +1 -1
- package/prompts/README.md +1 -0
- package/prompts/refine.md +17 -0
- package/src/capabilities/status.ts +4 -3
- package/src/refine.ts +116 -0
- package/src/tui/App.tsx +440 -31
- package/src/tui/components.tsx +29 -3
- package/src/tui/inspector.tsx +159 -89
- package/src/tui/next.ts +32 -0
- package/src/tui/palette.tsx +169 -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 +47 -5
- 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>
|
package/src/tui/inspector.tsx
CHANGED
|
@@ -2,7 +2,6 @@ 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 { formatCost, type Cost, type Role } from "../usage.ts"
|
|
6
5
|
import { Spinner } from "./components.tsx"
|
|
7
6
|
import { filesByUse, type FileUse, type Inspector, type Progress } from "./session.ts"
|
|
8
7
|
import { clip, tailCut } from "./text.ts"
|
|
@@ -22,26 +21,37 @@ export const SECTION = {
|
|
|
22
21
|
tokens: theme.ok,
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
const ROLE_COLOR: Record<Role, string> = {
|
|
26
|
-
"ForgeQE's agent": theme.accent,
|
|
27
|
-
"browser agent": theme.thought,
|
|
28
|
-
Scribe: theme.ok,
|
|
29
|
-
reviewer: theme.warn,
|
|
30
|
-
judge: theme.memory,
|
|
31
|
-
}
|
|
32
24
|
|
|
33
25
|
/** Rows a panel's border takes: top and bottom. */
|
|
34
26
|
export const PANEL_CHROME = 2
|
|
35
27
|
|
|
36
|
-
/**
|
|
37
|
-
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 }) {
|
|
38
46
|
return (
|
|
39
47
|
<box
|
|
40
48
|
border
|
|
41
49
|
borderStyle="rounded"
|
|
42
|
-
borderColor={theme.border}
|
|
50
|
+
borderColor={props.focused ? props.color : theme.border}
|
|
43
51
|
title={` ${props.title} `}
|
|
44
52
|
titleColor={props.color}
|
|
53
|
+
{...(props.focused && props.keys ? { bottomTitle: ` ${props.keys} `, bottomTitleAlignment: "right" as const } : {})}
|
|
54
|
+
{...(props.onFocus ? { onMouseDown: props.onFocus } : {})}
|
|
45
55
|
style={{
|
|
46
56
|
flexDirection: "column",
|
|
47
57
|
paddingLeft: 1,
|
|
@@ -57,53 +67,58 @@ export function Panel(props: { title: string; color: string; width?: number; hei
|
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
export type SessionInfo = {
|
|
60
|
-
|
|
70
|
+
/** "gpt-5.4 (azure-gpt54)": the model, and the llms.json profile it comes from. */
|
|
61
71
|
model: string
|
|
62
|
-
auth: string
|
|
63
72
|
cwd: string
|
|
64
|
-
tests: string
|
|
65
|
-
browser: string
|
|
66
73
|
domain?: string
|
|
67
|
-
/** The profile's price is set in llms.json and differs from the model's list price. */
|
|
68
|
-
price?: string
|
|
69
74
|
started?: number
|
|
70
75
|
requests: number
|
|
71
|
-
total?: { usage: Usage; cost?: Cost }
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
/** Rows SessionPanel takes, border included. */
|
|
75
79
|
export function sessionRows(info: SessionInfo): number {
|
|
76
|
-
return
|
|
80
|
+
return 3 + (info.domain ? 1 : 0) + PANEL_CHROME
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
/** Top of the left pane: what
|
|
83
|
+
/** Top of the left pane: what the session works with, nothing more; tokens are in their own panel. */
|
|
80
84
|
export function SessionPanel({ info, width }: { info: SessionInfo; width: number }) {
|
|
81
|
-
const room = Math.max(10, width -
|
|
85
|
+
const room = Math.max(10, width - 15)
|
|
82
86
|
const row = (label: string, value: string, color: string = theme.text) => (
|
|
83
87
|
<text key={label}>
|
|
84
|
-
<span fg={theme.muted}>{label.padEnd(
|
|
88
|
+
<span fg={theme.muted}>{label.padEnd(10)}</span>
|
|
85
89
|
<span fg={color}>{clip(value, room)}</span>
|
|
86
90
|
</text>
|
|
87
91
|
)
|
|
88
92
|
const started = info.started ? new Date(info.started) : null
|
|
89
93
|
const since = started ? ` · since ${String(started.getHours()).padStart(2, "0")}:${String(started.getMinutes()).padStart(2, "0")}` : ""
|
|
90
|
-
const used = info.total ? `${formatTokens(info.total.usage.input + info.total.usage.output)} tokens${info.total.cost ? ` · ${formatCost(info.total.cost)}` : ""}` : "nothing yet"
|
|
91
94
|
return (
|
|
92
95
|
<Panel title={`Session${since}`} color={SECTION.session} width={width}>
|
|
93
|
-
{row("Model",
|
|
94
|
-
{row("Sign-in", info.auth, info.auth.startsWith("Entra") ? theme.ok : theme.text)}
|
|
96
|
+
{row("Model", info.model)}
|
|
95
97
|
{row("Folder", tailCut(info.cwd, room))}
|
|
96
|
-
{row("Tests", info.tests, info.tests.startsWith("written") ? theme.ok : theme.muted)}
|
|
97
|
-
{row("Browser", info.browser)}
|
|
98
98
|
{info.domain ? row("Domain", info.domain, theme.memory) : null}
|
|
99
|
-
{
|
|
100
|
-
{row("Used", `${info.requests} request${info.requests === 1 ? "" : "s"} · ${used}`, theme.muted)}
|
|
99
|
+
{row("Requests", String(info.requests), theme.muted)}
|
|
101
100
|
</Panel>
|
|
102
101
|
)
|
|
103
102
|
}
|
|
104
103
|
|
|
105
104
|
/** The right pane's first line: who is working on what and for how long, and Stop while something runs. */
|
|
106
|
-
|
|
105
|
+
/** "███████░░░░░ 5/8": how far a suite has got, in `cells` cells. */
|
|
106
|
+
export function progressBar(done: number, total: number, cells: number): [string, string] {
|
|
107
|
+
const filled = total ? Math.round((Math.min(done, total) / total) * cells) : 0
|
|
108
|
+
return ["█".repeat(filled), "░".repeat(cells - filled)]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function StatusBar(props: {
|
|
112
|
+
running: boolean
|
|
113
|
+
who: string
|
|
114
|
+
activity: string
|
|
115
|
+
elapsed: string
|
|
116
|
+
status: string
|
|
117
|
+
width: number
|
|
118
|
+
onStop: () => void
|
|
119
|
+
/** A suite's cases: done so far, of how many. */
|
|
120
|
+
progress?: { done: number; total: number } | null
|
|
121
|
+
}) {
|
|
107
122
|
const done = props.status === "done"
|
|
108
123
|
const failed = props.status === "fail"
|
|
109
124
|
const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
|
|
@@ -116,7 +131,11 @@ export function StatusBar(props: { running: boolean; who: string; activity: stri
|
|
|
116
131
|
: props.status === "stopped"
|
|
117
132
|
? "Last request stopped"
|
|
118
133
|
: "Ready for your next request"
|
|
119
|
-
const
|
|
134
|
+
const bar = props.running && props.progress && props.progress.total > 1 ? props.progress : null
|
|
135
|
+
const cells = 12
|
|
136
|
+
const barText = bar ? ` ${bar.done}/${bar.total} ` : ""
|
|
137
|
+
const room = Math.max(10, props.width - props.who.length - (props.running ? 30 : 6) - (bar ? cells + barText.length : 0))
|
|
138
|
+
const [full, empty] = bar ? progressBar(bar.done, bar.total, cells) : ["", ""]
|
|
120
139
|
return (
|
|
121
140
|
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
|
|
122
141
|
<box style={{ flexDirection: "row" }}>
|
|
@@ -128,6 +147,13 @@ export function StatusBar(props: { running: boolean; who: string; activity: stri
|
|
|
128
147
|
</box>
|
|
129
148
|
{props.running ? (
|
|
130
149
|
<box style={{ flexDirection: "row" }}>
|
|
150
|
+
{bar ? (
|
|
151
|
+
<text>
|
|
152
|
+
<span fg={theme.ok}>{full}</span>
|
|
153
|
+
<span fg={theme.border}>{empty}</span>
|
|
154
|
+
<span fg={theme.muted}>{barText}</span>
|
|
155
|
+
</text>
|
|
156
|
+
) : null}
|
|
131
157
|
<text fg={theme.faint}>{`${props.elapsed} `}</text>
|
|
132
158
|
<box onMouseDown={props.onStop} style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.warn }}>
|
|
133
159
|
<text fg={theme.onButton}>{"■ Stop · esc"}</text>
|
|
@@ -151,9 +177,9 @@ const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
|
|
|
151
177
|
skipped: ["■", theme.faint],
|
|
152
178
|
}
|
|
153
179
|
|
|
154
|
-
type PlanLine = { mark: string; color: string; text: string; textColor: string }
|
|
180
|
+
export type PlanLine = { mark: string; color: string; text: string; textColor: string }
|
|
155
181
|
|
|
156
|
-
function planLines(s: Inspector): PlanLine[] {
|
|
182
|
+
export function planLines(s: Inspector): PlanLine[] {
|
|
157
183
|
const lines: PlanLine[] = []
|
|
158
184
|
for (const p of s.plan) {
|
|
159
185
|
const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
|
|
@@ -173,20 +199,33 @@ export function planRows(s: Inspector, cap: number): number {
|
|
|
173
199
|
return n ? Math.min(cap, n) + PANEL_CHROME : 0
|
|
174
200
|
}
|
|
175
201
|
|
|
176
|
-
/** The
|
|
177
|
-
export function
|
|
202
|
+
/** The rows of `total` a panel of `rows` shows: the latest, or, while a row is selected, a window that keeps it in view. */
|
|
203
|
+
export function windowRows(total: number, rows: number, selected?: number): [number, number] {
|
|
204
|
+
if (selected === undefined) return [Math.max(0, total - rows), total]
|
|
205
|
+
const first = Math.max(0, Math.min(selected - Math.floor(rows / 2), total - rows))
|
|
206
|
+
return [first, Math.min(total, first + rows)]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The agent's to-do list, then the browser steps or suite cases; the latest that fit, or around the selected one. */
|
|
210
|
+
export function PlanPanel(props: { s: Inspector; width: number; height: number; selected?: number; onFocus?: () => void }) {
|
|
211
|
+
const { s, width, height } = props
|
|
178
212
|
const lines = planLines(s)
|
|
179
213
|
const done = s.plan.filter((p) => p.status === "completed").length
|
|
180
214
|
const count = s.plan.length ? ` · ${done} of ${s.plan.length}` : s.progress.items.length ? ` · ${clip(s.progress.title, 40)}` : ""
|
|
181
|
-
const
|
|
215
|
+
const [first, last] = windowRows(lines.length, Math.max(1, height - PANEL_CHROME), props.selected)
|
|
182
216
|
return (
|
|
183
|
-
<Panel title={`Plan${count}`} color={SECTION.plan} width={width} height={height}>
|
|
184
|
-
{
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
217
|
+
<Panel title={`Plan${count}`} color={SECTION.plan} width={width} height={height} focused={props.selected !== undefined} keys="↑↓ select · enter details · esc chat" onFocus={props.onFocus}>
|
|
218
|
+
{lines.slice(first, last).map((l, i) => {
|
|
219
|
+
const active = first + i === props.selected
|
|
220
|
+
return (
|
|
221
|
+
<box key={first + i} style={{ flexDirection: "row", height: 1, flexShrink: 0, backgroundColor: active ? theme.button : undefined }}>
|
|
222
|
+
<text>
|
|
223
|
+
<span fg={l.color}>{`${l.mark} `}</span>
|
|
224
|
+
<span fg={active ? theme.text : l.textColor}>{clip(l.text, Math.max(10, width - 8))}</span>
|
|
225
|
+
</text>
|
|
226
|
+
</box>
|
|
227
|
+
)
|
|
228
|
+
})}
|
|
190
229
|
</Panel>
|
|
191
230
|
)
|
|
192
231
|
}
|
|
@@ -218,19 +257,63 @@ export function ActivityRow({ line }: { line: Line }) {
|
|
|
218
257
|
)
|
|
219
258
|
}
|
|
220
259
|
|
|
221
|
-
/**
|
|
222
|
-
export
|
|
260
|
+
/** What the activity panel can narrow to, and the key for each while it has focus. */
|
|
261
|
+
export const ACTIVITY_FILTERS = [
|
|
262
|
+
{ id: "all", label: "All", key: "a", kinds: null },
|
|
263
|
+
{ id: "errors", label: "Errors", key: "e", kinds: ["err"] },
|
|
264
|
+
{ id: "steps", label: "Steps", key: "s", kinds: ["ok", "err", "thought", "memory", "timing"] },
|
|
265
|
+
{ id: "tools", label: "Tools", key: "t", kinds: ["tool", "file", "code"] },
|
|
266
|
+
] as const
|
|
267
|
+
export type ActivityFilter = (typeof ACTIVITY_FILTERS)[number]["id"]
|
|
268
|
+
|
|
269
|
+
export function filterActivity(lines: Line[], filter: ActivityFilter): Line[] {
|
|
270
|
+
const kinds = ACTIVITY_FILTERS.find((f) => f.id === filter)?.kinds
|
|
271
|
+
return kinds ? lines.filter((l) => (kinds as readonly string[]).includes(l.kind)) : lines
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Everything the agents do, newest at the bottom; focused, it scrolls and narrows to errors, steps, or tools. */
|
|
275
|
+
export function ActivityPanel(props: {
|
|
276
|
+
lines: Line[]
|
|
277
|
+
scrollRef: RefObject<ScrollBoxRenderable | null>
|
|
278
|
+
width: number
|
|
279
|
+
height: number
|
|
280
|
+
filter?: ActivityFilter
|
|
281
|
+
focused?: boolean
|
|
282
|
+
onFocus?: () => void
|
|
283
|
+
onFilter?: (f: ActivityFilter) => void
|
|
284
|
+
}) {
|
|
285
|
+
const filter = props.filter || "all"
|
|
286
|
+
const shown = filterActivity(props.lines, filter)
|
|
287
|
+
const label = ACTIVITY_FILTERS.find((f) => f.id === filter)!.label
|
|
288
|
+
const count = filter === "all" ? (shown.length ? ` · ${shown.length}` : "") : ` · ${label} ${shown.length} of ${props.lines.length}`
|
|
223
289
|
return (
|
|
224
|
-
<Panel title={`Activity${
|
|
225
|
-
{props.
|
|
290
|
+
<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}>
|
|
291
|
+
{props.focused || filter !== "all" ? (
|
|
292
|
+
<box style={{ flexDirection: "row", height: 1, flexShrink: 0 }}>
|
|
293
|
+
{ACTIVITY_FILTERS.map((f) => {
|
|
294
|
+
const on = f.id === filter
|
|
295
|
+
return (
|
|
296
|
+
<box key={f.id} onMouseDown={() => props.onFilter?.(f.id)} style={{ paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: on ? SECTION.activity : theme.button }}>
|
|
297
|
+
<text>
|
|
298
|
+
<span fg={on ? theme.onButton : theme.text}>{f.key}</span>
|
|
299
|
+
<span fg={on ? theme.onButton : theme.muted}>{` ${f.label}`}</span>
|
|
300
|
+
</text>
|
|
301
|
+
</box>
|
|
302
|
+
)
|
|
303
|
+
})}
|
|
304
|
+
</box>
|
|
305
|
+
) : null}
|
|
306
|
+
{shown.length ? (
|
|
226
307
|
<scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
|
|
227
|
-
{
|
|
308
|
+
{shown.map((line) => (
|
|
228
309
|
<ActivityRow key={line.id} line={line} />
|
|
229
310
|
))}
|
|
230
311
|
</scrollbox>
|
|
231
312
|
) : (
|
|
232
313
|
<box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
|
|
233
|
-
<text fg={theme.faint}>
|
|
314
|
+
<text fg={theme.faint}>
|
|
315
|
+
{clip(props.lines.length ? `Nothing under ${label} yet` : "Tool calls, browser steps, and the agents' reasoning show here", Math.max(10, props.width - 4))}
|
|
316
|
+
</text>
|
|
234
317
|
</box>
|
|
235
318
|
)}
|
|
236
319
|
</Panel>
|
|
@@ -250,31 +333,37 @@ function fileLabel(path: string, room: number): [string, string] {
|
|
|
250
333
|
return [name, dir && (dir.length <= space || space >= 12) ? tailCut(dir, space) : ""]
|
|
251
334
|
}
|
|
252
335
|
|
|
253
|
-
export function FilesPanel(
|
|
336
|
+
export function FilesPanel(props: { s: Inspector; width: number; height: number; selected?: number; onFocus?: () => void; onOpen?: (file: string) => void }) {
|
|
337
|
+
const { s, width, height } = props
|
|
254
338
|
const files = filesByUse(s)
|
|
255
339
|
const rows = height - PANEL_CHROME
|
|
256
340
|
const room = Math.max(8, width - 10)
|
|
257
|
-
const
|
|
341
|
+
const focused = props.selected !== undefined
|
|
342
|
+
// Unfocused: the most recent, and how many more; focused: a window that follows the selection through all of them.
|
|
343
|
+
const [first, last] = focused ? windowRows(files.length, rows, props.selected) : [0, files.length > rows ? rows - 1 : files.length]
|
|
258
344
|
return (
|
|
259
|
-
<Panel title={`Files${files.length ? ` · ${files.length}` : ""}`} color={SECTION.files} width={width} height={height}>
|
|
345
|
+
<Panel title={`Files${files.length ? ` · ${files.length}` : ""}`} color={SECTION.files} width={width} height={height} focused={focused} keys="enter open" onFocus={props.onFocus}>
|
|
260
346
|
{files.length ? (
|
|
261
|
-
|
|
347
|
+
files.slice(first, last).map((f, i) => {
|
|
262
348
|
const [mark, color] = FILE_MARK[f.last]
|
|
263
349
|
const [name, dir] = fileLabel(f.path, room)
|
|
264
350
|
const times = f.reads + f.changes
|
|
351
|
+
const active = first + i === props.selected
|
|
265
352
|
return (
|
|
266
|
-
<
|
|
267
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
353
|
+
<box key={f.path} onMouseDown={() => props.onOpen?.(f.path)} style={{ flexDirection: "row", height: 1, flexShrink: 0, backgroundColor: active ? theme.button : undefined }}>
|
|
354
|
+
<text>
|
|
355
|
+
<span fg={color}>{`${mark} `}</span>
|
|
356
|
+
<span fg={f.changes || active ? theme.text : theme.muted}>{name}</span>
|
|
357
|
+
{dir ? <span fg={theme.faint}>{` ${dir}`}</span> : null}
|
|
358
|
+
{times > 1 ? <span fg={theme.faint}>{` ×${times}`}</span> : null}
|
|
359
|
+
</text>
|
|
360
|
+
</box>
|
|
272
361
|
)
|
|
273
362
|
})
|
|
274
363
|
) : (
|
|
275
364
|
<text fg={theme.faint}>{"None yet"}</text>
|
|
276
365
|
)}
|
|
277
|
-
{files.length >
|
|
366
|
+
{!focused && files.length > last ? <text fg={theme.faint}>{`+${files.length - last} more`}</text> : null}
|
|
278
367
|
</Panel>
|
|
279
368
|
)
|
|
280
369
|
}
|
|
@@ -309,40 +398,21 @@ export function ContextPanel({ c, width, height }: { c: ContextInfo; width: numb
|
|
|
309
398
|
)
|
|
310
399
|
}
|
|
311
400
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
live?: number
|
|
317
|
-
width: number
|
|
318
|
-
height: number
|
|
319
|
-
}) {
|
|
320
|
-
const { roles, total, width } = props
|
|
321
|
-
// The label takes what the token count (7) and, when priced, the cost (two spaces and up to 7) leave.
|
|
322
|
-
const priced = roles.some((r) => r.cost) || Boolean(total.cost)
|
|
323
|
-
const labelWidth = Math.max(6, width - 4 - 7 - (priced ? 9 : 0))
|
|
324
|
-
const line = (label: string, labelColor: string, u: Usage, c: Cost | undefined, valueColor: string = theme.text) => (
|
|
401
|
+
/** What the session used, in tokens: the total and how it splits. Costs are left to reports and `forgeqe models`. */
|
|
402
|
+
export function TokensPanel(props: { total: Usage; width: number; height: number }) {
|
|
403
|
+
const { total, width } = props
|
|
404
|
+
const line = (label: string, n: number, color: string = theme.text) => (
|
|
325
405
|
<text key={label}>
|
|
326
|
-
<span fg={
|
|
327
|
-
<span fg={
|
|
328
|
-
{c ? <span fg={theme.muted}>{` ${formatCost(c)}`}</span> : null}
|
|
406
|
+
<span fg={theme.muted}>{label.padEnd(10)}</span>
|
|
407
|
+
<span fg={color}>{formatTokens(n).padStart(Math.max(7, Math.min(10, width - 14)))}</span>
|
|
329
408
|
</text>
|
|
330
409
|
)
|
|
331
410
|
return (
|
|
332
411
|
<Panel title="Tokens" color={SECTION.tokens} width={width} height={props.height}>
|
|
333
|
-
{
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
</text>
|
|
338
|
-
) : null}
|
|
339
|
-
{roles.length ? roles.map((r) => line(r.role, ROLE_COLOR[r.role] || theme.text, r.usage, r.cost)) : props.live ? null : <text fg={theme.faint}>{"None used yet"}</text>}
|
|
340
|
-
{roles.length > 1 ? line("Total", theme.text, total.usage, total.cost, theme.ok) : null}
|
|
341
|
-
{roles.length ? (
|
|
342
|
-
<text fg={theme.faint}>
|
|
343
|
-
{clip(`in ${formatTokens(total.usage.input)} · out ${formatTokens(total.usage.output)}${total.usage.cached ? ` · ${formatTokens(total.usage.cached)} cached` : ""}`, Math.max(10, width - 4))}
|
|
344
|
-
</text>
|
|
345
|
-
) : null}
|
|
412
|
+
{line("Total", total.input + total.output, theme.ok)}
|
|
413
|
+
{line("Input", total.input)}
|
|
414
|
+
{line("Output", total.output)}
|
|
415
|
+
{line("Cached", total.cached || 0, theme.muted)}
|
|
346
416
|
</Panel>
|
|
347
417
|
)
|
|
348
418
|
}
|
package/src/tui/next.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { Produced } from "./transcript.tsx"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What to do next, after a request: a few one-click follow-ups from what it actually produced (a run that passed and
|
|
8
|
+
* 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.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type NextStep = { label: string; run: { kind: "insert"; text: string } | { kind: "open"; path: string } }
|
|
12
|
+
|
|
13
|
+
export function nextSteps(p: Produced, opts: { testsOn: boolean }): NextStep[] {
|
|
14
|
+
const steps: NextStep[] = []
|
|
15
|
+
const run = p.runs.at(-1)
|
|
16
|
+
const suite = p.suites.at(-1)
|
|
17
|
+
if (suite) {
|
|
18
|
+
if (existsSync(suite.html)) steps.push({ label: "Open the suite report", run: { kind: "open", path: suite.html } })
|
|
19
|
+
steps.push({ label: "Rerun the cases that failed", run: { kind: "insert", text: "Run the cases that failed in the last suite again" } })
|
|
20
|
+
} else if (run) {
|
|
21
|
+
const report = path.join(run.runDir, "report.html")
|
|
22
|
+
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" } })
|
|
23
|
+
if (run.status !== "complete") {
|
|
24
|
+
steps.push({ label: "Why did it fail?", run: { kind: "insert", text: "Why did the last run fail, and what should change?" } })
|
|
25
|
+
steps.push({ label: "Run it again, visible", run: { kind: "insert", text: "Run the last goal again in a visible browser" } })
|
|
26
|
+
}
|
|
27
|
+
if (existsSync(report)) steps.push({ label: "Open the report", run: { kind: "open", path: report } })
|
|
28
|
+
}
|
|
29
|
+
const test = p.tests.at(-1)
|
|
30
|
+
if (test?.result?.testFile && test.result.status === "passed") steps.unshift({ label: "Open the new test", run: { kind: "open", path: test.result.testFile } })
|
|
31
|
+
return steps.slice(0, 3)
|
|
32
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { CAPABILITIES } from "../capabilities/registry.ts"
|
|
2
|
+
import { clip } from "./text.ts"
|
|
3
|
+
import { theme } from "./theme.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The command palette (ctrl+p): every action, command, profile, and example request, found by typing a few letters.
|
|
7
|
+
* Items that would start work only fill the prompt; nothing is sent without the user pressing enter there.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type PaletteGroup = "Action" | "Command" | "Profile" | "Ask"
|
|
11
|
+
|
|
12
|
+
export type PaletteRun =
|
|
13
|
+
/** Sent as if typed: a slash command. */
|
|
14
|
+
| { kind: "command"; text: string }
|
|
15
|
+
/** Put in the prompt to edit and send: an example request. */
|
|
16
|
+
| { kind: "insert"; text: string }
|
|
17
|
+
/** Something the shell does itself. */
|
|
18
|
+
| { kind: "action"; id: "refine" | "stop" | "details" | "chat" }
|
|
19
|
+
|
|
20
|
+
export type PaletteItem = { id: string; group: PaletteGroup; title: string; detail?: string; keys?: string; run: PaletteRun }
|
|
21
|
+
|
|
22
|
+
export const GROUP_COLOR: Record<PaletteGroup, string> = { Action: theme.warn, Command: theme.accent, Profile: theme.memory, Ask: theme.ok }
|
|
23
|
+
|
|
24
|
+
export function paletteItems(opts: { running: boolean; headed: boolean; detailsFull: boolean; profiles: string[]; active: string; narrow: boolean; view: "chat" | "details" }): PaletteItem[] {
|
|
25
|
+
const items: PaletteItem[] = []
|
|
26
|
+
const command = (id: string, title: string, text: string, detail?: string, keys?: string) => items.push({ id, group: "Command", title, detail, keys, run: { kind: "command", text } })
|
|
27
|
+
if (opts.running) items.push({ id: "stop", group: "Action", title: "Stop the running request", keys: "esc", run: { kind: "action", id: "stop" } })
|
|
28
|
+
items.push({ id: "refine", group: "Action", title: "Refine my request", detail: "check it against what ForgeQE knows before sending", keys: "ctrl+r", run: { kind: "action", id: "refine" } })
|
|
29
|
+
if (opts.narrow) items.push({ id: "view", group: "Action", title: opts.view === "chat" ? "Show the details view" : "Back to the chat", keys: "ctrl+o", run: { kind: "action", id: opts.view === "chat" ? "details" : "chat" } })
|
|
30
|
+
command("features", "What ForgeQE can do here", "/features", "and what each feature still needs")
|
|
31
|
+
command("auth", "Sign in to a system", "/auth", "Azure DevOps, GitHub, Jira, Confluence, TestRail, Zephyr, qTest, Xray")
|
|
32
|
+
command("auth-status", "What is signed in", "/auth status")
|
|
33
|
+
command("headed", opts.headed ? "Hide the browser window" : "Show the browser window", `/headed ${opts.headed ? "off" : "on"}`)
|
|
34
|
+
command("details", opts.detailsFull ? "Show progress only" : "Show everything the agents do", `/details ${opts.detailsFull ? "off" : "on"}`)
|
|
35
|
+
command("skills", "Skills each agent can use", "/skills")
|
|
36
|
+
command("logs", "Save this session's log to share", "/logs")
|
|
37
|
+
command("clear", "Clear the chat", "/clear")
|
|
38
|
+
command("help", "Help", "/help")
|
|
39
|
+
command("exit", "Quit ForgeQE", "/exit", undefined, "ctrl+c")
|
|
40
|
+
for (const p of opts.profiles) if (p !== opts.active) items.push({ id: `profile:${p}`, group: "Profile", title: `Use the ${p} profile`, keys: "tab", run: { kind: "command", text: `/profile ${p}` } })
|
|
41
|
+
for (const c of CAPABILITIES) for (const e of c.examples) items.push({ id: `ask:${c.id}:${e}`, group: "Ask", title: e, detail: c.title, run: { kind: "insert", text: e } })
|
|
42
|
+
return items
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How well `query` matches `text`, case ignored; null when it does not. In order of strength: the text contains the
|
|
47
|
+
* query (best at the start of a word), every typed word starts a word of the text ("run sp" finds "Run … sprint"),
|
|
48
|
+
* or a short query is the text's initials ("rr" finds "Refine my request"). Letters merely scattered through the text
|
|
49
|
+
* do not match: they find everything and help nobody.
|
|
50
|
+
*/
|
|
51
|
+
export function fuzzyScore(query: string, text: string): number | null {
|
|
52
|
+
const q = query.trim().toLowerCase()
|
|
53
|
+
if (!q) return 0
|
|
54
|
+
const t = text.toLowerCase()
|
|
55
|
+
const words = t.split(/[^a-z0-9]+/).filter(Boolean)
|
|
56
|
+
const at = t.indexOf(q)
|
|
57
|
+
if (at >= 0) return 100 + (at === 0 || /[^a-z0-9]/.test(t[at - 1]!) ? 20 : 0) - Math.min(20, at / 4)
|
|
58
|
+
const typed = q.split(/\s+/).filter(Boolean)
|
|
59
|
+
if (typed.length > 1 && typed.every((w) => words.some((x) => x.startsWith(w)))) return 70 - typed.length
|
|
60
|
+
if (typed.length === 1 && q.length >= 2 && q.length <= 5) {
|
|
61
|
+
if (words.map((w) => w[0]).join("").includes(q)) return 40
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The items that match, best first; with no query, in their own order. */
|
|
67
|
+
export function searchPalette(query: string, items: PaletteItem[], limit = 50): PaletteItem[] {
|
|
68
|
+
if (!query.trim()) return items.slice(0, limit)
|
|
69
|
+
return items
|
|
70
|
+
.map((item, order) => {
|
|
71
|
+
const own = fuzzyScore(query, item.title)
|
|
72
|
+
// Also what a command is typed as ("/auth", "/headed"), so people who know the command find it by its name.
|
|
73
|
+
const typedAs = item.run.kind === "command" ? item.run.text : ""
|
|
74
|
+
const more = fuzzyScore(query, `${item.title} ${item.detail || ""} ${typedAs} ${item.group}`)
|
|
75
|
+
const best = own === null ? (more === null ? null : more - 2) : Math.max(own + 2, more ?? own)
|
|
76
|
+
return { item, order, score: best }
|
|
77
|
+
})
|
|
78
|
+
.filter((x): x is { item: PaletteItem; order: number; score: number } => x.score !== null)
|
|
79
|
+
.sort((a, b) => b.score - a.score || a.order - b.order)
|
|
80
|
+
.slice(0, limit)
|
|
81
|
+
.map((x) => x.item)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function Palette(props: { width: number; height: number; query: string; items: PaletteItem[]; selected: number; onPick: (item: PaletteItem) => void; onHover: (index: number) => void }) {
|
|
85
|
+
const boxWidth = Math.min(100, props.width - 8)
|
|
86
|
+
const rows = Math.max(4, Math.min(16, props.height - 12))
|
|
87
|
+
// Keep the selection in view: the window of rows slides with it.
|
|
88
|
+
const first = Math.max(0, Math.min(props.selected - Math.floor(rows / 2), props.items.length - rows))
|
|
89
|
+
const shown = props.items.slice(first, first + rows)
|
|
90
|
+
const room = boxWidth - 6
|
|
91
|
+
return (
|
|
92
|
+
<box
|
|
93
|
+
position="absolute"
|
|
94
|
+
zIndex={100}
|
|
95
|
+
border
|
|
96
|
+
borderStyle="rounded"
|
|
97
|
+
borderColor={theme.accent}
|
|
98
|
+
title=" Command palette "
|
|
99
|
+
titleColor={theme.accent}
|
|
100
|
+
bottomTitle=" ↑↓ select · enter choose · esc close "
|
|
101
|
+
bottomTitleAlignment="right"
|
|
102
|
+
style={{ top: 2, left: Math.floor((props.width - boxWidth) / 2), width: boxWidth, flexDirection: "column", backgroundColor: theme.panel, paddingLeft: 1, paddingRight: 1 }}
|
|
103
|
+
>
|
|
104
|
+
<text>
|
|
105
|
+
<span fg={theme.accent}>{"› "}</span>
|
|
106
|
+
{props.query ? <span fg={theme.text}>{props.query}</span> : <span fg={theme.faint}>{"Type to find an action, a command, a profile, or something to ask"}</span>}
|
|
107
|
+
<span fg={theme.accent}>{"▏"}</span>
|
|
108
|
+
</text>
|
|
109
|
+
<text fg={theme.border}>{"─".repeat(Math.max(4, boxWidth - 4))}</text>
|
|
110
|
+
{shown.length ? (
|
|
111
|
+
shown.map((item, i) => {
|
|
112
|
+
const index = first + i
|
|
113
|
+
const active = index === props.selected
|
|
114
|
+
const keys = item.keys ? ` ${item.keys}` : ""
|
|
115
|
+
const titleRoom = Math.max(10, room - 10 - keys.length - (item.detail ? Math.min(34, item.detail.length) + 2 : 0))
|
|
116
|
+
return (
|
|
117
|
+
<box
|
|
118
|
+
key={item.id}
|
|
119
|
+
onMouseDown={() => props.onPick(item)}
|
|
120
|
+
onMouseOver={() => props.onHover(index)}
|
|
121
|
+
style={{ flexDirection: "row", justifyContent: "space-between", backgroundColor: active ? theme.button : theme.panel, flexShrink: 0 }}
|
|
122
|
+
>
|
|
123
|
+
<text>
|
|
124
|
+
<span fg={active ? theme.accent : theme.panel}>{"▌"}</span>
|
|
125
|
+
<span fg={GROUP_COLOR[item.group]}>{item.group.padEnd(8)}</span>
|
|
126
|
+
<span fg={active ? theme.text : theme.muted}>{clip(item.title, titleRoom)}</span>
|
|
127
|
+
{item.detail ? <span fg={theme.faint}>{` ${clip(item.detail, 34)}`}</span> : null}
|
|
128
|
+
</text>
|
|
129
|
+
<text fg={theme.faint}>{keys}</text>
|
|
130
|
+
</box>
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
) : (
|
|
134
|
+
<text fg={theme.faint}>{" Nothing matches. Enter puts what you typed in the prompt; esc closes."}</text>
|
|
135
|
+
)}
|
|
136
|
+
{props.items.length > rows ? <text fg={theme.faint}>{` ${props.items.length} matches`}</text> : null}
|
|
137
|
+
</box>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The suggestions above the prompt while "/" or "@" is typed: same rows as the palette, so the two read as one. */
|
|
142
|
+
export function SuggestList(props: { title: string; width: number; items: { label: string; detail?: string; color: string }[]; selected: number; onPick: (i: number) => void; onHover: (i: number) => void }) {
|
|
143
|
+
const room = props.width - 6
|
|
144
|
+
return (
|
|
145
|
+
<box
|
|
146
|
+
border
|
|
147
|
+
borderStyle="rounded"
|
|
148
|
+
borderColor={theme.border}
|
|
149
|
+
title={` ${props.title} `}
|
|
150
|
+
titleColor={theme.accent}
|
|
151
|
+
bottomTitle=" tab complete · ↑↓ · esc "
|
|
152
|
+
bottomTitleAlignment="right"
|
|
153
|
+
style={{ flexDirection: "column", width: props.width, paddingLeft: 1, paddingRight: 1, backgroundColor: theme.panel, flexShrink: 0 }}
|
|
154
|
+
>
|
|
155
|
+
{props.items.map((item, i) => {
|
|
156
|
+
const active = i === props.selected
|
|
157
|
+
return (
|
|
158
|
+
<box key={`${item.label}-${i}`} onMouseDown={() => props.onPick(i)} onMouseOver={() => props.onHover(i)} style={{ flexDirection: "row", backgroundColor: active ? theme.button : theme.panel, flexShrink: 0 }}>
|
|
159
|
+
<text>
|
|
160
|
+
<span fg={active ? theme.accent : theme.panel}>{"▌"}</span>
|
|
161
|
+
<span fg={item.color}>{clip(item.label, Math.max(10, Math.floor(room / 2)))}</span>
|
|
162
|
+
{item.detail ? <span fg={theme.faint}>{` ${clip(item.detail, Math.max(8, room - item.label.length - 4))}`}</span> : null}
|
|
163
|
+
</text>
|
|
164
|
+
</box>
|
|
165
|
+
)
|
|
166
|
+
})}
|
|
167
|
+
</box>
|
|
168
|
+
)
|
|
169
|
+
}
|