@capagents/forgeqe 1.0.1 → 1.0.2
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/package.json +1 -1
- package/src/code/runtime.ts +2 -1
- package/src/tui/App.tsx +244 -41
- package/src/tui/components.tsx +12 -8
- package/src/tui/inspector.tsx +262 -0
- package/src/tui/session.ts +115 -0
- package/src/tui/text.ts +5 -0
- package/src/tui/transcript.tsx +2 -0
- package/src/types.ts +1 -1
package/package.json
CHANGED
package/src/code/runtime.ts
CHANGED
|
@@ -674,7 +674,8 @@ export async function openSession<R = AgentReport>(opts: {
|
|
|
674
674
|
const line = toolLine(opts.repo, part.tool, input, part.state.title)
|
|
675
675
|
const ok = part.state.status === "completed"
|
|
676
676
|
const file = toolFile(opts.repo, part.tool, input)
|
|
677
|
-
|
|
677
|
+
const skill = part.tool === "skill" && ok ? String(input.name || "") : ""
|
|
678
|
+
emit({ type: "code", kind: "tool", ok, tool: part.tool, ...(file ? { file } : {}), ...(skill ? { skill } : {}), text: ok ? line : `${line}: ${clip(toolError(String(part.state.error || "")) || "failed", 120)}` })
|
|
678
679
|
} else if (part?.type === "text" && part.time?.end && assistant.has(part.messageID) && !said.has(part.id)) {
|
|
679
680
|
said.add(part.id)
|
|
680
681
|
const text = String(part.text || "").trim()
|
package/src/tui/App.tsx
CHANGED
|
@@ -28,6 +28,29 @@ import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLi
|
|
|
28
28
|
import { Dialog, dialogSpec } from "./dialogs.ts"
|
|
29
29
|
import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
|
|
30
30
|
import { featuresText } from "../capabilities/render.ts"
|
|
31
|
+
import { CAPABILITIES } from "../capabilities/registry.ts"
|
|
32
|
+
import { CONTEXT_LIMIT } from "../code/runtime.ts"
|
|
33
|
+
import { readKnowledge } from "../knowledge.ts"
|
|
34
|
+
import { externalMcp } from "../mcp/external.ts"
|
|
35
|
+
import { meterSince } from "../usage.ts"
|
|
36
|
+
import { ActivityLog, ContextPanel, FilesPanel, PlanPanel, SessionBlock, TokensPanel, WorkHeader, type ContextInfo, type SessionInfo } from "./inspector.tsx"
|
|
37
|
+
import {
|
|
38
|
+
addSkill,
|
|
39
|
+
caseDone,
|
|
40
|
+
caseStarted,
|
|
41
|
+
newInspector,
|
|
42
|
+
newRequest,
|
|
43
|
+
paneFor,
|
|
44
|
+
progressDone,
|
|
45
|
+
setContext,
|
|
46
|
+
setPlan,
|
|
47
|
+
setWho,
|
|
48
|
+
startProgress,
|
|
49
|
+
stepOutcome,
|
|
50
|
+
stepStarted,
|
|
51
|
+
useFile,
|
|
52
|
+
type Pane,
|
|
53
|
+
} from "./session.ts"
|
|
31
54
|
|
|
32
55
|
export type TuiOptions = {
|
|
33
56
|
configPath?: string
|
|
@@ -54,11 +77,14 @@ export type TuiOptions = {
|
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
const VERSION = packageVersion()
|
|
80
|
+
/** Narrower than this, the split view's right pane would not fit: one pane, and ctrl+o switches. */
|
|
81
|
+
export const SPLIT_MIN = 120
|
|
57
82
|
|
|
58
83
|
export function App({ options }: { options: TuiOptions }) {
|
|
59
84
|
const renderer = useRenderer()
|
|
60
85
|
const { width, height } = useTerminalDimensions()
|
|
61
86
|
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
87
|
+
const activityScroll = useRef<ScrollBoxRenderable>(null)
|
|
62
88
|
const submitRef = useRef<(value: string) => void>(() => {})
|
|
63
89
|
const configRef = useRef<AppConfig | null>(null)
|
|
64
90
|
const abortRef = useRef<AbortController | null>(null)
|
|
@@ -79,6 +105,19 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
79
105
|
const testsRef = useRef<TestsSetting>({ on: false, why: "" })
|
|
80
106
|
const codingRef = useRef(false)
|
|
81
107
|
const codeRunDir = useRef("")
|
|
108
|
+
/** The right pane's live state: plan, steps or cases, files, context. */
|
|
109
|
+
const inspRef = useRef(newInspector())
|
|
110
|
+
/** Set while a typed line is handled, so the shell's answers to it go to the chat. */
|
|
111
|
+
const answeringRef = useRef(false)
|
|
112
|
+
const [, setRev] = useState(0)
|
|
113
|
+
const bump = () => setRev((n) => n + 1)
|
|
114
|
+
/** Usage since the shell opened, for the session's token and cost totals. */
|
|
115
|
+
const sessionMeter = useRef(meterSnapshot())
|
|
116
|
+
const sessionStarted = useRef<number | undefined>(undefined)
|
|
117
|
+
const requests = useRef(0)
|
|
118
|
+
/** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
|
|
119
|
+
const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
|
|
120
|
+
const contextInfo = useRef<{ key: string; knowledge: number; mcp: string[] }>({ key: "", knowledge: 0, mcp: [] })
|
|
82
121
|
|
|
83
122
|
const [lines, setLines] = useState<Line[]>([])
|
|
84
123
|
const [headed, setHeaded] = useState(Boolean(options.headed))
|
|
@@ -123,15 +162,29 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
123
162
|
choiceRef.current = choice
|
|
124
163
|
thinkingRef.current = thinking
|
|
125
164
|
|
|
126
|
-
|
|
165
|
+
/** Lines go to the chat, or while a request runs and they are not results, to the activity pane; `pane` overrides that. */
|
|
166
|
+
const push = (kind: Line["kind"], text: string, pane?: Pane) => {
|
|
127
167
|
const parts = kind === "user" || kind === "reply" ? [text] : text.split("\n")
|
|
168
|
+
const where = pane || (answeringRef.current ? "chat" : paneFor(kind, abortRef.current !== null))
|
|
128
169
|
setLines((prev) => {
|
|
129
170
|
const next = [...prev]
|
|
130
|
-
for (const part of parts) next.push({ id: idRef.current++, kind, text: part })
|
|
131
|
-
return next.slice(-
|
|
171
|
+
for (const part of parts) next.push({ id: idRef.current++, kind, text: part, pane: where })
|
|
172
|
+
return next.slice(-600)
|
|
132
173
|
})
|
|
133
174
|
}
|
|
134
|
-
const pushLine = (line: Omit<Line, "id">) =>
|
|
175
|
+
const pushLine = (line: Omit<Line, "id">) =>
|
|
176
|
+
setLines((prev) => [...prev, { ...line, pane: line.pane || paneFor(line.kind, abortRef.current !== null), id: idRef.current++ }].slice(-600))
|
|
177
|
+
/** What an agent's event tells the right pane: a file it used, a skill it loaded, its plan, how full its context is. */
|
|
178
|
+
const inspect = (e: CodeEvent) => {
|
|
179
|
+
const s = inspRef.current
|
|
180
|
+
if (e.kind === "tool") {
|
|
181
|
+
if (e.file) useFile(s, e.file)
|
|
182
|
+
if (e.skill) addSkill(s, e.skill)
|
|
183
|
+
} else if (e.kind === "plan") setPlan(s, e.items)
|
|
184
|
+
else if (e.kind === "usage") setContext(s, e.context || 0)
|
|
185
|
+
else return
|
|
186
|
+
bump()
|
|
187
|
+
}
|
|
135
188
|
/** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
|
|
136
189
|
const pushTool = (text: string, ok: boolean) => push(!ok ? "err" : /^(Writing|Editing) /.test(text) ? "file" : "tool", text)
|
|
137
190
|
const pushProduced = () => {
|
|
@@ -374,7 +427,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
374
427
|
return
|
|
375
428
|
}
|
|
376
429
|
if (key.name === "tab" && !key.shift) cycleProfile()
|
|
377
|
-
if (key.name === "p" && key.ctrl) push("sys", HELP)
|
|
430
|
+
if (key.name === "p" && key.ctrl) push("sys", HELP, "chat")
|
|
431
|
+
if (key.name === "o" && key.ctrl && width < SPLIT_MIN) setNarrowView((v) => (v === "chat" ? "details" : "chat"))
|
|
378
432
|
if (key.name === "escape" && abortRef.current) openDialog("stop")
|
|
379
433
|
})
|
|
380
434
|
|
|
@@ -396,6 +450,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
396
450
|
replayingRef.current = false
|
|
397
451
|
setStep(0)
|
|
398
452
|
setBrowsing(true)
|
|
453
|
+
startProgress(inspRef.current, `Browser · ${clip(e.goal, 40)}`)
|
|
454
|
+
setWho(inspRef.current, "Browser agent")
|
|
455
|
+
bump()
|
|
399
456
|
if (agent) push("code", `Browser: ${clip(e.goal, 110)}`)
|
|
400
457
|
if (!aborted()) setActivity(cfg?.llm.provider === "mock" ? "Running the offline demo…" : "Starting the browser…")
|
|
401
458
|
return
|
|
@@ -417,6 +474,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
417
474
|
if (shows(event, detailRef.current)) push(event.level === "error" ? "err" : "sys", event.text)
|
|
418
475
|
if (!aborted()) setActivity(activityFromLog(event.text))
|
|
419
476
|
}
|
|
477
|
+
if (event.type === "thought") {
|
|
478
|
+
stepStarted(inspRef.current, `Step ${event.step} · ${clip(event.text.replace(/\s+/g, " "), 90)}${replayingRef.current ? " (memory)" : ""}`)
|
|
479
|
+
bump()
|
|
480
|
+
}
|
|
420
481
|
if (event.type === "thought" && !replayingRef.current) {
|
|
421
482
|
if (shows(event, detailRef.current)) push("thought", stepIntent(event.step, event.text, detailRef.current))
|
|
422
483
|
if (!aborted()) setActivity(`Acting in the browser (step ${stepRef.current})…`)
|
|
@@ -425,7 +486,11 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
425
486
|
markRef.current = Date.now()
|
|
426
487
|
if (!aborted()) setActivity(replayingRef.current ? "Replaying from memory…" : `Thinking about step ${stepRef.current + 1}…`)
|
|
427
488
|
}
|
|
428
|
-
if (event.type === "outcome")
|
|
489
|
+
if (event.type === "outcome") {
|
|
490
|
+
push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
|
|
491
|
+
stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(event.text, 80))
|
|
492
|
+
bump()
|
|
493
|
+
}
|
|
429
494
|
return
|
|
430
495
|
}
|
|
431
496
|
case "run-done": {
|
|
@@ -450,17 +515,25 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
450
515
|
producedRef.current.runs.push({ runDir: e.runDir, status: r?.status || "fail", files: e.files })
|
|
451
516
|
setLastStats(summary.stats)
|
|
452
517
|
setBrowsing(false)
|
|
518
|
+
progressDone(inspRef.current, aborted())
|
|
519
|
+
setWho(inspRef.current, codingRef.current ? "Scribe" : "ForgeQE's agent")
|
|
520
|
+
bump()
|
|
453
521
|
if (agent && !aborted()) setActivity("The agent is reading the result…")
|
|
454
522
|
return
|
|
455
523
|
}
|
|
456
524
|
case "suite-start":
|
|
457
525
|
push("sys", `${e.cases.length} test case${e.cases.length === 1 ? "" : "s"}${e.parallel > 1 ? `, ${e.parallel} at a time` : ""}`)
|
|
458
526
|
setSuiteProgress({ done: 0, total: e.cases.length })
|
|
527
|
+
startProgress(inspRef.current, `${e.cases.length} test case${e.cases.length === 1 ? "" : "s"}`)
|
|
528
|
+
bump()
|
|
459
529
|
return
|
|
460
530
|
case "suite-event": {
|
|
461
531
|
const s = e.event
|
|
462
532
|
const tag = (id: string) => ((configRef.current?.suite.parallel || 1) > 1 ? `[${id}] ` : "")
|
|
463
533
|
if (s.type === "case-start") {
|
|
534
|
+
caseStarted(inspRef.current, s.case.id, s.case.title)
|
|
535
|
+
setWho(inspRef.current, "Browser agent")
|
|
536
|
+
bump()
|
|
464
537
|
push("code", `${s.index + 1}/${s.total} ${s.case.id} ${s.case.title}`)
|
|
465
538
|
// What was read from the test management system: where the case lives and the steps the browser will follow.
|
|
466
539
|
const steps = s.case.steps
|
|
@@ -476,6 +549,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
476
549
|
} else if (s.type === "case-done") {
|
|
477
550
|
setSuiteProgress((p) => (p ? { ...p, done: p.done + 1 } : p))
|
|
478
551
|
const r = s.result
|
|
552
|
+
caseDone(inspRef.current, r.case.id, r.status, r.failure ? FAILURE_LABEL[r.failure.type] : undefined)
|
|
553
|
+
bump()
|
|
479
554
|
push(r.status === "passed" ? "ok" : "err", `${r.case.id} ${r.status} in ${formatDuration(r.ms)}${r.failure ? ` · ${FAILURE_LABEL[r.failure.type]}` : ""}`)
|
|
480
555
|
if (r.failure) push("sys", ` ${tag(r.case.id)}${clip(r.failure.reason, 200)} Next: ${FAILURE_ACTION[r.failure.type]}.`)
|
|
481
556
|
// Each step of the case with its verdict, and what the page showed for a step that did not pass.
|
|
@@ -508,6 +583,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
508
583
|
return
|
|
509
584
|
case "agent": {
|
|
510
585
|
const ev = e.event
|
|
586
|
+
inspect(ev)
|
|
587
|
+
if (ev.kind === "plan") return
|
|
511
588
|
if (ev.kind === "usage") useTokens(`agent-tool-${runIndex.current}`, ev.usage.input + ev.usage.output)
|
|
512
589
|
// Hidden lines (reads, searches, the agent's narration) still show on the activity line below.
|
|
513
590
|
else if (shows(ev, detailRef.current)) {
|
|
@@ -527,6 +604,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
527
604
|
codingRef.current = true
|
|
528
605
|
codeRunDir.current = e.runDir
|
|
529
606
|
setCoding(true)
|
|
607
|
+
setWho(inspRef.current, "Scribe")
|
|
608
|
+
bump()
|
|
530
609
|
const show = shortPath
|
|
531
610
|
pushLine({
|
|
532
611
|
kind: "card",
|
|
@@ -550,6 +629,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
550
629
|
case "code-done":
|
|
551
630
|
codingRef.current = false
|
|
552
631
|
setCoding(false)
|
|
632
|
+
setWho(inspRef.current, "ForgeQE's agent")
|
|
633
|
+
bump()
|
|
553
634
|
producedRef.current.tests.push({ runDir: codeRunDir.current, result: e.result, error: e.error })
|
|
554
635
|
if (e.error) push("err", `Coding agent: ${e.error}`)
|
|
555
636
|
if (agent && !aborted()) setActivity("The agent is reading the result…")
|
|
@@ -561,6 +642,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
561
642
|
}
|
|
562
643
|
|
|
563
644
|
const onBrainEvent = (e: CodeEvent) => {
|
|
645
|
+
inspect(e)
|
|
646
|
+
if (e.kind === "plan") return
|
|
564
647
|
if (e.kind === "usage") {
|
|
565
648
|
useTokens("agent", e.usage.input + e.usage.output)
|
|
566
649
|
return
|
|
@@ -600,6 +683,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
600
683
|
started.current = Date.now()
|
|
601
684
|
usedRef.current = new Map()
|
|
602
685
|
producedRef.current = emptyProduced()
|
|
686
|
+
newRequest(inspRef.current)
|
|
687
|
+
requests.current++
|
|
688
|
+
sessionStarted.current ??= Date.now()
|
|
689
|
+
bump()
|
|
603
690
|
setTokens(0)
|
|
604
691
|
setStatus("running")
|
|
605
692
|
setActivity(activityText)
|
|
@@ -607,6 +694,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
607
694
|
}
|
|
608
695
|
|
|
609
696
|
const finish = (finalStatus: string) => {
|
|
697
|
+
progressDone(inspRef.current, finalStatus === "stopped")
|
|
698
|
+
bump()
|
|
610
699
|
setStatus(finalStatus)
|
|
611
700
|
setActivity("")
|
|
612
701
|
setThinking(false)
|
|
@@ -671,21 +760,21 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
671
760
|
controller.signal.removeEventListener("abort", onAbort)
|
|
672
761
|
if (answer.outcome !== "stopped") convoRef.current.push({ user: text, reply: answer.reply })
|
|
673
762
|
if (answer.outcome === "stopped") {
|
|
674
|
-
push("sys", "Stopped.")
|
|
763
|
+
push("sys", "Stopped.", "chat")
|
|
675
764
|
finalStatus = "stopped"
|
|
676
765
|
} else {
|
|
677
766
|
if (answer.reply) pushLine({ kind: "reply", text: answer.reply, outcome: answer.outcome })
|
|
678
|
-
if (answer.error && !answer.reply) push("err", answer.error)
|
|
767
|
+
if (answer.error && !answer.reply) push("err", answer.error, "chat")
|
|
679
768
|
finalStatus = answer.outcome === "done" ? "done" : answer.outcome === "needs_input" ? "idle" : "fail"
|
|
680
769
|
}
|
|
681
770
|
} catch (err) {
|
|
682
|
-
push("err", (err as Error).message)
|
|
771
|
+
push("err", (err as Error).message, "chat")
|
|
683
772
|
closeBrain()
|
|
684
773
|
} finally {
|
|
685
774
|
pushProduced()
|
|
686
775
|
// What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
|
|
687
776
|
const spent = requestLine(meterStart)
|
|
688
|
-
if (spent) push("sys", spent)
|
|
777
|
+
if (spent) push("sys", spent, "chat")
|
|
689
778
|
finish(finalStatus)
|
|
690
779
|
}
|
|
691
780
|
}
|
|
@@ -737,6 +826,15 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
737
826
|
}, [])
|
|
738
827
|
|
|
739
828
|
const onSubmit = (value: string) => {
|
|
829
|
+
// What a command answers (/help, /skills, "busy") is for the chat, even while a request runs.
|
|
830
|
+
answeringRef.current = true
|
|
831
|
+
try {
|
|
832
|
+
submit(value)
|
|
833
|
+
} finally {
|
|
834
|
+
answeringRef.current = false
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const submit = (value: string) => {
|
|
740
838
|
const text = value.replace(/\t/g, "").trim()
|
|
741
839
|
setInputKey((k) => k + 1)
|
|
742
840
|
const pending = entryRef.current
|
|
@@ -864,17 +962,24 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
864
962
|
|
|
865
963
|
const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
|
|
866
964
|
const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
|
|
965
|
+
// The split view's right pane shows tokens, time, and the last result, so the hints under the prompt leave them out.
|
|
966
|
+
const wide = width >= SPLIT_MIN
|
|
967
|
+
const viewHint: Hint[] = wide ? [] : [{ key: "ctrl+o", label: "details", onPress: () => setNarrowView("details") }]
|
|
968
|
+
// While running, the row also holds the spinner and esc stop: below 95 columns the view hint does not fit beside them.
|
|
969
|
+
const runningViewHint: Hint[] = width >= 95 ? viewHint : []
|
|
867
970
|
const idleHints: Hint[] = [
|
|
868
971
|
...copiedHint,
|
|
869
|
-
...(lastStats ? [{ key: `last run ${lastStats}`, label: "" }] : []),
|
|
972
|
+
...(lastStats && !wide ? [{ key: `last run ${lastStats}`, label: "" }] : []),
|
|
973
|
+
...viewHint,
|
|
870
974
|
{ key: "tab", label: "profiles" },
|
|
871
975
|
{ key: "ctrl+p", label: "commands" },
|
|
872
976
|
closeHint,
|
|
873
977
|
]
|
|
874
978
|
const runningHints: Hint[] = [
|
|
875
979
|
...copiedHint,
|
|
876
|
-
{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" },
|
|
877
|
-
|
|
980
|
+
...(wide ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" }, ...runningViewHint]),
|
|
981
|
+
// Beside the 40% pane's spinner and esc stop, only close fits from 120 columns.
|
|
982
|
+
...(wide ? [] : [{ key: "ctrl+p", label: "commands" }]),
|
|
878
983
|
closeHint,
|
|
879
984
|
]
|
|
880
985
|
const runningLeft = (
|
|
@@ -920,6 +1025,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
920
1025
|
model={model}
|
|
921
1026
|
right={right}
|
|
922
1027
|
rightColor={rightColor}
|
|
1028
|
+
compact={width >= SPLIT_MIN && !home}
|
|
923
1029
|
/>
|
|
924
1030
|
<Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
|
|
925
1031
|
</>
|
|
@@ -946,37 +1052,134 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
946
1052
|
)
|
|
947
1053
|
}
|
|
948
1054
|
|
|
1055
|
+
// What the split view shows about the session and the request in progress.
|
|
1056
|
+
const cfgNow = configRef.current
|
|
1057
|
+
const insp = inspRef.current
|
|
1058
|
+
const spent = meterSince(sessionMeter.current)
|
|
1059
|
+
const contextKey = cfgNow ? `${cfgNow.configPath}|${cfgNow.activeProfile}|${cfgNow.knowledge?.dir}` : ""
|
|
1060
|
+
if (cfgNow && contextInfo.current.key !== contextKey) {
|
|
1061
|
+
let mcp: string[] = []
|
|
1062
|
+
try {
|
|
1063
|
+
mcp = externalMcp(cfgNow).servers.map((x) => x.label)
|
|
1064
|
+
} catch {}
|
|
1065
|
+
contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
|
|
1066
|
+
}
|
|
1067
|
+
const llm = cfgNow?.llm
|
|
1068
|
+
const sessionInfo: SessionInfo = {
|
|
1069
|
+
profile,
|
|
1070
|
+
model: model || (cfgNow ? modelLabel(cfgNow) : ""),
|
|
1071
|
+
auth: !llm ? "–" : llm.auth === "entra" ? "Entra ID, no key" : llm.apiKeyEnv ? `API key in ${llm.apiKeyEnv}` : llm.provider === "mock" ? "offline demo" : llm.provider,
|
|
1072
|
+
cwd: shortCwd(),
|
|
1073
|
+
tests: testsRef.current.on ? "written to this folder" : requests.current ? `not written (${testsRef.current.why || "off"})` : "set on the first request",
|
|
1074
|
+
browser: `${headed ? "visible" : "headless"} · up to ${maxSteps} steps${options.url ? ` · ${hostOf(options.url)}` : ""}`,
|
|
1075
|
+
...(cfgNow?.domain ? { domain: `${cfgNow.domain.industry}${cfgNow.domain.regulations.length ? ` (${cfgNow.domain.regulations.join(", ")})` : ""}` } : {}),
|
|
1076
|
+
started: sessionStarted.current,
|
|
1077
|
+
requests: requests.current,
|
|
1078
|
+
total: spent.roles.length ? { usage: spent.total, cost: spent.cost } : undefined,
|
|
1079
|
+
}
|
|
1080
|
+
const contextPanel: ContextInfo = {
|
|
1081
|
+
tokens: insp.context,
|
|
1082
|
+
limit: CONTEXT_LIMIT,
|
|
1083
|
+
skills: insp.skills,
|
|
1084
|
+
knowledge: contextInfo.current.knowledge,
|
|
1085
|
+
...(cfgNow?.domain ? { domain: cfgNow.domain.industry } : {}),
|
|
1086
|
+
mcp: contextInfo.current.mcp,
|
|
1087
|
+
capabilities: CAPABILITIES.length,
|
|
1088
|
+
}
|
|
1089
|
+
const placeholder = running
|
|
1090
|
+
? coding
|
|
1091
|
+
? "The coding agent is writing a test · esc to stop"
|
|
1092
|
+
: `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
|
|
1093
|
+
: "Ask ForgeQE: a goal, test cases, a test, a pipeline…"
|
|
1094
|
+
const chatLines = lines.filter((l) => l.pane !== "activity")
|
|
1095
|
+
const activityLines = lines.filter((l) => l.pane === "activity")
|
|
1096
|
+
const stop = () => openDialog("stop")
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* The right pane: who is working, the plan, the activity, and files, context, and tokens. Its rows are counted, not
|
|
1100
|
+
* left to flex: the activity log gets what the fixed parts leave, so the bottom panels are never pushed off screen.
|
|
1101
|
+
*/
|
|
1102
|
+
const details = (paneWidth: number, rowsAvailable: number) => {
|
|
1103
|
+
const bottomRows = Math.max(6, Math.min(9, Math.floor(rowsAvailable / 4)))
|
|
1104
|
+
const planCap = Math.max(3, Math.min(10, Math.floor(rowsAvailable / 4)))
|
|
1105
|
+
const planLines = insp.plan.length + insp.progress.items.length + (insp.plan.length && insp.progress.items.length ? 1 : 0)
|
|
1106
|
+
const planHeight = 1 + Math.max(1, Math.min(planCap, planLines))
|
|
1107
|
+
// Header, a blank row, the plan, a blank row, the activity, a blank row, and the bottom panels with their title and legend.
|
|
1108
|
+
const activityRows = Math.max(4, rowsAvailable - 1 - 1 - planHeight - 1 - 1 - (bottomRows + 2))
|
|
1109
|
+
const third = Math.floor((paneWidth - 2) / 3)
|
|
1110
|
+
return (
|
|
1111
|
+
<box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
|
|
1112
|
+
<WorkHeader running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} />
|
|
1113
|
+
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1114
|
+
<PlanPanel s={insp} width={paneWidth} rows={planCap} />
|
|
1115
|
+
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1116
|
+
<ActivityLog lines={activityLines} scrollRef={activityScroll} height={activityRows} width={paneWidth} />
|
|
1117
|
+
<box style={{ flexDirection: "row", height: bottomRows + 2, flexShrink: 0, marginTop: 1 }}>
|
|
1118
|
+
<FilesPanel s={insp} width={third} rows={bottomRows - 1} />
|
|
1119
|
+
<ContextPanel c={contextPanel} width={third} />
|
|
1120
|
+
<TokensPanel roles={spent.roles} total={{ usage: spent.total, cost: spent.cost }} width={paneWidth - 2 * third} />
|
|
1121
|
+
</box>
|
|
1122
|
+
</box>
|
|
1123
|
+
)
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const transcript = (all: Line[], style: Record<string, unknown>) => (
|
|
1127
|
+
<scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
|
|
1128
|
+
{all.map((line) => (
|
|
1129
|
+
<TranscriptLine key={line.id} line={line} />
|
|
1130
|
+
))}
|
|
1131
|
+
{running && !split ? (
|
|
1132
|
+
<box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
|
|
1133
|
+
<Spinner kind="dots" />
|
|
1134
|
+
<text fg={theme.text}>{` ${activity || "Working…"}`}</text>
|
|
1135
|
+
</box>
|
|
1136
|
+
) : null}
|
|
1137
|
+
</scrollbox>
|
|
1138
|
+
)
|
|
1139
|
+
const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.min(10, Math.floor(height / 3)))])
|
|
1140
|
+
|
|
1141
|
+
const split = width >= SPLIT_MIN
|
|
1142
|
+
if (split) {
|
|
1143
|
+
const leftWidth = Math.floor(width * 0.4)
|
|
1144
|
+
const rightWidth = width - leftWidth - 3
|
|
1145
|
+
return (
|
|
1146
|
+
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1147
|
+
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1148
|
+
<box style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1149
|
+
<SessionBlock info={sessionInfo} width={leftWidth - 3} />
|
|
1150
|
+
{transcript(chatLines, {})}
|
|
1151
|
+
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
|
|
1152
|
+
</box>
|
|
1153
|
+
<box border={["left"]} borderColor={theme.button} style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1154
|
+
{details(rightWidth - 2, height - 2)}
|
|
1155
|
+
</box>
|
|
1156
|
+
</box>
|
|
1157
|
+
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1158
|
+
</box>
|
|
1159
|
+
)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
949
1162
|
const promptWidth = Math.max(40, width - 4)
|
|
1163
|
+
if (narrowView === "details") {
|
|
1164
|
+
return (
|
|
1165
|
+
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1166
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>{details(promptWidth, height - 3 - (dialog ? 8 : 0))}</box>
|
|
1167
|
+
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
|
|
1168
|
+
{dialog ? (
|
|
1169
|
+
// Stop, a question, or "your turn": the same panel the chat view shows in place of the prompt.
|
|
1170
|
+
controls(promptWidth)
|
|
1171
|
+
) : (
|
|
1172
|
+
<Hints width={promptWidth} items={[{ key: "ctrl+o", label: "back to the chat", onPress: () => setNarrowView("chat") }, closeHint]} />
|
|
1173
|
+
)}
|
|
1174
|
+
</box>
|
|
1175
|
+
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1176
|
+
</box>
|
|
1177
|
+
)
|
|
1178
|
+
}
|
|
950
1179
|
return (
|
|
951
1180
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
stickyScroll
|
|
955
|
-
stickyStart="bottom"
|
|
956
|
-
style={{ flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}
|
|
957
|
-
focused={false}
|
|
958
|
-
>
|
|
959
|
-
{lines.map((line) => (
|
|
960
|
-
<TranscriptLine key={line.id} line={line} />
|
|
961
|
-
))}
|
|
962
|
-
{running ? (
|
|
963
|
-
<box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
|
|
964
|
-
<Spinner kind="dots" />
|
|
965
|
-
<text fg={theme.text}>{` ${activity || "Working…"}`}</text>
|
|
966
|
-
</box>
|
|
967
|
-
) : null}
|
|
968
|
-
</scrollbox>
|
|
969
|
-
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
|
|
970
|
-
{renderControls(
|
|
971
|
-
promptWidth,
|
|
972
|
-
running
|
|
973
|
-
? coding
|
|
974
|
-
? "The coding agent is writing a test · esc to stop"
|
|
975
|
-
: `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
|
|
976
|
-
: "Ask ForgeQE: a goal, test cases, a test, a pipeline…",
|
|
977
|
-
[3, Math.max(3, Math.min(10, Math.floor(height / 3)))],
|
|
978
|
-
)}
|
|
979
|
-
</box>
|
|
1181
|
+
{transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 })}
|
|
1182
|
+
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>{controls(promptWidth)}</box>
|
|
980
1183
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
981
1184
|
</box>
|
|
982
1185
|
)
|
package/src/tui/components.tsx
CHANGED
|
@@ -48,6 +48,8 @@ export type PromptProps = {
|
|
|
48
48
|
rightColor?: string
|
|
49
49
|
/** A secret being entered: the box shows one dot per character instead of a text field, so the value is never drawn. */
|
|
50
50
|
masked?: number
|
|
51
|
+
/** Narrow pane beside a session panel that names the model and profile: the bottom row shows only `right`. */
|
|
52
|
+
compact?: boolean
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** Enter sends; Shift+Enter, Alt+Enter or Ctrl+J start a new line. */
|
|
@@ -106,16 +108,18 @@ export function Prompt(props: PromptProps) {
|
|
|
106
108
|
placeholderColor={theme.faint}
|
|
107
109
|
/>
|
|
108
110
|
)}
|
|
109
|
-
<box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
|
|
110
|
-
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
<box style={{ flexDirection: "row", justifyContent: props.compact ? "flex-end" : "space-between", marginTop: 1 }}>
|
|
112
|
+
{props.compact ? null : (
|
|
113
|
+
<text>
|
|
114
|
+
<span fg={theme.muted}>{"Model "}</span>
|
|
115
|
+
<span fg={theme.text}>{props.model || "…"}</span>
|
|
116
|
+
</text>
|
|
117
|
+
)}
|
|
114
118
|
<text>
|
|
115
119
|
{props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
|
|
116
|
-
{props.right ? <span fg={theme.faint}>{" · "}</span> : null}
|
|
117
|
-
<span fg={theme.muted}>{"profile "}</span>
|
|
118
|
-
<span fg={theme.text}>{props.profile || "none"}</span>
|
|
120
|
+
{props.right && !props.compact ? <span fg={theme.faint}>{" · "}</span> : null}
|
|
121
|
+
{props.compact ? null : <span fg={theme.muted}>{"profile "}</span>}
|
|
122
|
+
{props.compact ? null : <span fg={theme.text}>{props.profile || "none"}</span>}
|
|
119
123
|
</text>
|
|
120
124
|
</box>
|
|
121
125
|
</box>
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import type { RefObject } from "react"
|
|
3
|
+
|
|
4
|
+
import type { Usage } from "../types.ts"
|
|
5
|
+
import { formatCost, type Cost, type Role } from "../usage.ts"
|
|
6
|
+
import { Spinner } from "./components.tsx"
|
|
7
|
+
import { filesByUse, type FileUse, type Inspector, type Progress } from "./session.ts"
|
|
8
|
+
import { clip, tailCut } from "./text.ts"
|
|
9
|
+
import { formatTokens, theme } from "./theme.ts"
|
|
10
|
+
import { TranscriptLine, type Line } from "./transcript.tsx"
|
|
11
|
+
|
|
12
|
+
/** Each section of the split view has its own colour from the shell's palette; status colours keep their meaning. */
|
|
13
|
+
export const SECTION = {
|
|
14
|
+
session: theme.accent,
|
|
15
|
+
plan: theme.thought,
|
|
16
|
+
activity: theme.accent,
|
|
17
|
+
files: theme.memory,
|
|
18
|
+
context: theme.warn,
|
|
19
|
+
tokens: theme.ok,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ROLE_COLOR: Record<Role, string> = {
|
|
23
|
+
"ForgeQE's agent": theme.accent,
|
|
24
|
+
"browser agent": theme.thought,
|
|
25
|
+
Scribe: theme.ok,
|
|
26
|
+
reviewer: theme.warn,
|
|
27
|
+
judge: theme.memory,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function SectionTitle({ title, color, right, width }: { title: string; color: string; right?: string; width?: number }) {
|
|
31
|
+
if (right && width) right = clip(right, Math.max(4, width - title.length - 3))
|
|
32
|
+
return (
|
|
33
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0 }}>
|
|
34
|
+
<text>
|
|
35
|
+
<span fg={color}>{"▍"}</span>
|
|
36
|
+
<span fg={color}>{title}</span>
|
|
37
|
+
</text>
|
|
38
|
+
{right ? <text fg={theme.muted}>{right}</text> : null}
|
|
39
|
+
</box>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type SessionInfo = {
|
|
44
|
+
profile: string
|
|
45
|
+
model: string
|
|
46
|
+
auth: string
|
|
47
|
+
cwd: string
|
|
48
|
+
tests: string
|
|
49
|
+
browser: string
|
|
50
|
+
domain?: string
|
|
51
|
+
started?: number
|
|
52
|
+
requests: number
|
|
53
|
+
total?: { usage: Usage; cost?: Cost }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Top of the left pane: what this session runs with, and what it has used. */
|
|
57
|
+
export function SessionBlock({ info, width }: { info: SessionInfo; width: number }) {
|
|
58
|
+
const room = Math.max(10, width - 12)
|
|
59
|
+
const row = (label: string, value: string, color: string = theme.text) => (
|
|
60
|
+
<text key={label}>
|
|
61
|
+
<span fg={theme.muted}>{label.padEnd(9)}</span>
|
|
62
|
+
<span fg={color}>{clip(value, room)}</span>
|
|
63
|
+
</text>
|
|
64
|
+
)
|
|
65
|
+
const started = info.started ? new Date(info.started) : null
|
|
66
|
+
const clock = started ? `${String(started.getHours()).padStart(2, "0")}:${String(started.getMinutes()).padStart(2, "0")}` : "–"
|
|
67
|
+
const used = info.total ? `${formatTokens(info.total.usage.input + info.total.usage.output)} tokens${info.total.cost ? ` · ${formatCost(info.total.cost)}` : ""}` : "nothing yet"
|
|
68
|
+
return (
|
|
69
|
+
<box style={{ flexDirection: "column", flexShrink: 0, paddingBottom: 1 }}>
|
|
70
|
+
<SectionTitle title="SESSION" color={SECTION.session} right={`started ${clock}`} width={width} />
|
|
71
|
+
{row("Model", `${info.profile || "none"} · ${info.model || "…"}`)}
|
|
72
|
+
{row("Sign-in", info.auth, info.auth.startsWith("Entra") ? theme.ok : theme.text)}
|
|
73
|
+
{row("Folder", tailCut(info.cwd, room))}
|
|
74
|
+
{row("Tests", info.tests, info.tests.startsWith("written") ? theme.ok : theme.muted)}
|
|
75
|
+
{row("Browser", info.browser)}
|
|
76
|
+
{info.domain ? row("Domain", info.domain, theme.memory) : null}
|
|
77
|
+
{row("Used", `${info.requests} request${info.requests === 1 ? "" : "s"} · ${used}`, theme.muted)}
|
|
78
|
+
</box>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The right pane's top line: who is working on what, for how long, and the Stop button. */
|
|
83
|
+
export function WorkHeader(props: { running: boolean; who: string; activity: string; elapsed: string; status: string; width: number; onStop: () => void }) {
|
|
84
|
+
const statusColor = props.running ? theme.warn : props.status === "done" ? theme.ok : props.status === "fail" ? theme.err : theme.muted
|
|
85
|
+
const label = props.running ? `${props.who} · ${props.activity || "working…"}` : props.status === "idle" ? "Waiting for your next request" : `Last request ${props.status}`
|
|
86
|
+
return (
|
|
87
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0 }}>
|
|
88
|
+
<box style={{ flexDirection: "row" }}>
|
|
89
|
+
{props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={statusColor}>{"●"}</text>}
|
|
90
|
+
<text>
|
|
91
|
+
<span fg={statusColor}>{` ${clip(label, Math.max(10, props.width - 26))}`}</span>
|
|
92
|
+
{props.elapsed ? <span fg={theme.faint}>{` ${props.elapsed}`}</span> : null}
|
|
93
|
+
</text>
|
|
94
|
+
</box>
|
|
95
|
+
<box
|
|
96
|
+
onMouseDown={props.running ? props.onStop : undefined}
|
|
97
|
+
style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: props.running ? theme.warn : theme.button }}
|
|
98
|
+
>
|
|
99
|
+
<text fg={props.running ? theme.onButton : theme.muted}>{"■ Stop"}</text>
|
|
100
|
+
</box>
|
|
101
|
+
</box>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const PLAN_MARK: Record<string, [string, string]> = {
|
|
106
|
+
completed: ["✓", theme.ok],
|
|
107
|
+
in_progress: ["◐", theme.warn],
|
|
108
|
+
pending: ["○", theme.faint],
|
|
109
|
+
cancelled: ["✗", theme.faint],
|
|
110
|
+
}
|
|
111
|
+
const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
|
|
112
|
+
ok: ["✓", theme.ok],
|
|
113
|
+
running: ["◐", theme.warn],
|
|
114
|
+
fail: ["✗", theme.err],
|
|
115
|
+
skipped: ["■", theme.faint],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The agent's to-do list, then the browser steps or suite cases; only the latest that fit in `rows`. */
|
|
119
|
+
export function PlanPanel({ s, width, rows }: { s: Inspector; width: number; rows: number }) {
|
|
120
|
+
const lines: { mark: string; color: string; text: string; textColor: string }[] = []
|
|
121
|
+
for (const p of s.plan) {
|
|
122
|
+
const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
|
|
123
|
+
lines.push({ mark, color, text: p.text, textColor: p.status === "completed" || p.status === "cancelled" ? theme.muted : theme.text })
|
|
124
|
+
}
|
|
125
|
+
if (s.progress.items.length) {
|
|
126
|
+
if (s.plan.length) lines.push({ mark: " ", color: theme.faint, text: s.progress.title, textColor: theme.faint })
|
|
127
|
+
for (const i of s.progress.items) {
|
|
128
|
+
const [mark, color] = PROGRESS_MARK[i.status]
|
|
129
|
+
lines.push({ mark, color, text: i.detail ? `${i.label} — ${i.detail}` : i.label, textColor: i.status === "fail" ? theme.err : i.status === "running" ? theme.text : theme.muted })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const done = s.plan.filter((p) => p.status === "completed").length
|
|
133
|
+
const right = s.plan.length ? `${done} of ${s.plan.length}` : s.progress.items.length ? s.progress.title : ""
|
|
134
|
+
const shown = lines.slice(-Math.max(1, rows))
|
|
135
|
+
return (
|
|
136
|
+
<box style={{ flexDirection: "column", flexShrink: 0 }}>
|
|
137
|
+
<SectionTitle title="PLAN" color={SECTION.plan} right={right} width={width} />
|
|
138
|
+
{shown.length ? (
|
|
139
|
+
shown.map((l, i) => (
|
|
140
|
+
<text key={i}>
|
|
141
|
+
<span fg={l.color}>{` ${l.mark} `}</span>
|
|
142
|
+
<span fg={l.textColor}>{clip(l.text, Math.max(10, width - 4))}</span>
|
|
143
|
+
</text>
|
|
144
|
+
))
|
|
145
|
+
) : (
|
|
146
|
+
<text fg={theme.faint}>{clip(" The agent's plan and each browser step or test case show here.", Math.max(10, width))}</text>
|
|
147
|
+
)}
|
|
148
|
+
</box>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Everything the agents do, newest at the bottom. */
|
|
153
|
+
export function ActivityLog(props: { lines: Line[]; scrollRef: RefObject<ScrollBoxRenderable | null>; height: number; width: number }) {
|
|
154
|
+
return (
|
|
155
|
+
<box style={{ flexDirection: "column", height: props.height, flexShrink: 0 }}>
|
|
156
|
+
<SectionTitle title="ACTIVITY" color={SECTION.activity} right={props.lines.length ? `${props.lines.length} events` : ""} width={props.width} />
|
|
157
|
+
<scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
|
|
158
|
+
{props.lines.length ? (
|
|
159
|
+
props.lines.map((line) => <TranscriptLine key={line.id} line={line} />)
|
|
160
|
+
) : (
|
|
161
|
+
<text fg={theme.faint}>{clip(" Tool calls, browser steps, and the agents' reasoning show here.", Math.max(10, props.width))}</text>
|
|
162
|
+
)}
|
|
163
|
+
</scrollbox>
|
|
164
|
+
</box>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const FILE_MARK: Record<FileUse["last"], [string, string]> = { read: ["◉", theme.accent], write: ["✎", theme.ok], edit: ["✎", theme.warn] }
|
|
169
|
+
|
|
170
|
+
export function FilesPanel({ s, width, rows }: { s: Inspector; width: number; rows: number }) {
|
|
171
|
+
const files = filesByUse(s)
|
|
172
|
+
const room = Math.max(8, width - 9)
|
|
173
|
+
return (
|
|
174
|
+
<box style={{ flexDirection: "column", width, paddingRight: 1 }}>
|
|
175
|
+
<SectionTitle title="FILES" color={SECTION.files} right={files.length ? String(files.length) : ""} width={width} />
|
|
176
|
+
{files.length ? (
|
|
177
|
+
files.slice(0, rows).map((f) => {
|
|
178
|
+
const [mark, color] = FILE_MARK[f.last]
|
|
179
|
+
const times = f.reads + f.changes
|
|
180
|
+
return (
|
|
181
|
+
<text key={f.path}>
|
|
182
|
+
<span fg={color}>{`${mark} `}</span>
|
|
183
|
+
<span fg={f.changes ? theme.text : theme.muted}>{tailCut(f.path, room)}</span>
|
|
184
|
+
{times > 1 ? <span fg={theme.faint}>{` ×${times}`}</span> : null}
|
|
185
|
+
</text>
|
|
186
|
+
)
|
|
187
|
+
})
|
|
188
|
+
) : (
|
|
189
|
+
<text fg={theme.faint}>{"No files yet"}</text>
|
|
190
|
+
)}
|
|
191
|
+
{files.length > rows ? <text fg={theme.faint}>{`and ${files.length - rows} more`}</text> : null}
|
|
192
|
+
{width < 22 ? null : <text>
|
|
193
|
+
<span fg={theme.accent}>{"◉"}</span>
|
|
194
|
+
<span fg={theme.faint}>{" read "}</span>
|
|
195
|
+
<span fg={theme.ok}>{"✎"}</span>
|
|
196
|
+
<span fg={theme.faint}>{" new "}</span>
|
|
197
|
+
<span fg={theme.warn}>{"✎"}</span>
|
|
198
|
+
<span fg={theme.faint}>{" edited"}</span>
|
|
199
|
+
</text>}
|
|
200
|
+
</box>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export type ContextInfo = { tokens: number; limit: number; skills: string[]; knowledge: number; domain?: string; mcp: string[]; capabilities: number }
|
|
205
|
+
|
|
206
|
+
/** How full the model's context window is, and what ForgeQE put into it. */
|
|
207
|
+
export function ContextPanel({ c, width }: { c: ContextInfo; width: number }) {
|
|
208
|
+
const barWidth = Math.max(6, width - 16)
|
|
209
|
+
const share = c.limit ? Math.min(1, c.tokens / c.limit) : 0
|
|
210
|
+
const filled = Math.round(share * barWidth)
|
|
211
|
+
const color = share < 0.6 ? theme.ok : share < 0.85 ? theme.warn : theme.err
|
|
212
|
+
const room = Math.max(6, width - 12)
|
|
213
|
+
const row = (label: string, value: string, valueColor: string = theme.text) => (
|
|
214
|
+
<text key={label}>
|
|
215
|
+
<span fg={theme.muted}>{label.padEnd(10)}</span>
|
|
216
|
+
<span fg={valueColor}>{clip(value, room)}</span>
|
|
217
|
+
</text>
|
|
218
|
+
)
|
|
219
|
+
return (
|
|
220
|
+
<box style={{ flexDirection: "column", width, paddingRight: 1 }}>
|
|
221
|
+
<SectionTitle title="CONTEXT" color={SECTION.context} right={c.tokens ? `${Math.round(share * 100)}%` : ""} width={width} />
|
|
222
|
+
<text>
|
|
223
|
+
<span fg={color}>{"█".repeat(filled)}</span>
|
|
224
|
+
<span fg={theme.faint}>{"░".repeat(barWidth - filled)}</span>
|
|
225
|
+
<span fg={theme.muted}>{` ${formatTokens(c.tokens)}/${formatTokens(c.limit)}`}</span>
|
|
226
|
+
</text>
|
|
227
|
+
{row("Skills", c.skills.length ? c.skills.join(", ") : "none loaded", c.skills.length ? theme.ok : theme.faint)}
|
|
228
|
+
{row("Knowledge", c.knowledge ? `${c.knowledge} product file${c.knowledge === 1 ? "" : "s"}` : "none", c.knowledge ? theme.text : theme.faint)}
|
|
229
|
+
{row("Domain", c.domain || "not declared", c.domain ? theme.memory : theme.faint)}
|
|
230
|
+
{row("MCP", c.mcp.length ? c.mcp.join(", ") : "none", c.mcp.length ? theme.text : theme.faint)}
|
|
231
|
+
{row("Tools", `${c.capabilities} capabilities`, theme.muted)}
|
|
232
|
+
</box>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function TokensPanel({ roles, total, width }: { roles: { role: Role; usage: Usage; cost?: Cost }[]; total: { usage: Usage; cost?: Cost }; width: number }) {
|
|
237
|
+
const amount = (u: Usage, c?: Cost) => `${formatTokens(u.input + u.output).padStart(6)}${c ? ` ${formatCost(c)}` : ""}`
|
|
238
|
+
const labelWidth = Math.max(8, Math.min(16, width - 18))
|
|
239
|
+
return (
|
|
240
|
+
<box style={{ flexDirection: "column", width }}>
|
|
241
|
+
<SectionTitle title="TOKENS" color={SECTION.tokens} right={total.usage.cached ? `${formatTokens(total.usage.cached)} cached` : ""} />
|
|
242
|
+
{roles.length ? (
|
|
243
|
+
roles.map((r) => (
|
|
244
|
+
<text key={r.role}>
|
|
245
|
+
<span fg={ROLE_COLOR[r.role] || theme.text}>{clip(r.role, labelWidth).padEnd(labelWidth)}</span>
|
|
246
|
+
<span fg={theme.text}>{amount(r.usage, r.cost)}</span>
|
|
247
|
+
</text>
|
|
248
|
+
))
|
|
249
|
+
) : (
|
|
250
|
+
<text fg={theme.faint}>{"Nothing used yet"}</text>
|
|
251
|
+
)}
|
|
252
|
+
{roles.length > 1 ? (
|
|
253
|
+
<text>
|
|
254
|
+
<span fg={theme.muted}>{"Total".padEnd(labelWidth)}</span>
|
|
255
|
+
<span fg={theme.ok}>{amount(total.usage, total.cost)}</span>
|
|
256
|
+
</text>
|
|
257
|
+
) : null}
|
|
258
|
+
<text fg={theme.faint}>{`in ${formatTokens(total.usage.input)} · out ${formatTokens(total.usage.output)}`}</text>
|
|
259
|
+
</box>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { FileAction, PlanItem } from "../types.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the shell's right-hand pane shows about the request in progress: the agent's plan, the browser steps or suite
|
|
5
|
+
* cases, the files the agents used, and what is in the model's context. Plain data, changed only through the
|
|
6
|
+
* functions below, so it is tested without a terminal.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type FileUse = { path: string; reads: number; changes: number; last: FileAction; order: number }
|
|
10
|
+
|
|
11
|
+
export type Progress = { label: string; status: "running" | "ok" | "fail" | "skipped"; detail?: string }
|
|
12
|
+
|
|
13
|
+
export type Who = "ForgeQE's agent" | "Browser agent" | "Scribe"
|
|
14
|
+
|
|
15
|
+
export type Inspector = {
|
|
16
|
+
/** The agent's own to-do list. */
|
|
17
|
+
plan: PlanItem[]
|
|
18
|
+
/** The browser run's steps, or the suite's cases; `title` says which. */
|
|
19
|
+
progress: { title: string; items: Progress[] }
|
|
20
|
+
files: Map<string, FileUse>
|
|
21
|
+
/** Input tokens of the latest model call: how full its context window is. */
|
|
22
|
+
context: number
|
|
23
|
+
skills: string[]
|
|
24
|
+
who: Who
|
|
25
|
+
/** Counts file uses, so the most recent comes first. */
|
|
26
|
+
seq: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const MAX_PROGRESS = 200
|
|
30
|
+
|
|
31
|
+
export function newInspector(): Inspector {
|
|
32
|
+
return { plan: [], progress: { title: "", items: [] }, files: new Map(), context: 0, skills: [], who: "ForgeQE's agent", seq: 0 }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A new request keeps the files, skills, and context of the session, and starts a fresh plan and progress. */
|
|
36
|
+
export function newRequest(s: Inspector): void {
|
|
37
|
+
s.plan = []
|
|
38
|
+
s.progress = { title: "", items: [] }
|
|
39
|
+
s.who = "ForgeQE's agent"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function setPlan(s: Inspector, items: PlanItem[]): void {
|
|
43
|
+
s.plan = items
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useFile(s: Inspector, file: { path: string; action: FileAction }): void {
|
|
47
|
+
const was = s.files.get(file.path)
|
|
48
|
+
const reads = (was?.reads || 0) + (file.action === "read" ? 1 : 0)
|
|
49
|
+
const changes = (was?.changes || 0) + (file.action === "read" ? 0 : 1)
|
|
50
|
+
// Newest first: a file used again moves to the top.
|
|
51
|
+
s.files.set(file.path, { path: file.path, reads, changes, last: file.action, order: ++s.seq })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function addSkill(s: Inspector, name: string): void {
|
|
55
|
+
if (name && !s.skills.includes(name)) s.skills.push(name)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function setContext(s: Inspector, tokens: number): void {
|
|
59
|
+
if (tokens > 0) s.context = tokens
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function setWho(s: Inspector, who: Who): void {
|
|
63
|
+
s.who = who
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function startProgress(s: Inspector, title: string): void {
|
|
67
|
+
s.progress = { title, items: [] }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A browser step started (or a case): the previous running one, if any, counts as done. */
|
|
71
|
+
export function stepStarted(s: Inspector, label: string): void {
|
|
72
|
+
for (const i of s.progress.items) if (i.status === "running") i.status = "ok"
|
|
73
|
+
s.progress.items.push({ label, status: "running" })
|
|
74
|
+
if (s.progress.items.length > MAX_PROGRESS) s.progress.items.splice(0, s.progress.items.length - MAX_PROGRESS)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The outcome of the running step: a failed action marks it, a later success does not clear a failure. */
|
|
78
|
+
export function stepOutcome(s: Inspector, ok: boolean, detail?: string): void {
|
|
79
|
+
const running = [...s.progress.items].reverse().find((i) => i.status === "running" || i.status === "fail")
|
|
80
|
+
if (!running) return
|
|
81
|
+
if (!ok) {
|
|
82
|
+
running.status = "fail"
|
|
83
|
+
if (detail) running.detail = detail
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every step still running is over: done, or stopped when the run was. */
|
|
88
|
+
export function progressDone(s: Inspector, stopped = false): void {
|
|
89
|
+
for (const i of s.progress.items) if (i.status === "running") i.status = stopped ? "skipped" : "ok"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function caseStarted(s: Inspector, id: string, title: string): void {
|
|
93
|
+
s.progress.items.push({ label: `${id} ${title}`, status: "running" })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function caseDone(s: Inspector, id: string, status: "passed" | "failed" | "error" | "stopped", detail?: string): void {
|
|
97
|
+
const item = s.progress.items.find((i) => i.label.startsWith(`${id} `) && i.status === "running")
|
|
98
|
+
if (!item) return
|
|
99
|
+
item.status = status === "passed" ? "ok" : status === "stopped" ? "skipped" : "fail"
|
|
100
|
+
if (detail) item.detail = detail
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The files, most recently used first. */
|
|
104
|
+
export function filesByUse(s: Inspector): FileUse[] {
|
|
105
|
+
return [...s.files.values()].sort((a, b) => b.order - a.order)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Which pane a transcript line belongs in: the conversation, or what the agents are doing. */
|
|
109
|
+
export type Pane = "chat" | "activity"
|
|
110
|
+
const CHAT_KINDS = new Set(["user", "reply", "card", "summary", "suitesummary"])
|
|
111
|
+
|
|
112
|
+
/** Results and the conversation go to the chat; while a request runs, everything else is activity. */
|
|
113
|
+
export function paneFor(kind: string, running: boolean): Pane {
|
|
114
|
+
return CHAT_KINDS.has(kind) || !running ? "chat" : "activity"
|
|
115
|
+
}
|
package/src/tui/text.ts
CHANGED
|
@@ -7,6 +7,11 @@ export function clip(text: string, n = 72): string {
|
|
|
7
7
|
return s.length <= n ? s : `${s.slice(0, n - 1)}…`
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/** "…/pages/LoginPage.ts": a path cut from the start, since its end tells files apart. */
|
|
11
|
+
export function tailCut(text: string, n: number): string {
|
|
12
|
+
return text.length <= n ? text : `…${text.slice(text.length - n + 1)}`
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
export function modelLabel(cfg: AppConfig): string {
|
|
11
16
|
if (cfg.llm.provider === "none") return "none set up"
|
|
12
17
|
if (cfg.llm.provider === "mock") return "offline"
|
package/src/tui/transcript.tsx
CHANGED
|
@@ -20,6 +20,8 @@ export type Line = {
|
|
|
20
20
|
suite?: SuiteResult
|
|
21
21
|
card?: Card
|
|
22
22
|
outcome?: "done" | "failed" | "needs_input"
|
|
23
|
+
/** Which side of the split view shows it: the conversation, or what the agents are doing. */
|
|
24
|
+
pane?: "chat" | "activity"
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/** What one request produced: browser runs with their files, suites, and the tests the coding agent wrote. */
|
package/src/types.ts
CHANGED
|
@@ -127,7 +127,7 @@ export type PlanItem = { text: string; status: "pending" | "in_progress" | "comp
|
|
|
127
127
|
export type CodeEvent =
|
|
128
128
|
| { type: "code"; kind: "info"; text: string }
|
|
129
129
|
/** A finished tool call: `tool` is its name, and `file` the file it read or changed, relative to the repository. */
|
|
130
|
-
| { type: "code"; kind: "tool"; text: string; ok: boolean; tool?: string; file?: { path: string; action: FileAction } }
|
|
130
|
+
| { type: "code"; kind: "tool"; text: string; ok: boolean; tool?: string; file?: { path: string; action: FileAction }; skill?: string }
|
|
131
131
|
| { type: "code"; kind: "say"; text: string }
|
|
132
132
|
| { type: "code"; kind: "test"; text: string; ok: boolean; attempt: number }
|
|
133
133
|
/** The session's usage so far; `context` is the last model call's input, how full its context window is. */
|