@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/App.tsx
CHANGED
|
@@ -30,9 +30,9 @@ import { initWorkspace } from "../init.ts"
|
|
|
30
30
|
import { ShortcutsPanel } from "./shortcuts.tsx"
|
|
31
31
|
import { ago, getStarted, projectLine, setupSentence, type Fact, type GetStarted, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
|
|
32
32
|
import { TIPS, formatDuration, formatTokens, packageVersion, shortCwd, theme } from "./theme.ts"
|
|
33
|
-
import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
|
|
33
|
+
import { emptyProduced, Line, Produced, producedCard, settleCall, sinceRequest, TestsSetting, TranscriptLine, trimLines } from "./transcript.tsx"
|
|
34
34
|
import { Dialog, dialogSpec } from "./dialogs.ts"
|
|
35
|
-
import { activityFromLog, clip,
|
|
35
|
+
import { activityFromLog, clip, modelLabel } from "./text.ts"
|
|
36
36
|
import { featuresText } from "../capabilities/render.ts"
|
|
37
37
|
import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
|
|
38
38
|
import { commandSuggestions, complete, lookupWorkItem, recentRuns, referenceSuggestions, repoFiles, triggerAt, type Suggestion, type WorkItem } from "./suggest.ts"
|
|
@@ -49,18 +49,19 @@ import {
|
|
|
49
49
|
ActivityPanel,
|
|
50
50
|
ContextPanel,
|
|
51
51
|
FilesPanel,
|
|
52
|
+
InfoStrip,
|
|
53
|
+
nowNext,
|
|
52
54
|
nextFocus,
|
|
53
55
|
planLines,
|
|
54
56
|
PlanPanel,
|
|
55
57
|
planProgress,
|
|
56
58
|
planRows,
|
|
57
|
-
|
|
59
|
+
STATUS_ROWS,
|
|
58
60
|
StatusBar,
|
|
59
61
|
TokensPanel,
|
|
60
62
|
type ActivityFilter,
|
|
61
63
|
type ContextInfo,
|
|
62
64
|
type Focus,
|
|
63
|
-
type SessionInfo,
|
|
64
65
|
} from "./inspector.tsx"
|
|
65
66
|
import {
|
|
66
67
|
addSkill,
|
|
@@ -123,6 +124,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
123
124
|
widthRef.current = width
|
|
124
125
|
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
125
126
|
const activityScroll = useRef<ScrollBoxRenderable>(null)
|
|
127
|
+
/** Refine's body, which scrolls when the refined request is longer than the screen has room for. */
|
|
128
|
+
const refineScroll = useRef<ScrollBoxRenderable>(null)
|
|
126
129
|
const submitRef = useRef<(value: string) => void>(() => {})
|
|
127
130
|
/** Refines what is in the prompt (phase two of the palette work wires it). */
|
|
128
131
|
const refineRef = useRef<() => void>(() => {})
|
|
@@ -239,17 +242,33 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
239
242
|
thinkingRef.current = thinking
|
|
240
243
|
|
|
241
244
|
/** Lines go to the chat, or while a request runs and they are not results, to the activity pane; `pane` overrides that. */
|
|
242
|
-
const push = (kind: Line["kind"], text: string, pane?: Pane) => {
|
|
245
|
+
const push = (kind: Line["kind"], text: string, pane?: Pane, quiet?: boolean) => {
|
|
243
246
|
const parts = kind === "user" || kind === "reply" ? [text] : text.split("\n")
|
|
244
247
|
const where = pane || (answeringRef.current ? "chat" : paneFor(kind, abortRef.current !== null))
|
|
245
248
|
setLines((prev) => {
|
|
246
249
|
const next = [...prev]
|
|
247
|
-
for (const part of parts) next.push({ id: idRef.current++, kind, text: part, pane: where })
|
|
248
|
-
return next
|
|
250
|
+
for (const part of parts) next.push({ id: idRef.current++, kind, text: part, pane: where, ...(quiet ? { quiet } : {}) })
|
|
251
|
+
return trimLines(next)
|
|
249
252
|
})
|
|
250
253
|
}
|
|
251
|
-
|
|
252
|
-
|
|
254
|
+
// The pane is decided when the event arrives: the updater runs later, when the request may already have finished.
|
|
255
|
+
const pushLine = (line: Omit<Line, "id">) => {
|
|
256
|
+
const next = { ...line, pane: line.pane || paneFor(line.kind, abortRef.current !== null), id: idRef.current++ }
|
|
257
|
+
setLines((prev) => trimLines([...prev, next]))
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* What the agents do always reaches the Activity panel; `shown` says whether the conversation shows it too at this
|
|
261
|
+
* detail level (/details). Hidden lines were dropped before, so the panel missed most of the agents' work.
|
|
262
|
+
*/
|
|
263
|
+
const record = (kind: Line["kind"], text: string, shown: boolean) => push(kind, text, shown ? undefined : "activity", !shown)
|
|
264
|
+
/** A tool call started: one running line in Activity, which its result replaces. */
|
|
265
|
+
const startCall = (id: string, text: string) => pushLine({ kind: "running", text, pane: "activity", quiet: true, call: id })
|
|
266
|
+
/** A tool call finished: its running line becomes the result, shown in the conversation too when `shown`. */
|
|
267
|
+
const endCall = (id: string | undefined, kind: Line["kind"], text: string, shown: boolean) => {
|
|
268
|
+
const line = { kind, text, pane: shown ? paneFor(kind, abortRef.current !== null) : ("activity" as const), quiet: !shown }
|
|
269
|
+
const n = idRef.current++
|
|
270
|
+
setLines((prev) => trimLines(settleCall(prev, id, line, n)))
|
|
271
|
+
}
|
|
253
272
|
/** What an agent's event tells the right pane: a file it used, a skill it loaded, its plan, how full its context is. */
|
|
254
273
|
const inspect = (e: CodeEvent) => {
|
|
255
274
|
const s = inspRef.current
|
|
@@ -261,8 +280,14 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
261
280
|
else return
|
|
262
281
|
bump()
|
|
263
282
|
}
|
|
283
|
+
/** What the agent is on between calls: the plan's task in progress, rather than a bare "working". */
|
|
284
|
+
const nowWorking = () => {
|
|
285
|
+
const task = inspRef.current.plan.find((p) => p.status === "in_progress")
|
|
286
|
+
return task ? `Working on: ${clip(task.text, 70)}` : "The agent is deciding what to do next…"
|
|
287
|
+
}
|
|
264
288
|
/** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
|
|
265
|
-
const
|
|
289
|
+
const toolKind = (text: string, ok: boolean): Line["kind"] => (!ok ? "err" : /^(Writing|Editing) /.test(text) ? "file" : "tool")
|
|
290
|
+
const pushTool = (text: string, ok: boolean, id?: string, shown = true) => endCall(id, toolKind(text, ok), text, shown)
|
|
266
291
|
/** A short note in the hint row that clears itself: "opened report.html". */
|
|
267
292
|
const flashTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
268
293
|
const flash = (text: string) => {
|
|
@@ -419,7 +444,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
419
444
|
reloadAfterAuth()
|
|
420
445
|
return
|
|
421
446
|
}
|
|
422
|
-
const controller = begin("Signing in…")
|
|
447
|
+
const controller = begin("Signing in…", system ? `Sign in to ${system}` : "Sign in")
|
|
423
448
|
controller.signal.addEventListener("abort", () => settleEntry(null), { once: true })
|
|
424
449
|
let finalStatus = "fail"
|
|
425
450
|
try {
|
|
@@ -646,6 +671,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
646
671
|
}
|
|
647
672
|
} else if (r.phase === "error") {
|
|
648
673
|
if (key.name === "escape" || key.name === "return" || key.name === "enter") setRefine(null)
|
|
674
|
+
} else if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") {
|
|
675
|
+
const page = Math.max(3, Math.floor(height / 3))
|
|
676
|
+
refineScroll.current?.scrollBy(key.name === "up" ? -1 : key.name === "down" ? 1 : key.name === "pageup" ? -page : page)
|
|
649
677
|
} else if (key.name === "left" || (key.name === "tab" && key.shift)) setRefine({ ...r, choice: (r.choice + REFINE_CHOICES.length - 1) % REFINE_CHOICES.length })
|
|
650
678
|
else if (key.name === "right" || key.name === "tab") setRefine({ ...r, choice: (r.choice + 1) % REFINE_CHOICES.length })
|
|
651
679
|
else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") chooseRefine(REFINE_CHOICES[r.choice]!.id)
|
|
@@ -684,7 +712,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
684
712
|
const panes = (width >= SPLIT_MIN && !chatWide) || (width < SPLIT_MIN && narrowView === "details")
|
|
685
713
|
const insp = inspRef.current
|
|
686
714
|
const panelsOpen = width >= SPLIT_MIN && !chatWide
|
|
687
|
-
|
|
715
|
+
// Files takes the keys only where it is a panel: a narrow column shows it as a line of the info strip.
|
|
716
|
+
const filesPanel = (panelsOpen ? Math.floor(width / 2) - 3 : width - 4) >= 80
|
|
717
|
+
const shown = (f: Focus) => f === "activity" || (f === "files" && filesPanel) || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && panelsOpen)
|
|
688
718
|
if (panes && key.name === "tab" && key.shift && !entryRef.current) {
|
|
689
719
|
// Consumed here: the prompt can take focus within this key press and must not also act on it.
|
|
690
720
|
key.preventDefault()
|
|
@@ -729,6 +759,13 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
729
759
|
if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && panelsOpen) return setNav((n) => ({ ...n, focus: "chat" }))
|
|
730
760
|
if (!key.ctrl && !key.meta) return
|
|
731
761
|
}
|
|
762
|
+
// pgup and pgdn scroll the conversation from the prompt, as the wheel does; pgdn at the end follows new lines again.
|
|
763
|
+
if ((key.name === "pageup" || key.name === "pagedown") && !shortcutsRef.current && !entryRef.current?.secret) {
|
|
764
|
+
key.preventDefault()
|
|
765
|
+
const page = Math.max(3, height - 10)
|
|
766
|
+
scrollRef.current?.scrollBy(key.name === "pageup" ? -page : page)
|
|
767
|
+
return
|
|
768
|
+
}
|
|
732
769
|
// ? on an empty prompt: every key in one panel; esc or ? closes it.
|
|
733
770
|
if (shortcutsRef.current) {
|
|
734
771
|
if (key.name === "escape" || key.sequence === "?") setShortcuts(false)
|
|
@@ -809,11 +846,12 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
809
846
|
else if (!aborted()) setActivity(`Replaying step ${event.step} from memory…`)
|
|
810
847
|
}
|
|
811
848
|
if (event.type === "memory") {
|
|
812
|
-
|
|
849
|
+
record("memory", event.text, shows(event, detailRef.current))
|
|
813
850
|
markRef.current = Date.now()
|
|
814
851
|
}
|
|
815
852
|
if (event.type === "usage") useTokens(`run-${runIndex.current}`, event.usage.input + event.usage.output)
|
|
816
853
|
if (event.type === "log") {
|
|
854
|
+
// Engine info logs stay in the session log: they would bury the steps in the panel.
|
|
817
855
|
if (shows(event, detailRef.current)) push(event.level === "error" ? "err" : "sys", event.text)
|
|
818
856
|
if (!aborted()) setActivity(activityFromLog(event.text))
|
|
819
857
|
}
|
|
@@ -909,6 +947,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
909
947
|
if (ev.type === "usage") useTokens(`case-${s.index}`, ev.usage.input + ev.usage.output)
|
|
910
948
|
else if (!shows(ev, detailRef.current)) {
|
|
911
949
|
if (ev.type === "log" && !aborted()) setActivity(`${p}${activityFromLog(ev.text)}`)
|
|
950
|
+
else if (ev.type === "memory") record("memory", p + ev.text, false)
|
|
912
951
|
} else if (ev.type === "log") push(ev.level === "error" ? "err" : "sys", p + ev.text)
|
|
913
952
|
else if (ev.type === "memory") push("memory", p + ev.text)
|
|
914
953
|
else if (ev.type === "thought") push("thought", p + stepIntent(ev.step, ev.text, detailRef.current))
|
|
@@ -928,25 +967,32 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
928
967
|
case "info":
|
|
929
968
|
push(e.ok === undefined ? "code" : e.ok ? "ok" : "err", e.text)
|
|
930
969
|
return
|
|
970
|
+
case "call-start":
|
|
971
|
+
startCall(e.id, e.text)
|
|
972
|
+
if (!aborted()) setActivity(`Agent: ${clip(e.text, 60)}…`)
|
|
973
|
+
return
|
|
931
974
|
case "call":
|
|
932
|
-
pushTool(e.text, e.ok)
|
|
933
|
-
if (!aborted()) setActivity(
|
|
975
|
+
pushTool(e.text, e.ok, e.id, !e.quiet)
|
|
976
|
+
if (!aborted()) setActivity(nowWorking())
|
|
934
977
|
return
|
|
935
978
|
case "agent": {
|
|
936
979
|
const ev = e.event
|
|
937
980
|
inspect(ev)
|
|
938
981
|
if (ev.kind === "plan") return
|
|
939
982
|
if (ev.kind === "usage") useTokens(`agent-tool-${runIndex.current}`, ev.usage.input + ev.usage.output)
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
else if (ev.kind === "
|
|
945
|
-
else if (ev.kind === "
|
|
983
|
+
else {
|
|
984
|
+
// Every line reaches Activity; the conversation keeps the ones this detail level shows.
|
|
985
|
+
const shown = shows(ev, detailRef.current)
|
|
986
|
+
if (ev.kind === "start") startCall(ev.id, ev.text)
|
|
987
|
+
else if (ev.kind === "info") record("code", ev.text, shown)
|
|
988
|
+
else if (ev.kind === "say") record("thought", ev.text, shown)
|
|
989
|
+
else if (ev.kind === "tool") pushTool(ev.text, ev.ok, ev.id, shown)
|
|
990
|
+
else if (ev.kind === "test") record(ev.ok ? "ok" : "err", ev.text, shown)
|
|
946
991
|
}
|
|
947
992
|
if (aborted()) return
|
|
948
993
|
const who = codingRef.current ? "Coding agent" : "Agent"
|
|
949
|
-
if (ev.kind === "
|
|
994
|
+
if (ev.kind === "start") setActivity(`${who}: ${clip(ev.text, 60)}…`)
|
|
995
|
+
else if (ev.kind === "tool") setActivity(`${who}: ${clip(ev.text, 60)}`)
|
|
950
996
|
else if (ev.kind === "info" && ev.text.startsWith("ForgeQE runs the test")) setActivity("ForgeQE is running the new test…")
|
|
951
997
|
else if (ev.kind === "say") setActivity(`${who} is working…`)
|
|
952
998
|
return
|
|
@@ -991,6 +1037,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
991
1037
|
return
|
|
992
1038
|
case "report":
|
|
993
1039
|
producedRef.current.reports.push({ title: e.title, html: e.html })
|
|
1040
|
+
// What the report wrote is a file the request made: it is in Files with the rest.
|
|
1041
|
+
for (const f of [e.markdown, e.html]) useFile(inspRef.current, { path: path.relative(process.cwd(), f) || f, action: "write" })
|
|
1042
|
+
bump()
|
|
994
1043
|
return
|
|
995
1044
|
}
|
|
996
1045
|
}
|
|
@@ -1003,17 +1052,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1003
1052
|
return
|
|
1004
1053
|
}
|
|
1005
1054
|
// ForgeQE's own agent: its tool calls (Azure DevOps and other MCP servers, commands) and what it says between them,
|
|
1006
|
-
// one line each in brief;
|
|
1007
|
-
|
|
1055
|
+
// one line each in brief in the conversation; its file reads and searches only in Activity, or with /details.
|
|
1056
|
+
const shown = shows(e, detailRef.current)
|
|
1057
|
+
if (e.kind === "start") startCall(e.id, e.text)
|
|
1058
|
+
else if (e.kind === "tool") pushTool(e.text, e.ok, e.id, showsAgentTool(e.text, e.ok, detailRef.current))
|
|
1008
1059
|
else if (e.kind === "say") push("thought", briefText(e.text, detailRef.current, 200))
|
|
1009
|
-
else if (
|
|
1010
|
-
|
|
1011
|
-
if (e.kind === "tool") pushTool(e.text, e.ok)
|
|
1012
|
-
if (e.kind === "test") push(e.ok ? "ok" : "err", e.text)
|
|
1013
|
-
}
|
|
1060
|
+
else if (e.kind === "info") record("code", e.text, shown)
|
|
1061
|
+
else if (e.kind === "test") record(e.ok ? "ok" : "err", e.text, shown)
|
|
1014
1062
|
if (abortRef.current?.signal.aborted) return
|
|
1015
|
-
if (e.kind === "
|
|
1016
|
-
else if (e.kind === "
|
|
1063
|
+
if (e.kind === "start") setActivity(`Agent: ${clip(e.text, 60)}…`)
|
|
1064
|
+
else if (e.kind === "tool" || e.kind === "say") setActivity(nowWorking())
|
|
1017
1065
|
}
|
|
1018
1066
|
|
|
1019
1067
|
const toolContext = (agent: boolean, signal?: AbortSignal) => ({
|
|
@@ -1033,10 +1081,12 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1033
1081
|
ask,
|
|
1034
1082
|
})
|
|
1035
1083
|
|
|
1036
|
-
|
|
1084
|
+
/** `request`: what was asked, which heads this request's lines in Activity so they are not read as the last one's. */
|
|
1085
|
+
const begin = (activityText: string, request: string) => {
|
|
1037
1086
|
const controller = new AbortController()
|
|
1038
1087
|
abortRef.current = controller
|
|
1039
1088
|
started.current = Date.now()
|
|
1089
|
+
pushLine({ kind: "request", text: request.replace(/\s+/g, " ").trim(), pane: "activity", quiet: true, at: started.current })
|
|
1040
1090
|
usedRef.current = new Map()
|
|
1041
1091
|
producedRef.current = emptyProduced()
|
|
1042
1092
|
newRequest(inspRef.current)
|
|
@@ -1050,6 +1100,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1050
1100
|
}
|
|
1051
1101
|
|
|
1052
1102
|
const finish = (finalStatus: string, followUps: FollowUp[] = []) => {
|
|
1103
|
+
// A call the request ended inside of (stopped, or its result never came) is not left spinning in Activity; then the
|
|
1104
|
+
// request's closing line says how it ended, how long it took, and how many activities it had.
|
|
1105
|
+
const took = started.current != null ? formatDuration(Date.now() - started.current) : ""
|
|
1106
|
+
const ended = (["done", "fail", "stopped", "idle"] as const).find((x) => x === finalStatus) || "fail"
|
|
1107
|
+
const endId = idRef.current++
|
|
1108
|
+
setLines((prev) => {
|
|
1109
|
+
const settled = prev.some((l) => l.kind === "running") ? prev.map((l) => (l.kind === "running" ? { ...l, kind: "tool" as const, text: `${l.text} · ${finalStatus === "stopped" ? "stopped" : "no result"}` } : l)) : prev
|
|
1110
|
+
const n = sinceRequest(settled)
|
|
1111
|
+
return trimLines([...settled, { id: endId, kind: "requestEnd", text: `${took ? `in ${took} · ` : ""}${n} ${n === 1 ? "activity" : "activities"}`, pane: "activity", quiet: true, ended }])
|
|
1112
|
+
})
|
|
1053
1113
|
setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on, followUps }))
|
|
1054
1114
|
progressDone(inspRef.current, finalStatus === "stopped")
|
|
1055
1115
|
bump()
|
|
@@ -1073,7 +1133,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1073
1133
|
directNoted.current = true
|
|
1074
1134
|
push("sys", `ForgeQE's agent is off (${why}), so the text runs as a browser goal.${why.includes("agent runtime") ? ` ${INSTALL_HINT}` : " Press Tab for a live profile."} /features lists what ForgeQE can do.`)
|
|
1075
1135
|
}
|
|
1076
|
-
const controller = begin(cfg.llm.provider === "mock" ? "Running the offline demo…" : "Starting…")
|
|
1136
|
+
const controller = begin(cfg.llm.provider === "mock" ? "Running the offline demo…" : "Starting…", goal)
|
|
1077
1137
|
testsRef.current = { on: false, why: why.includes("agent runtime") ? "the agent runtime Scribe needs is not installed" : "ForgeQE's agent is off in the offline profile" }
|
|
1078
1138
|
let finalStatus = "fail"
|
|
1079
1139
|
try {
|
|
@@ -1088,7 +1148,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1088
1148
|
}
|
|
1089
1149
|
|
|
1090
1150
|
const askAgent = async (text: string, cfg: AppConfig) => {
|
|
1091
|
-
const controller = begin("Thinking…")
|
|
1151
|
+
const controller = begin("Thinking…", text)
|
|
1092
1152
|
const meterStart = meterSnapshot()
|
|
1093
1153
|
setThinking(true)
|
|
1094
1154
|
const setup = sessionSetup(cfg, options.repo)
|
|
@@ -1113,7 +1173,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1113
1173
|
const brain = brainRef.current
|
|
1114
1174
|
const onAbort = () => void brain.abort()
|
|
1115
1175
|
controller.signal.addEventListener("abort", onAbort, { once: true })
|
|
1116
|
-
if (!controller.signal.aborted) setActivity("
|
|
1176
|
+
if (!controller.signal.aborted) setActivity("Reading your request and deciding the first step…")
|
|
1117
1177
|
const answer = await brain.ask(fresh ? carryOver(convoRef.current, text) : text)
|
|
1118
1178
|
controller.signal.removeEventListener("abort", onAbort)
|
|
1119
1179
|
if (answer.outcome !== "stopped") convoRef.current.push({ user: text, reply: answer.reply })
|
|
@@ -1347,29 +1407,21 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1347
1407
|
const running = status === "running"
|
|
1348
1408
|
const elapsed = started.current != null ? formatDuration(Date.now() - started.current) : ""
|
|
1349
1409
|
const modeColor = running ? theme.warn : status === "fail" ? theme.err : theme.accent
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
? "coding agent"
|
|
1359
|
-
: "agent"
|
|
1360
|
-
: status === "done" || status === "fail" || status === "stopped"
|
|
1361
|
-
? status
|
|
1362
|
-
: options.url
|
|
1363
|
-
? hostOf(options.url)
|
|
1364
|
-
: headed
|
|
1365
|
-
? "headed"
|
|
1366
|
-
: ""
|
|
1367
|
-
const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
|
|
1410
|
+
// What a running request has got to, for the line under the conversation: a suite's case or the browser's step.
|
|
1411
|
+
const progressText = suiteProgress
|
|
1412
|
+
? `case ${Math.min(suiteProgress.done + 1, suiteProgress.total)}/${suiteProgress.total}`
|
|
1413
|
+
: browsing
|
|
1414
|
+
? step > maxSteps
|
|
1415
|
+
? `step ${step}, past ${maxSteps}`
|
|
1416
|
+
: `step ${step}/${maxSteps}`
|
|
1417
|
+
: ""
|
|
1368
1418
|
const footerVersion = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
|
|
1369
|
-
//
|
|
1419
|
+
// The footer is the status line, the one place for the model and its profile (tab switches it), what the session
|
|
1420
|
+
// used, and the version; the conversation's footer adds the folder on its left. Nothing above repeats them.
|
|
1370
1421
|
const sessionTokens = meterSince(sessionMeter.current).total
|
|
1371
1422
|
const usedTokens = sessionTokens.input + sessionTokens.output
|
|
1372
|
-
const
|
|
1423
|
+
const modelText = configRef.current ? `${modelLabel(configRef.current)}${profile ? ` (${profile})` : ""}` : model
|
|
1424
|
+
const footerRight = [modelText, usedTokens ? `${formatTokens(usedTokens)} tokens` : "", footerVersion].filter(Boolean).join(" · ")
|
|
1373
1425
|
// Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
|
|
1374
1426
|
const suggTrigger = triggerAt(promptText)
|
|
1375
1427
|
const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
|
|
@@ -1435,12 +1487,15 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1435
1487
|
</box>
|
|
1436
1488
|
)
|
|
1437
1489
|
|
|
1438
|
-
|
|
1490
|
+
/** The prompt and its row of buttons, or a panel in their place; `maxHeight` is the rows a panel may take. */
|
|
1491
|
+
const renderControls = (promptWidth: number, placeholder: string, rows: [number, number], maxHeight: number) => {
|
|
1439
1492
|
if (refining && !dialog) {
|
|
1440
1493
|
return (
|
|
1441
1494
|
<RefinePanel
|
|
1442
1495
|
state={refining}
|
|
1443
1496
|
width={promptWidth}
|
|
1497
|
+
maxHeight={maxHeight}
|
|
1498
|
+
scrollRef={refineScroll}
|
|
1444
1499
|
onChoose={chooseRefine}
|
|
1445
1500
|
onHover={(i) => refining.phase === "done" && setRefine({ ...refining, choice: i })}
|
|
1446
1501
|
/>
|
|
@@ -1460,6 +1515,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1460
1515
|
selected={Math.min(choice, spec.options.length - 1)}
|
|
1461
1516
|
onChoose={choose}
|
|
1462
1517
|
onHover={setChoice}
|
|
1518
|
+
maxHeight={maxHeight}
|
|
1463
1519
|
/>
|
|
1464
1520
|
)
|
|
1465
1521
|
}
|
|
@@ -1513,11 +1569,6 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1513
1569
|
placeholder={entry ? `${entry.label}${entry.secret ? " (hidden as you type)" : ""}${entry.initial ? ` · Enter keeps ${entry.initial}` : ""} · esc cancels` : placeholder}
|
|
1514
1570
|
masked={entry?.secret ? secretLength : undefined}
|
|
1515
1571
|
modeColor={modeColor}
|
|
1516
|
-
profile={profile}
|
|
1517
|
-
model={model}
|
|
1518
|
-
right={right}
|
|
1519
|
-
rightColor={rightColor}
|
|
1520
|
-
compact={!home}
|
|
1521
1572
|
focused={!palette && !refining && (panelsOpen ? nav.focus === "chat" : wide || narrowView === "chat")}
|
|
1522
1573
|
initialValue={draft}
|
|
1523
1574
|
onText={(t) => {
|
|
@@ -1555,7 +1606,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1555
1606
|
start: startActions(cfgHome, 4, undefined, recent.some((r) => r.kind === "run")),
|
|
1556
1607
|
recent,
|
|
1557
1608
|
first: getStarted(cfgHome, items),
|
|
1558
|
-
facts: projectLine(cfgHome, process.cwd(), {
|
|
1609
|
+
facts: projectLine(cfgHome, process.cwd(), { signIns }),
|
|
1559
1610
|
}
|
|
1560
1611
|
}
|
|
1561
1612
|
const { items: setup, start, recent, first, facts } = setupRef.current
|
|
@@ -1575,10 +1626,12 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1575
1626
|
const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
|
|
1576
1627
|
const recentRows = recent.length ? 1 + 1 + recent.length : 0
|
|
1577
1628
|
const setupRows = loading || open.length ? 2 : 0
|
|
1578
|
-
const fixed = 5 + 2 + 1 +
|
|
1629
|
+
const fixed = 5 + 2 + 1 + 3 + 1 + 1 + 1 // logo, project line, gap, prompt border, buttons, footer, one row to spare
|
|
1579
1630
|
const room = height - fixed
|
|
1580
1631
|
const want = Math.max(5, Math.min(promptLines, room - 1))
|
|
1581
|
-
|
|
1632
|
+
// Refine or a question in the prompt's place takes the room the sections would: they are for starting, not deciding.
|
|
1633
|
+
const deciding = Boolean(refining || dialog)
|
|
1634
|
+
let left = deciding ? 0 : room - want
|
|
1582
1635
|
const showStart = startRows > 0 && left >= startRows
|
|
1583
1636
|
if (showStart) left -= startRows
|
|
1584
1637
|
const showRecent = recentRows > 0 && left >= recentRows
|
|
@@ -1608,7 +1661,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1608
1661
|
</box>
|
|
1609
1662
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1610
1663
|
<box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0 }}>
|
|
1611
|
-
{renderControls(promptWidth, "Describe what you want to test…", [5, maxRows])}
|
|
1664
|
+
{renderControls(promptWidth, "Describe what you want to test…", [5, maxRows], height - 5 - 2 - 1 - 1 - 1)}
|
|
1612
1665
|
{showStart ? (
|
|
1613
1666
|
<box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
|
|
1614
1667
|
{heading(offerTitle)}
|
|
@@ -1695,14 +1748,6 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1695
1748
|
} catch {}
|
|
1696
1749
|
contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
|
|
1697
1750
|
}
|
|
1698
|
-
const llm = cfgNow?.llm
|
|
1699
|
-
const sessionInfo: SessionInfo = {
|
|
1700
|
-
model: cfgNow ? `${llm?.provider === "mock" ? "offline demo" : llm?.deployment || llm?.model || "none"}${profile ? ` (${profile})` : ""}` : "…",
|
|
1701
|
-
cwd: shortCwd(),
|
|
1702
|
-
...(cfgNow?.domain ? { domain: `${cfgNow.domain.industry}${cfgNow.domain.regulations.length ? ` (${cfgNow.domain.regulations.join(", ")})` : ""}` } : {}),
|
|
1703
|
-
started: sessionStarted.current,
|
|
1704
|
-
requests: requests.current,
|
|
1705
|
-
}
|
|
1706
1751
|
const contextPanel: ContextInfo = {
|
|
1707
1752
|
tokens: insp.context,
|
|
1708
1753
|
limit: CONTEXT_LIMIT,
|
|
@@ -1721,22 +1766,38 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1721
1766
|
const activityLines = lines.filter((l) => l.pane === "activity")
|
|
1722
1767
|
const stop = () => openDialog("stop")
|
|
1723
1768
|
|
|
1769
|
+
// Files, context, and tokens as three panels where there is room for them, else as one line.
|
|
1770
|
+
const INFO_PANELS_MIN = 80
|
|
1771
|
+
const infoRows = (paneWidth: number) => (paneWidth >= INFO_PANELS_MIN ? 8 : 1)
|
|
1772
|
+
const infoPanels = (paneWidth: number) => {
|
|
1773
|
+
if (paneWidth < INFO_PANELS_MIN) return <InfoStrip s={insp} c={contextPanel} total={spent.total} width={paneWidth} />
|
|
1774
|
+
const bottom = 8 // the context panel's six rows, and the border
|
|
1775
|
+
const third = Math.floor(paneWidth / 3)
|
|
1776
|
+
const sel = nav.focus === "files" ? Math.min(nav.files, Math.max(0, filesByUse(insp).length - 1)) : undefined
|
|
1777
|
+
return (
|
|
1778
|
+
<box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
|
|
1779
|
+
<FilesPanel s={insp} width={third} height={bottom} selected={sel} onFocus={() => setNav((n) => ({ ...n, focus: "files" }))} onOpen={openFile} />
|
|
1780
|
+
<ContextPanel c={contextPanel} width={third} height={bottom} />
|
|
1781
|
+
<TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
|
|
1782
|
+
</box>
|
|
1783
|
+
)
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1724
1786
|
/**
|
|
1725
|
-
* The
|
|
1726
|
-
* Rows are counted, not left to flex: the activity gets what the
|
|
1787
|
+
* The Activities column: who is working and on what, the agent's plan with each task's state, and every call, step,
|
|
1788
|
+
* and note as it happens. Rows are counted, not left to flex: the activity gets what the plan leaves.
|
|
1727
1789
|
*/
|
|
1728
|
-
const
|
|
1729
|
-
const
|
|
1730
|
-
const plan = planRows(insp, Math.max(3, Math.min(
|
|
1731
|
-
const activityRows = Math.max(5, rowsAvailable -
|
|
1732
|
-
const third = Math.floor(paneWidth / 3)
|
|
1790
|
+
const activityColumn = (paneWidth: number, rowsAvailable: number) => {
|
|
1791
|
+
const where = nowNext(insp)
|
|
1792
|
+
const plan = planRows(insp, Math.max(3, Math.min(10, Math.floor(rowsAvailable / 4))))
|
|
1793
|
+
const activityRows = Math.max(5, rowsAvailable - STATUS_ROWS - 1 - plan)
|
|
1733
1794
|
const focusOn = (f: Focus) => () => setNav((n) => ({ ...n, focus: f }))
|
|
1734
|
-
const sel =
|
|
1795
|
+
const sel = nav.focus === "plan" ? Math.min(nav.plan, Math.max(0, planLines(insp).length - 1)) : undefined
|
|
1735
1796
|
return (
|
|
1736
1797
|
<box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
|
|
1737
1798
|
<StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress || planProgress(insp)} actions={paneButtons} />
|
|
1738
1799
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1739
|
-
{plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel
|
|
1800
|
+
{plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel} onFocus={focusOn("plan")} animate={running} /> : null}
|
|
1740
1801
|
<ActivityPanel
|
|
1741
1802
|
lines={activityLines}
|
|
1742
1803
|
scrollRef={activityScroll}
|
|
@@ -1746,17 +1807,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1746
1807
|
focused={nav.focus === "activity"}
|
|
1747
1808
|
onFocus={focusOn("activity")}
|
|
1748
1809
|
onFilter={(filter) => setNav((n) => ({ ...n, filter, focus: "activity" }))}
|
|
1810
|
+
now={running ? where : undefined}
|
|
1749
1811
|
/>
|
|
1750
|
-
<box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
|
|
1751
|
-
<FilesPanel s={insp} width={third} height={bottom} selected={sel("files")} onFocus={focusOn("files")} onOpen={openFile} />
|
|
1752
|
-
<ContextPanel c={contextPanel} width={third} height={bottom} />
|
|
1753
|
-
<TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
|
|
1754
|
-
</box>
|
|
1755
1812
|
</box>
|
|
1756
1813
|
)
|
|
1757
1814
|
}
|
|
1758
1815
|
|
|
1759
|
-
const transcript = (
|
|
1816
|
+
const transcript = (every: Line[], style: Record<string, unknown>) => {
|
|
1817
|
+
// Quiet lines (reads, searches, calls in progress) are the Activity panel's; the conversation stays readable.
|
|
1818
|
+
const all = every.filter((l) => !l.quiet)
|
|
1819
|
+
return (
|
|
1760
1820
|
<scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
|
|
1761
1821
|
{all.map((line, i) => (
|
|
1762
1822
|
<TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
|
|
@@ -1767,28 +1827,31 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1767
1827
|
<Spinner kind="dots" color={theme.memory} />
|
|
1768
1828
|
<text>
|
|
1769
1829
|
<span fg={theme.memory}>{` ${activity || "Working…"}`}</span>
|
|
1770
|
-
<span fg={theme.faint}>{` (${elapsed
|
|
1830
|
+
<span fg={theme.faint}>{` (${[elapsed, progressText, tokens ? `${formatTokens(tokens)} tokens` : "", "esc to interrupt"].filter(Boolean).join(" · ")})`}</span>
|
|
1771
1831
|
</text>
|
|
1772
1832
|
</box>
|
|
1773
1833
|
) : null}
|
|
1774
1834
|
</scrollbox>
|
|
1775
|
-
|
|
1835
|
+
)
|
|
1836
|
+
}
|
|
1776
1837
|
// A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
|
|
1777
|
-
|
|
1838
|
+
// A panel in the prompt's place (Refine, a question) may take most of the screen, leaving the conversation a few rows.
|
|
1839
|
+
const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.floor(height * 0.45))], Math.max(8, height - 8))
|
|
1778
1840
|
|
|
1779
1841
|
const split = panelsOpen
|
|
1780
1842
|
if (split) {
|
|
1781
|
-
|
|
1843
|
+
// Half the width for the Activities column; the conversation, with files, context, and tokens above it, the other half.
|
|
1844
|
+
const leftWidth = Math.floor(width / 2)
|
|
1782
1845
|
return (
|
|
1783
1846
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1784
1847
|
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1785
1848
|
<box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1786
|
-
|
|
1849
|
+
{infoPanels(leftWidth - 3)}
|
|
1787
1850
|
{transcript(chatLines, {})}
|
|
1788
1851
|
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
|
|
1789
1852
|
</box>
|
|
1790
1853
|
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1791
|
-
{
|
|
1854
|
+
{activityColumn(width - leftWidth - 3, height - 2)}
|
|
1792
1855
|
</box>
|
|
1793
1856
|
</box>
|
|
1794
1857
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
@@ -1801,7 +1864,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1801
1864
|
if (!wide && narrowView === "details") {
|
|
1802
1865
|
return (
|
|
1803
1866
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1804
|
-
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>
|
|
1867
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>
|
|
1868
|
+
{activityColumn(promptWidth, height - 3 - (dialog ? 8 : 0) - infoRows(promptWidth))}
|
|
1869
|
+
{infoPanels(promptWidth)}
|
|
1870
|
+
</box>
|
|
1805
1871
|
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
|
|
1806
1872
|
{dialog ? (
|
|
1807
1873
|
// Stop, a question, or "your turn": the same panel the chat view shows in place of the prompt.
|