@capagents/forgeqe 1.0.1 → 1.0.3
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/mcp/external.ts +7 -0
- package/src/tui/App.tsx +246 -42
- package/src/tui/components.tsx +16 -12
- package/src/tui/inspector.tsx +348 -0
- package/src/tui/session.ts +123 -0
- package/src/tui/text.ts +5 -0
- package/src/tui/theme.ts +2 -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/mcp/external.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type ExternalServer = {
|
|
|
15
15
|
config: Record<string, unknown>
|
|
16
16
|
/** Values ForgeQE derives from its environment (e.g. base64 of email:token) for {env:} references in config. */
|
|
17
17
|
secrets: Record<string, string>
|
|
18
|
+
/** How the server signs in, in a few words, for the shell and the agent: "PAT in ADO_PAT", "Azure CLI sign-in". */
|
|
19
|
+
signIn?: string
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export type ExternalPlan = { servers: ExternalServer[]; skipped: { label: string; why: string }[] }
|
|
@@ -64,11 +66,16 @@ export function externalMcp(cfg: AppConfig, which: Which = Bun.which, env: NodeJ
|
|
|
64
66
|
else if (!which("npx")) skipped.push({ label, why: "it needs Node.js (npx)" })
|
|
65
67
|
else if (!auth) skipped.push({ label, why: `set ${ado.tokenEnv}, or sign in with az login` })
|
|
66
68
|
else {
|
|
69
|
+
const signIn = auth === "pat" ? `PAT in ${ado.tokenEnv}` : "Azure CLI sign-in"
|
|
67
70
|
servers.push({
|
|
68
71
|
name: "azure_devops",
|
|
69
72
|
label,
|
|
73
|
+
signIn,
|
|
70
74
|
about:
|
|
71
75
|
`Microsoft's Azure DevOps MCP server: organization ${org}${ado.project ? `, project ${ado.project}` : ", every project"} (work items, boards, test plans, pipelines, repos, wiki pages, search across code, wikis and work items, security alerts). ` +
|
|
76
|
+
(auth === "pat"
|
|
77
|
+
? `It signs in with the personal access token in ${ado.tokenEnv}. `
|
|
78
|
+
: `${ado.tokenEnv} is not set, so it signs in as the Azure CLI's account (az login). When it answers TF400813 or "not authorized", that account has no access to ${org}: say so, and that setting ${ado.tokenEnv} (in .env, or /auth azure_devops with a token) or adding that account to the organization fixes it. `) +
|
|
72
79
|
"Always pass project to its tools that take one: without it the server asks the client to pick a project, which this session cannot answer, so the call fails. " +
|
|
73
80
|
"Its wiki tools only work in a wiki that exists and cannot create one; write wiki pages with ForgeQE's write_ado_wiki_page, which creates the project wiki when there is none. " +
|
|
74
81
|
(ado.project
|
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 { ActivityPanel, ContextPanel, FilesPanel, PlanPanel, planRows, SessionPanel, StatusBar, TokensPanel, 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,13 @@ 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
|
+
// The action itself, without the result after a colon or the locator in brackets: "click "Search"".
|
|
492
|
+
const action = event.text.split(/: | {2}\[/)[0]!.trim()
|
|
493
|
+
stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(event.text, 80), clip(action, 60))
|
|
494
|
+
bump()
|
|
495
|
+
}
|
|
429
496
|
return
|
|
430
497
|
}
|
|
431
498
|
case "run-done": {
|
|
@@ -450,17 +517,25 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
450
517
|
producedRef.current.runs.push({ runDir: e.runDir, status: r?.status || "fail", files: e.files })
|
|
451
518
|
setLastStats(summary.stats)
|
|
452
519
|
setBrowsing(false)
|
|
520
|
+
progressDone(inspRef.current, aborted())
|
|
521
|
+
setWho(inspRef.current, codingRef.current ? "Scribe" : "ForgeQE's agent")
|
|
522
|
+
bump()
|
|
453
523
|
if (agent && !aborted()) setActivity("The agent is reading the result…")
|
|
454
524
|
return
|
|
455
525
|
}
|
|
456
526
|
case "suite-start":
|
|
457
527
|
push("sys", `${e.cases.length} test case${e.cases.length === 1 ? "" : "s"}${e.parallel > 1 ? `, ${e.parallel} at a time` : ""}`)
|
|
458
528
|
setSuiteProgress({ done: 0, total: e.cases.length })
|
|
529
|
+
startProgress(inspRef.current, `${e.cases.length} test case${e.cases.length === 1 ? "" : "s"}`)
|
|
530
|
+
bump()
|
|
459
531
|
return
|
|
460
532
|
case "suite-event": {
|
|
461
533
|
const s = e.event
|
|
462
534
|
const tag = (id: string) => ((configRef.current?.suite.parallel || 1) > 1 ? `[${id}] ` : "")
|
|
463
535
|
if (s.type === "case-start") {
|
|
536
|
+
caseStarted(inspRef.current, s.case.id, s.case.title)
|
|
537
|
+
setWho(inspRef.current, "Browser agent")
|
|
538
|
+
bump()
|
|
464
539
|
push("code", `${s.index + 1}/${s.total} ${s.case.id} ${s.case.title}`)
|
|
465
540
|
// What was read from the test management system: where the case lives and the steps the browser will follow.
|
|
466
541
|
const steps = s.case.steps
|
|
@@ -476,6 +551,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
476
551
|
} else if (s.type === "case-done") {
|
|
477
552
|
setSuiteProgress((p) => (p ? { ...p, done: p.done + 1 } : p))
|
|
478
553
|
const r = s.result
|
|
554
|
+
caseDone(inspRef.current, r.case.id, r.status, r.failure ? FAILURE_LABEL[r.failure.type] : undefined)
|
|
555
|
+
bump()
|
|
479
556
|
push(r.status === "passed" ? "ok" : "err", `${r.case.id} ${r.status} in ${formatDuration(r.ms)}${r.failure ? ` · ${FAILURE_LABEL[r.failure.type]}` : ""}`)
|
|
480
557
|
if (r.failure) push("sys", ` ${tag(r.case.id)}${clip(r.failure.reason, 200)} Next: ${FAILURE_ACTION[r.failure.type]}.`)
|
|
481
558
|
// Each step of the case with its verdict, and what the page showed for a step that did not pass.
|
|
@@ -508,6 +585,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
508
585
|
return
|
|
509
586
|
case "agent": {
|
|
510
587
|
const ev = e.event
|
|
588
|
+
inspect(ev)
|
|
589
|
+
if (ev.kind === "plan") return
|
|
511
590
|
if (ev.kind === "usage") useTokens(`agent-tool-${runIndex.current}`, ev.usage.input + ev.usage.output)
|
|
512
591
|
// Hidden lines (reads, searches, the agent's narration) still show on the activity line below.
|
|
513
592
|
else if (shows(ev, detailRef.current)) {
|
|
@@ -527,6 +606,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
527
606
|
codingRef.current = true
|
|
528
607
|
codeRunDir.current = e.runDir
|
|
529
608
|
setCoding(true)
|
|
609
|
+
setWho(inspRef.current, "Scribe")
|
|
610
|
+
bump()
|
|
530
611
|
const show = shortPath
|
|
531
612
|
pushLine({
|
|
532
613
|
kind: "card",
|
|
@@ -550,6 +631,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
550
631
|
case "code-done":
|
|
551
632
|
codingRef.current = false
|
|
552
633
|
setCoding(false)
|
|
634
|
+
setWho(inspRef.current, "ForgeQE's agent")
|
|
635
|
+
bump()
|
|
553
636
|
producedRef.current.tests.push({ runDir: codeRunDir.current, result: e.result, error: e.error })
|
|
554
637
|
if (e.error) push("err", `Coding agent: ${e.error}`)
|
|
555
638
|
if (agent && !aborted()) setActivity("The agent is reading the result…")
|
|
@@ -561,6 +644,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
561
644
|
}
|
|
562
645
|
|
|
563
646
|
const onBrainEvent = (e: CodeEvent) => {
|
|
647
|
+
inspect(e)
|
|
648
|
+
if (e.kind === "plan") return
|
|
564
649
|
if (e.kind === "usage") {
|
|
565
650
|
useTokens("agent", e.usage.input + e.usage.output)
|
|
566
651
|
return
|
|
@@ -600,6 +685,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
600
685
|
started.current = Date.now()
|
|
601
686
|
usedRef.current = new Map()
|
|
602
687
|
producedRef.current = emptyProduced()
|
|
688
|
+
newRequest(inspRef.current)
|
|
689
|
+
requests.current++
|
|
690
|
+
sessionStarted.current ??= Date.now()
|
|
691
|
+
bump()
|
|
603
692
|
setTokens(0)
|
|
604
693
|
setStatus("running")
|
|
605
694
|
setActivity(activityText)
|
|
@@ -607,6 +696,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
607
696
|
}
|
|
608
697
|
|
|
609
698
|
const finish = (finalStatus: string) => {
|
|
699
|
+
progressDone(inspRef.current, finalStatus === "stopped")
|
|
700
|
+
bump()
|
|
610
701
|
setStatus(finalStatus)
|
|
611
702
|
setActivity("")
|
|
612
703
|
setThinking(false)
|
|
@@ -671,21 +762,21 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
671
762
|
controller.signal.removeEventListener("abort", onAbort)
|
|
672
763
|
if (answer.outcome !== "stopped") convoRef.current.push({ user: text, reply: answer.reply })
|
|
673
764
|
if (answer.outcome === "stopped") {
|
|
674
|
-
push("sys", "Stopped.")
|
|
765
|
+
push("sys", "Stopped.", "chat")
|
|
675
766
|
finalStatus = "stopped"
|
|
676
767
|
} else {
|
|
677
768
|
if (answer.reply) pushLine({ kind: "reply", text: answer.reply, outcome: answer.outcome })
|
|
678
|
-
if (answer.error && !answer.reply) push("err", answer.error)
|
|
769
|
+
if (answer.error && !answer.reply) push("err", answer.error, "chat")
|
|
679
770
|
finalStatus = answer.outcome === "done" ? "done" : answer.outcome === "needs_input" ? "idle" : "fail"
|
|
680
771
|
}
|
|
681
772
|
} catch (err) {
|
|
682
|
-
push("err", (err as Error).message)
|
|
773
|
+
push("err", (err as Error).message, "chat")
|
|
683
774
|
closeBrain()
|
|
684
775
|
} finally {
|
|
685
776
|
pushProduced()
|
|
686
777
|
// What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
|
|
687
778
|
const spent = requestLine(meterStart)
|
|
688
|
-
if (spent) push("sys", spent)
|
|
779
|
+
if (spent) push("sys", spent, "chat")
|
|
689
780
|
finish(finalStatus)
|
|
690
781
|
}
|
|
691
782
|
}
|
|
@@ -737,6 +828,15 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
737
828
|
}, [])
|
|
738
829
|
|
|
739
830
|
const onSubmit = (value: string) => {
|
|
831
|
+
// What a command answers (/help, /skills, "busy") is for the chat, even while a request runs.
|
|
832
|
+
answeringRef.current = true
|
|
833
|
+
try {
|
|
834
|
+
submit(value)
|
|
835
|
+
} finally {
|
|
836
|
+
answeringRef.current = false
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
const submit = (value: string) => {
|
|
740
840
|
const text = value.replace(/\t/g, "").trim()
|
|
741
841
|
setInputKey((k) => k + 1)
|
|
742
842
|
const pending = entryRef.current
|
|
@@ -748,8 +848,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
748
848
|
return
|
|
749
849
|
}
|
|
750
850
|
if (!text) return
|
|
851
|
+
// Setup notes come before the request, so they do not read as its answer; the split view shows them in its session panel.
|
|
852
|
+
const notes = priceNotesRef.current.splice(0)
|
|
853
|
+
if (width < SPLIT_MIN) for (const w of notes) push("sys", `Price check: ${w}`, "chat")
|
|
751
854
|
push("user", text)
|
|
752
|
-
for (const w of priceNotesRef.current.splice(0)) push("sys", `Price check: ${w}`)
|
|
753
855
|
const cmd = parseCommand(text)
|
|
754
856
|
if (cmd.type === "help") {
|
|
755
857
|
push("sys", HELP)
|
|
@@ -864,17 +966,24 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
864
966
|
|
|
865
967
|
const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
|
|
866
968
|
const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
|
|
969
|
+
// The split view's right pane shows tokens, time, and the last result, so the hints under the prompt leave them out.
|
|
970
|
+
const wide = width >= SPLIT_MIN
|
|
971
|
+
const viewHint: Hint[] = wide ? [] : [{ key: "ctrl+o", label: "details", onPress: () => setNarrowView("details") }]
|
|
972
|
+
// While running, the row also holds the spinner and esc stop: below 95 columns the view hint does not fit beside them.
|
|
973
|
+
const runningViewHint: Hint[] = width >= 95 ? viewHint : []
|
|
867
974
|
const idleHints: Hint[] = [
|
|
868
975
|
...copiedHint,
|
|
869
|
-
...(lastStats ? [{ key: `last run ${lastStats}`, label: "" }] : []),
|
|
976
|
+
...(lastStats && !wide ? [{ key: `last run ${lastStats}`, label: "" }] : []),
|
|
977
|
+
...viewHint,
|
|
870
978
|
{ key: "tab", label: "profiles" },
|
|
871
979
|
{ key: "ctrl+p", label: "commands" },
|
|
872
980
|
closeHint,
|
|
873
981
|
]
|
|
874
982
|
const runningHints: Hint[] = [
|
|
875
983
|
...copiedHint,
|
|
876
|
-
{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" },
|
|
877
|
-
|
|
984
|
+
...(wide ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" }, ...runningViewHint]),
|
|
985
|
+
// Beside the 40% pane's spinner and esc stop, only close fits from 120 columns.
|
|
986
|
+
...(wide ? [] : [{ key: "ctrl+p", label: "commands" }]),
|
|
878
987
|
closeHint,
|
|
879
988
|
]
|
|
880
989
|
const runningLeft = (
|
|
@@ -920,6 +1029,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
920
1029
|
model={model}
|
|
921
1030
|
right={right}
|
|
922
1031
|
rightColor={rightColor}
|
|
1032
|
+
compact={width >= SPLIT_MIN && !home}
|
|
923
1033
|
/>
|
|
924
1034
|
<Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
|
|
925
1035
|
</>
|
|
@@ -946,37 +1056,131 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
946
1056
|
)
|
|
947
1057
|
}
|
|
948
1058
|
|
|
1059
|
+
// What the split view shows about the session and the request in progress.
|
|
1060
|
+
const cfgNow = configRef.current
|
|
1061
|
+
const insp = inspRef.current
|
|
1062
|
+
const spent = meterSince(sessionMeter.current)
|
|
1063
|
+
const contextKey = cfgNow ? `${cfgNow.configPath}|${cfgNow.activeProfile}|${cfgNow.knowledge?.dir}` : ""
|
|
1064
|
+
if (cfgNow && contextInfo.current.key !== contextKey) {
|
|
1065
|
+
let mcp: string[] = []
|
|
1066
|
+
try {
|
|
1067
|
+
// "Azure DevOps (az login)": which account a server works as decides what it may see, so the panel says it.
|
|
1068
|
+
mcp = externalMcp(cfgNow).servers.map((x) => (x.signIn ? `${x.label.replace(/ MCP$/, "")} (${x.signIn.startsWith("PAT") ? "PAT" : "az login"})` : x.label))
|
|
1069
|
+
} catch {}
|
|
1070
|
+
contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
|
|
1071
|
+
}
|
|
1072
|
+
const llm = cfgNow?.llm
|
|
1073
|
+
const sessionInfo: SessionInfo = {
|
|
1074
|
+
profile,
|
|
1075
|
+
model: model || (cfgNow ? modelLabel(cfgNow) : ""),
|
|
1076
|
+
auth: !llm ? "–" : llm.auth === "entra" ? "Entra ID, no key" : llm.apiKeyEnv ? `API key in ${llm.apiKeyEnv}` : llm.provider === "mock" ? "offline demo" : llm.provider,
|
|
1077
|
+
cwd: shortCwd(),
|
|
1078
|
+
tests: testsRef.current.on ? "written to this folder" : requests.current ? `not written (${testsRef.current.why || "off"})` : "set on the first request",
|
|
1079
|
+
browser: `${headed ? "visible" : "headless"} · up to ${maxSteps} steps${options.url ? ` · ${hostOf(options.url)}` : ""}`,
|
|
1080
|
+
...(cfgNow?.domain ? { domain: `${cfgNow.domain.industry}${cfgNow.domain.regulations.length ? ` (${cfgNow.domain.regulations.join(", ")})` : ""}` } : {}),
|
|
1081
|
+
...(llm?.priceWarning && llm.price ? { price: `${llm.price.input}/${llm.price.output} ${llm.price.currency} per M in/out from llms.json, not the list price` } : {}),
|
|
1082
|
+
started: sessionStarted.current,
|
|
1083
|
+
requests: requests.current,
|
|
1084
|
+
total: spent.roles.length ? { usage: spent.total, cost: spent.cost } : undefined,
|
|
1085
|
+
}
|
|
1086
|
+
const contextPanel: ContextInfo = {
|
|
1087
|
+
tokens: insp.context,
|
|
1088
|
+
limit: CONTEXT_LIMIT,
|
|
1089
|
+
skills: insp.skills,
|
|
1090
|
+
knowledge: contextInfo.current.knowledge,
|
|
1091
|
+
...(cfgNow?.domain ? { domain: cfgNow.domain.industry } : {}),
|
|
1092
|
+
mcp: contextInfo.current.mcp,
|
|
1093
|
+
capabilities: CAPABILITIES.length,
|
|
1094
|
+
}
|
|
1095
|
+
const placeholder = running
|
|
1096
|
+
? coding
|
|
1097
|
+
? "The coding agent is writing a test · esc to stop"
|
|
1098
|
+
: `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
|
|
1099
|
+
: "Ask ForgeQE: a goal, test cases, a test, a pipeline…"
|
|
1100
|
+
const chatLines = lines.filter((l) => l.pane !== "activity")
|
|
1101
|
+
const activityLines = lines.filter((l) => l.pane === "activity")
|
|
1102
|
+
const stop = () => openDialog("stop")
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* The right pane: the status bar, then bordered panels for the plan, the activity, and files, context, and tokens.
|
|
1106
|
+
* Rows are counted, not left to flex: the activity gets what the others leave, so nothing is pushed off screen.
|
|
1107
|
+
*/
|
|
1108
|
+
const details = (paneWidth: number, rowsAvailable: number) => {
|
|
1109
|
+
const bottom = 6 + 2 // the context panel's six rows, and the border
|
|
1110
|
+
const plan = planRows(insp, Math.max(3, Math.min(8, Math.floor(rowsAvailable / 5))))
|
|
1111
|
+
const activityRows = Math.max(5, rowsAvailable - 2 - plan - bottom)
|
|
1112
|
+
const third = Math.floor(paneWidth / 3)
|
|
1113
|
+
return (
|
|
1114
|
+
<box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
|
|
1115
|
+
<StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} />
|
|
1116
|
+
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1117
|
+
{plan ? <PlanPanel s={insp} width={paneWidth} height={plan} /> : null}
|
|
1118
|
+
<ActivityPanel lines={activityLines} scrollRef={activityScroll} width={paneWidth} height={activityRows} />
|
|
1119
|
+
<box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
|
|
1120
|
+
<FilesPanel s={insp} width={third} height={bottom} />
|
|
1121
|
+
<ContextPanel c={contextPanel} width={third} height={bottom} />
|
|
1122
|
+
<TokensPanel roles={spent.roles} total={{ usage: spent.total, cost: spent.cost }} live={running ? tokens : 0} width={paneWidth - 2 * third} height={bottom} />
|
|
1123
|
+
</box>
|
|
1124
|
+
</box>
|
|
1125
|
+
)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const transcript = (all: Line[], style: Record<string, unknown>) => (
|
|
1129
|
+
<scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
|
|
1130
|
+
{all.map((line) => (
|
|
1131
|
+
<TranscriptLine key={line.id} line={line} />
|
|
1132
|
+
))}
|
|
1133
|
+
{running && !split ? (
|
|
1134
|
+
<box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
|
|
1135
|
+
<Spinner kind="dots" />
|
|
1136
|
+
<text fg={theme.text}>{` ${activity || "Working…"}`}</text>
|
|
1137
|
+
</box>
|
|
1138
|
+
) : null}
|
|
1139
|
+
</scrollbox>
|
|
1140
|
+
)
|
|
1141
|
+
const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.min(10, Math.floor(height / 3)))])
|
|
1142
|
+
|
|
1143
|
+
const split = width >= SPLIT_MIN
|
|
1144
|
+
if (split) {
|
|
1145
|
+
const leftWidth = Math.floor(width * 0.4)
|
|
1146
|
+
return (
|
|
1147
|
+
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1148
|
+
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1149
|
+
<box style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1150
|
+
<SessionPanel info={sessionInfo} width={leftWidth - 3} />
|
|
1151
|
+
{transcript(chatLines, {})}
|
|
1152
|
+
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
|
|
1153
|
+
</box>
|
|
1154
|
+
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1155
|
+
{details(width - leftWidth - 3, height - 2)}
|
|
1156
|
+
</box>
|
|
1157
|
+
</box>
|
|
1158
|
+
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1159
|
+
</box>
|
|
1160
|
+
)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
949
1163
|
const promptWidth = Math.max(40, width - 4)
|
|
1164
|
+
if (narrowView === "details") {
|
|
1165
|
+
return (
|
|
1166
|
+
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1167
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>{details(promptWidth, height - 3 - (dialog ? 8 : 0))}</box>
|
|
1168
|
+
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
|
|
1169
|
+
{dialog ? (
|
|
1170
|
+
// Stop, a question, or "your turn": the same panel the chat view shows in place of the prompt.
|
|
1171
|
+
controls(promptWidth)
|
|
1172
|
+
) : (
|
|
1173
|
+
<Hints width={promptWidth} items={[{ key: "ctrl+o", label: "back to the chat", onPress: () => setNarrowView("chat") }, closeHint]} />
|
|
1174
|
+
)}
|
|
1175
|
+
</box>
|
|
1176
|
+
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1177
|
+
</box>
|
|
1178
|
+
)
|
|
1179
|
+
}
|
|
950
1180
|
return (
|
|
951
1181
|
<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>
|
|
1182
|
+
{transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 })}
|
|
1183
|
+
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>{controls(promptWidth)}</box>
|
|
980
1184
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
981
1185
|
</box>
|
|
982
1186
|
)
|
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
|
+
/** Beside a session panel that names the model and profile, and a status bar: no bottom row. */
|
|
52
|
+
compact?: boolean
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** Enter sends; Shift+Enter, Alt+Enter or Ctrl+J start a new line. */
|
|
@@ -106,18 +108,20 @@ export function Prompt(props: PromptProps) {
|
|
|
106
108
|
placeholderColor={theme.faint}
|
|
107
109
|
/>
|
|
108
110
|
)}
|
|
109
|
-
|
|
110
|
-
<
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
111
|
+
{props.compact ? null : (
|
|
112
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
|
|
113
|
+
<text>
|
|
114
|
+
<span fg={theme.muted}>{"Model "}</span>
|
|
115
|
+
<span fg={theme.text}>{props.model || "…"}</span>
|
|
116
|
+
</text>
|
|
117
|
+
<text>
|
|
118
|
+
{props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
|
|
119
|
+
{props.right ? <span fg={theme.faint}>{" · "}</span> : null}
|
|
120
|
+
<span fg={theme.muted}>{"profile "}</span>
|
|
121
|
+
<span fg={theme.text}>{props.profile || "none"}</span>
|
|
122
|
+
</text>
|
|
123
|
+
</box>
|
|
124
|
+
)}
|
|
121
125
|
</box>
|
|
122
126
|
<text>
|
|
123
127
|
<span fg={props.modeColor}>{edge.slice(0, 1)}</span>
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import type { ReactNode, 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 type { Line } from "./transcript.tsx"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The split view's panels. Each is a rounded box with its title in the border, in its section's colour from the
|
|
14
|
+
* shell's palette; status colours keep their meaning inside (ok done, warn running or attention, err failed).
|
|
15
|
+
*/
|
|
16
|
+
export const SECTION = {
|
|
17
|
+
session: theme.accent,
|
|
18
|
+
plan: theme.thought,
|
|
19
|
+
activity: theme.accent,
|
|
20
|
+
files: theme.memory,
|
|
21
|
+
context: theme.warn,
|
|
22
|
+
tokens: theme.ok,
|
|
23
|
+
}
|
|
24
|
+
|
|
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
|
+
|
|
33
|
+
/** Rows a panel's border takes: top and bottom. */
|
|
34
|
+
export const PANEL_CHROME = 2
|
|
35
|
+
|
|
36
|
+
/** A titled, rounded panel; `width` and `height` include the border. */
|
|
37
|
+
export function Panel(props: { title: string; color: string; width?: number; height?: number; children: ReactNode }) {
|
|
38
|
+
return (
|
|
39
|
+
<box
|
|
40
|
+
border
|
|
41
|
+
borderStyle="rounded"
|
|
42
|
+
borderColor={theme.border}
|
|
43
|
+
title={` ${props.title} `}
|
|
44
|
+
titleColor={props.color}
|
|
45
|
+
style={{
|
|
46
|
+
flexDirection: "column",
|
|
47
|
+
paddingLeft: 1,
|
|
48
|
+
paddingRight: 1,
|
|
49
|
+
flexShrink: 0,
|
|
50
|
+
...(props.width !== undefined ? { width: props.width } : {}),
|
|
51
|
+
...(props.height !== undefined ? { height: props.height } : {}),
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{props.children}
|
|
55
|
+
</box>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type SessionInfo = {
|
|
60
|
+
profile: string
|
|
61
|
+
model: string
|
|
62
|
+
auth: string
|
|
63
|
+
cwd: string
|
|
64
|
+
tests: string
|
|
65
|
+
browser: string
|
|
66
|
+
domain?: string
|
|
67
|
+
/** The profile's price is set in llms.json and differs from the model's list price. */
|
|
68
|
+
price?: string
|
|
69
|
+
started?: number
|
|
70
|
+
requests: number
|
|
71
|
+
total?: { usage: Usage; cost?: Cost }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Rows SessionPanel takes, border included. */
|
|
75
|
+
export function sessionRows(info: SessionInfo): number {
|
|
76
|
+
return 6 + (info.domain ? 1 : 0) + (info.price ? 1 : 0) + PANEL_CHROME
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Top of the left pane: what this session runs with, and what it has used. */
|
|
80
|
+
export function SessionPanel({ info, width }: { info: SessionInfo; width: number }) {
|
|
81
|
+
const room = Math.max(10, width - 14)
|
|
82
|
+
const row = (label: string, value: string, color: string = theme.text) => (
|
|
83
|
+
<text key={label}>
|
|
84
|
+
<span fg={theme.muted}>{label.padEnd(9)}</span>
|
|
85
|
+
<span fg={color}>{clip(value, room)}</span>
|
|
86
|
+
</text>
|
|
87
|
+
)
|
|
88
|
+
const started = info.started ? new Date(info.started) : null
|
|
89
|
+
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
|
+
return (
|
|
92
|
+
<Panel title={`Session${since}`} color={SECTION.session} width={width}>
|
|
93
|
+
{row("Model", `${info.profile || "none"} · ${info.model || "…"}`)}
|
|
94
|
+
{row("Sign-in", info.auth, info.auth.startsWith("Entra") ? theme.ok : theme.text)}
|
|
95
|
+
{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
|
+
{info.domain ? row("Domain", info.domain, theme.memory) : null}
|
|
99
|
+
{info.price ? row("Price", info.price, theme.warn) : null}
|
|
100
|
+
{row("Used", `${info.requests} request${info.requests === 1 ? "" : "s"} · ${used}`, theme.muted)}
|
|
101
|
+
</Panel>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The right pane's first line: who is working on what and for how long, and Stop while something runs. */
|
|
106
|
+
export function StatusBar(props: { running: boolean; who: string; activity: string; elapsed: string; status: string; width: number; onStop: () => void }) {
|
|
107
|
+
const done = props.status === "done"
|
|
108
|
+
const failed = props.status === "fail"
|
|
109
|
+
const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
|
|
110
|
+
const what = props.running
|
|
111
|
+
? props.activity || "working…"
|
|
112
|
+
: done
|
|
113
|
+
? "Last request done"
|
|
114
|
+
: failed
|
|
115
|
+
? "Last request failed"
|
|
116
|
+
: props.status === "stopped"
|
|
117
|
+
? "Last request stopped"
|
|
118
|
+
: "Ready for your next request"
|
|
119
|
+
const room = Math.max(10, props.width - props.who.length - (props.running ? 30 : 6))
|
|
120
|
+
return (
|
|
121
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
|
|
122
|
+
<box style={{ flexDirection: "row" }}>
|
|
123
|
+
{props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
|
|
124
|
+
<text>
|
|
125
|
+
{props.running ? <span fg={theme.text}>{` ${props.who}`}</span> : null}
|
|
126
|
+
<span fg={props.running ? theme.muted : color}>{`${props.running ? " " : " "}${clip(what, room)}`}</span>
|
|
127
|
+
</text>
|
|
128
|
+
</box>
|
|
129
|
+
{props.running ? (
|
|
130
|
+
<box style={{ flexDirection: "row" }}>
|
|
131
|
+
<text fg={theme.faint}>{`${props.elapsed} `}</text>
|
|
132
|
+
<box onMouseDown={props.onStop} style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.warn }}>
|
|
133
|
+
<text fg={theme.onButton}>{"■ Stop · esc"}</text>
|
|
134
|
+
</box>
|
|
135
|
+
</box>
|
|
136
|
+
) : null}
|
|
137
|
+
</box>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const PLAN_MARK: Record<string, [string, string]> = {
|
|
142
|
+
completed: ["✓", theme.ok],
|
|
143
|
+
in_progress: ["◐", theme.warn],
|
|
144
|
+
pending: ["○", theme.faint],
|
|
145
|
+
cancelled: ["✗", theme.faint],
|
|
146
|
+
}
|
|
147
|
+
const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
|
|
148
|
+
ok: ["✓", theme.ok],
|
|
149
|
+
running: ["◐", theme.warn],
|
|
150
|
+
fail: ["✗", theme.err],
|
|
151
|
+
skipped: ["■", theme.faint],
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type PlanLine = { mark: string; color: string; text: string; textColor: string }
|
|
155
|
+
|
|
156
|
+
function planLines(s: Inspector): PlanLine[] {
|
|
157
|
+
const lines: PlanLine[] = []
|
|
158
|
+
for (const p of s.plan) {
|
|
159
|
+
const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
|
|
160
|
+
lines.push({ mark, color, text: p.text, textColor: p.status === "completed" || p.status === "cancelled" ? theme.muted : theme.text })
|
|
161
|
+
}
|
|
162
|
+
if (s.progress.items.length && s.plan.length) lines.push({ mark: "─", color: theme.border, text: s.progress.title, textColor: theme.faint })
|
|
163
|
+
for (const i of s.progress.items) {
|
|
164
|
+
const [mark, color] = PROGRESS_MARK[i.status]
|
|
165
|
+
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 })
|
|
166
|
+
}
|
|
167
|
+
return lines
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Rows the plan panel takes, border included; 0 while there is nothing to plan or follow. */
|
|
171
|
+
export function planRows(s: Inspector, cap: number): number {
|
|
172
|
+
const n = planLines(s).length
|
|
173
|
+
return n ? Math.min(cap, n) + PANEL_CHROME : 0
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The agent's to-do list, then the browser steps or suite cases; the latest that fit. */
|
|
177
|
+
export function PlanPanel({ s, width, height }: { s: Inspector; width: number; height: number }) {
|
|
178
|
+
const lines = planLines(s)
|
|
179
|
+
const done = s.plan.filter((p) => p.status === "completed").length
|
|
180
|
+
const count = s.plan.length ? ` · ${done} of ${s.plan.length}` : s.progress.items.length ? ` · ${clip(s.progress.title, 40)}` : ""
|
|
181
|
+
const shown = lines.slice(-Math.max(1, height - PANEL_CHROME))
|
|
182
|
+
return (
|
|
183
|
+
<Panel title={`Plan${count}`} color={SECTION.plan} width={width} height={height}>
|
|
184
|
+
{shown.map((l, i) => (
|
|
185
|
+
<text key={i}>
|
|
186
|
+
<span fg={l.color}>{`${l.mark} `}</span>
|
|
187
|
+
<span fg={l.textColor}>{clip(l.text, Math.max(10, width - 8))}</span>
|
|
188
|
+
</text>
|
|
189
|
+
))}
|
|
190
|
+
</Panel>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Each kind of activity line: its mark, the mark's colour, and the text's colour. */
|
|
195
|
+
const ACTIVITY_STYLE: Record<string, [string, string, string]> = {
|
|
196
|
+
tool: ["⚙", theme.faint, theme.muted],
|
|
197
|
+
file: ["✎", theme.accent, theme.text],
|
|
198
|
+
code: ["▸", theme.memory, theme.memory],
|
|
199
|
+
timing: ["+", theme.warn, theme.warn],
|
|
200
|
+
memory: ["↻", theme.memory, theme.memory],
|
|
201
|
+
thought: [" ", theme.thought, theme.thought],
|
|
202
|
+
ok: ["✓", theme.ok, theme.text],
|
|
203
|
+
err: ["✗", theme.err, theme.text],
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** One activity line, its mark in a column of its own so wrapped text stays under the text. */
|
|
207
|
+
export function ActivityRow({ line }: { line: Line }) {
|
|
208
|
+
const [mark, markColor, textColor] = ACTIVITY_STYLE[line.kind] || ["·", theme.faint, theme.muted]
|
|
209
|
+
return (
|
|
210
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
211
|
+
<text fg={markColor} style={{ width: 2, flexShrink: 0 }}>
|
|
212
|
+
{mark}
|
|
213
|
+
</text>
|
|
214
|
+
<text fg={textColor} style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
215
|
+
{line.text || " "}
|
|
216
|
+
</text>
|
|
217
|
+
</box>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Everything the agents do, newest at the bottom. */
|
|
222
|
+
export function ActivityPanel(props: { lines: Line[]; scrollRef: RefObject<ScrollBoxRenderable | null>; width: number; height: number }) {
|
|
223
|
+
return (
|
|
224
|
+
<Panel title={`Activity${props.lines.length ? ` · ${props.lines.length}` : ""}`} color={SECTION.activity} width={props.width} height={props.height}>
|
|
225
|
+
{props.lines.length ? (
|
|
226
|
+
<scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
|
|
227
|
+
{props.lines.map((line) => (
|
|
228
|
+
<ActivityRow key={line.id} line={line} />
|
|
229
|
+
))}
|
|
230
|
+
</scrollbox>
|
|
231
|
+
) : (
|
|
232
|
+
<box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
|
|
233
|
+
<text fg={theme.faint}>{clip("Tool calls, browser steps, and the agents' reasoning show here", Math.max(10, props.width - 4))}</text>
|
|
234
|
+
</box>
|
|
235
|
+
)}
|
|
236
|
+
</Panel>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const FILE_MARK: Record<FileUse["last"], [string, string]> = { read: ["◉", theme.accent], write: ["✚", theme.ok], edit: ["✎", theme.warn] }
|
|
241
|
+
|
|
242
|
+
/** "PayBeneficiaryTests.cs tests/Payments": the name first, its folder after it, dimmed. */
|
|
243
|
+
function fileLabel(path: string, room: number): [string, string] {
|
|
244
|
+
const cut = path.lastIndexOf("/")
|
|
245
|
+
const name = cut < 0 ? path : path.slice(cut + 1)
|
|
246
|
+
const dir = cut < 0 ? "" : path.slice(0, cut)
|
|
247
|
+
if (name.length >= room - 2) return [clip(name, room), ""]
|
|
248
|
+
// A folder cut to a few characters says nothing: leave it out rather than show "…ts".
|
|
249
|
+
const space = room - name.length - 2
|
|
250
|
+
return [name, dir && (dir.length <= space || space >= 12) ? tailCut(dir, space) : ""]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function FilesPanel({ s, width, height }: { s: Inspector; width: number; height: number }) {
|
|
254
|
+
const files = filesByUse(s)
|
|
255
|
+
const rows = height - PANEL_CHROME
|
|
256
|
+
const room = Math.max(8, width - 10)
|
|
257
|
+
const listed = files.length > rows ? files.slice(0, rows - 1) : files
|
|
258
|
+
return (
|
|
259
|
+
<Panel title={`Files${files.length ? ` · ${files.length}` : ""}`} color={SECTION.files} width={width} height={height}>
|
|
260
|
+
{files.length ? (
|
|
261
|
+
listed.map((f) => {
|
|
262
|
+
const [mark, color] = FILE_MARK[f.last]
|
|
263
|
+
const [name, dir] = fileLabel(f.path, room)
|
|
264
|
+
const times = f.reads + f.changes
|
|
265
|
+
return (
|
|
266
|
+
<text key={f.path}>
|
|
267
|
+
<span fg={color}>{`${mark} `}</span>
|
|
268
|
+
<span fg={f.changes ? theme.text : theme.muted}>{name}</span>
|
|
269
|
+
{dir ? <span fg={theme.faint}>{` ${dir}`}</span> : null}
|
|
270
|
+
{times > 1 ? <span fg={theme.faint}>{` ×${times}`}</span> : null}
|
|
271
|
+
</text>
|
|
272
|
+
)
|
|
273
|
+
})
|
|
274
|
+
) : (
|
|
275
|
+
<text fg={theme.faint}>{"None yet"}</text>
|
|
276
|
+
)}
|
|
277
|
+
{files.length > listed.length ? <text fg={theme.faint}>{`+${files.length - listed.length} more`}</text> : null}
|
|
278
|
+
</Panel>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export type ContextInfo = { tokens: number; limit: number; skills: string[]; knowledge: number; domain?: string; mcp: string[]; capabilities: number }
|
|
283
|
+
|
|
284
|
+
/** How full the model's context window is, and what ForgeQE put into it. */
|
|
285
|
+
export function ContextPanel({ c, width, height }: { c: ContextInfo; width: number; height: number }) {
|
|
286
|
+
const inner = Math.max(6, width - 4)
|
|
287
|
+
const share = c.limit ? Math.min(1, c.tokens / c.limit) : 0
|
|
288
|
+
const filled = Math.round(share * inner)
|
|
289
|
+
const color = share < 0.6 ? theme.ok : share < 0.85 ? theme.warn : theme.err
|
|
290
|
+
const room = Math.max(6, inner - 11)
|
|
291
|
+
const row = (label: string, value: string, valueColor: string = theme.text) => (
|
|
292
|
+
<text key={label}>
|
|
293
|
+
<span fg={theme.muted}>{label.padEnd(11)}</span>
|
|
294
|
+
<span fg={valueColor}>{clip(value, room)}</span>
|
|
295
|
+
</text>
|
|
296
|
+
)
|
|
297
|
+
return (
|
|
298
|
+
<Panel title={`Context · ${Math.round(share * 100)}%`} color={SECTION.context} width={width} height={height}>
|
|
299
|
+
<text>
|
|
300
|
+
<span fg={color}>{"█".repeat(filled)}</span>
|
|
301
|
+
<span fg={theme.border}>{"█".repeat(inner - filled)}</span>
|
|
302
|
+
</text>
|
|
303
|
+
<text fg={theme.muted}>{`${formatTokens(c.tokens)} of ${formatTokens(c.limit)} tokens`}</text>
|
|
304
|
+
{row("Skills", c.skills.length ? c.skills.join(", ") : "none loaded", c.skills.length ? theme.ok : theme.faint)}
|
|
305
|
+
{row("Knowledge", c.knowledge ? `${c.knowledge} product file${c.knowledge === 1 ? "" : "s"}` : "none", c.knowledge ? theme.text : theme.faint)}
|
|
306
|
+
{row("Domain", c.domain || "not declared", c.domain ? theme.memory : theme.faint)}
|
|
307
|
+
{row("MCP", c.mcp.length ? c.mcp.join(", ") : "none", c.mcp.length ? theme.text : theme.faint)}
|
|
308
|
+
</Panel>
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function TokensPanel(props: {
|
|
313
|
+
roles: { role: Role; usage: Usage; cost?: Cost }[]
|
|
314
|
+
total: { usage: Usage; cost?: Cost }
|
|
315
|
+
/** Tokens of the request in progress, counted as they stream; the roles above get them when each run ends. */
|
|
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) => (
|
|
325
|
+
<text key={label}>
|
|
326
|
+
<span fg={labelColor}>{clip(label, labelWidth).padEnd(labelWidth)}</span>
|
|
327
|
+
<span fg={valueColor}>{formatTokens(u.input + u.output).padStart(7)}</span>
|
|
328
|
+
{c ? <span fg={theme.muted}>{` ${formatCost(c)}`}</span> : null}
|
|
329
|
+
</text>
|
|
330
|
+
)
|
|
331
|
+
return (
|
|
332
|
+
<Panel title="Tokens" color={SECTION.tokens} width={width} height={props.height}>
|
|
333
|
+
{props.live ? (
|
|
334
|
+
<text>
|
|
335
|
+
<span fg={theme.warn}>{"This request".padEnd(labelWidth)}</span>
|
|
336
|
+
<span fg={theme.warn}>{formatTokens(props.live).padStart(7)}</span>
|
|
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}
|
|
346
|
+
</Panel>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
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; actions?: 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
|
+
/**
|
|
78
|
+
* An action of the running step: the step is then named by what it did ("Step 2 · click "Learn more""), which says
|
|
79
|
+
* more than the agent's reasoning. A failed action marks the step; a later success does not clear the failure.
|
|
80
|
+
*/
|
|
81
|
+
export function stepOutcome(s: Inspector, ok: boolean, detail?: string, action?: string): void {
|
|
82
|
+
const running = [...s.progress.items].reverse().find((i) => i.status === "running" || i.status === "fail")
|
|
83
|
+
if (!running) return
|
|
84
|
+
if (action) {
|
|
85
|
+
running.actions = [...(running.actions || []), action]
|
|
86
|
+
const step = /^Step \d+/.exec(running.label)?.[0]
|
|
87
|
+
if (step) running.label = `${step} · ${running.actions.slice(0, 2).join(", ")}${running.actions.length > 2 ? ` +${running.actions.length - 2}` : ""}`
|
|
88
|
+
}
|
|
89
|
+
if (!ok) {
|
|
90
|
+
running.status = "fail"
|
|
91
|
+
if (detail) running.detail = detail
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Every step still running is over: done, or stopped when the run was. */
|
|
96
|
+
export function progressDone(s: Inspector, stopped = false): void {
|
|
97
|
+
for (const i of s.progress.items) if (i.status === "running") i.status = stopped ? "skipped" : "ok"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function caseStarted(s: Inspector, id: string, title: string): void {
|
|
101
|
+
s.progress.items.push({ label: `${id} ${title}`, status: "running" })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function caseDone(s: Inspector, id: string, status: "passed" | "failed" | "error" | "stopped", detail?: string): void {
|
|
105
|
+
const item = s.progress.items.find((i) => i.label.startsWith(`${id} `) && i.status === "running")
|
|
106
|
+
if (!item) return
|
|
107
|
+
item.status = status === "passed" ? "ok" : status === "stopped" ? "skipped" : "fail"
|
|
108
|
+
if (detail) item.detail = detail
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The files, most recently used first. */
|
|
112
|
+
export function filesByUse(s: Inspector): FileUse[] {
|
|
113
|
+
return [...s.files.values()].sort((a, b) => b.order - a.order)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Which pane a transcript line belongs in: the conversation, or what the agents are doing. */
|
|
117
|
+
export type Pane = "chat" | "activity"
|
|
118
|
+
const CHAT_KINDS = new Set(["user", "reply", "card", "summary", "suitesummary"])
|
|
119
|
+
|
|
120
|
+
/** Results and the conversation go to the chat; while a request runs, everything else is activity. */
|
|
121
|
+
export function paneFor(kind: string, running: boolean): Pane {
|
|
122
|
+
return CHAT_KINDS.has(kind) || !running ? "chat" : "activity"
|
|
123
|
+
}
|
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/theme.ts
CHANGED
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. */
|