@capagents/forgeqe 1.0.2 → 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/mcp/external.ts +7 -0
- package/src/tui/App.tsx +26 -25
- package/src/tui/components.tsx +11 -11
- package/src/tui/inspector.tsx +201 -115
- package/src/tui/session.ts +11 -3
- package/src/tui/theme.ts +2 -0
package/package.json
CHANGED
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
|
@@ -33,7 +33,7 @@ import { CONTEXT_LIMIT } from "../code/runtime.ts"
|
|
|
33
33
|
import { readKnowledge } from "../knowledge.ts"
|
|
34
34
|
import { externalMcp } from "../mcp/external.ts"
|
|
35
35
|
import { meterSince } from "../usage.ts"
|
|
36
|
-
import {
|
|
36
|
+
import { ActivityPanel, ContextPanel, FilesPanel, PlanPanel, planRows, SessionPanel, StatusBar, TokensPanel, type ContextInfo, type SessionInfo } from "./inspector.tsx"
|
|
37
37
|
import {
|
|
38
38
|
addSkill,
|
|
39
39
|
caseDone,
|
|
@@ -488,7 +488,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
488
488
|
}
|
|
489
489
|
if (event.type === "outcome") {
|
|
490
490
|
push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
|
|
491
|
-
|
|
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))
|
|
492
494
|
bump()
|
|
493
495
|
}
|
|
494
496
|
return
|
|
@@ -846,8 +848,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
846
848
|
return
|
|
847
849
|
}
|
|
848
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")
|
|
849
854
|
push("user", text)
|
|
850
|
-
for (const w of priceNotesRef.current.splice(0)) push("sys", `Price check: ${w}`)
|
|
851
855
|
const cmd = parseCommand(text)
|
|
852
856
|
if (cmd.type === "help") {
|
|
853
857
|
push("sys", HELP)
|
|
@@ -1060,7 +1064,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1060
1064
|
if (cfgNow && contextInfo.current.key !== contextKey) {
|
|
1061
1065
|
let mcp: string[] = []
|
|
1062
1066
|
try {
|
|
1063
|
-
|
|
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))
|
|
1064
1069
|
} catch {}
|
|
1065
1070
|
contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
|
|
1066
1071
|
}
|
|
@@ -1073,6 +1078,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1073
1078
|
tests: testsRef.current.on ? "written to this folder" : requests.current ? `not written (${testsRef.current.why || "off"})` : "set on the first request",
|
|
1074
1079
|
browser: `${headed ? "visible" : "headless"} · up to ${maxSteps} steps${options.url ? ` · ${hostOf(options.url)}` : ""}`,
|
|
1075
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` } : {}),
|
|
1076
1082
|
started: sessionStarted.current,
|
|
1077
1083
|
requests: requests.current,
|
|
1078
1084
|
total: spent.roles.length ? { usage: spent.total, cost: spent.cost } : undefined,
|
|
@@ -1096,28 +1102,24 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1096
1102
|
const stop = () => openDialog("stop")
|
|
1097
1103
|
|
|
1098
1104
|
/**
|
|
1099
|
-
* The right pane:
|
|
1100
|
-
* left to flex: the activity
|
|
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.
|
|
1101
1107
|
*/
|
|
1102
1108
|
const details = (paneWidth: number, rowsAvailable: number) => {
|
|
1103
|
-
const
|
|
1104
|
-
const
|
|
1105
|
-
const
|
|
1106
|
-
const
|
|
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)
|
|
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)
|
|
1110
1113
|
return (
|
|
1111
1114
|
<box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
|
|
1112
|
-
<
|
|
1115
|
+
<StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} />
|
|
1113
1116
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1114
|
-
<PlanPanel s={insp} width={paneWidth}
|
|
1115
|
-
<
|
|
1116
|
-
<
|
|
1117
|
-
|
|
1118
|
-
<
|
|
1119
|
-
<
|
|
1120
|
-
<TokensPanel roles={spent.roles} total={{ usage: spent.total, cost: spent.cost }} width={paneWidth - 2 * third} />
|
|
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} />
|
|
1121
1123
|
</box>
|
|
1122
1124
|
</box>
|
|
1123
1125
|
)
|
|
@@ -1141,17 +1143,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1141
1143
|
const split = width >= SPLIT_MIN
|
|
1142
1144
|
if (split) {
|
|
1143
1145
|
const leftWidth = Math.floor(width * 0.4)
|
|
1144
|
-
const rightWidth = width - leftWidth - 3
|
|
1145
1146
|
return (
|
|
1146
1147
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1147
1148
|
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1148
1149
|
<box style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1149
|
-
<
|
|
1150
|
+
<SessionPanel info={sessionInfo} width={leftWidth - 3} />
|
|
1150
1151
|
{transcript(chatLines, {})}
|
|
1151
1152
|
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
|
|
1152
1153
|
</box>
|
|
1153
|
-
<box
|
|
1154
|
-
{details(
|
|
1154
|
+
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1155
|
+
{details(width - leftWidth - 3, height - 2)}
|
|
1155
1156
|
</box>
|
|
1156
1157
|
</box>
|
|
1157
1158
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
package/src/tui/components.tsx
CHANGED
|
@@ -48,7 +48,7 @@ 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
|
-
/**
|
|
51
|
+
/** Beside a session panel that names the model and profile, and a status bar: no bottom row. */
|
|
52
52
|
compact?: boolean
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -108,20 +108,20 @@ export function Prompt(props: PromptProps) {
|
|
|
108
108
|
placeholderColor={theme.faint}
|
|
109
109
|
/>
|
|
110
110
|
)}
|
|
111
|
-
|
|
112
|
-
{
|
|
111
|
+
{props.compact ? null : (
|
|
112
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
|
|
113
113
|
<text>
|
|
114
114
|
<span fg={theme.muted}>{"Model "}</span>
|
|
115
115
|
<span fg={theme.text}>{props.model || "…"}</span>
|
|
116
116
|
</text>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
</
|
|
124
|
-
|
|
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
|
+
)}
|
|
125
125
|
</box>
|
|
126
126
|
<text>
|
|
127
127
|
<span fg={props.modeColor}>{edge.slice(0, 1)}</span>
|
package/src/tui/inspector.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
-
import type { RefObject } from "react"
|
|
2
|
+
import type { ReactNode, RefObject } from "react"
|
|
3
3
|
|
|
4
4
|
import type { Usage } from "../types.ts"
|
|
5
5
|
import { formatCost, type Cost, type Role } from "../usage.ts"
|
|
@@ -7,9 +7,12 @@ import { Spinner } from "./components.tsx"
|
|
|
7
7
|
import { filesByUse, type FileUse, type Inspector, type Progress } from "./session.ts"
|
|
8
8
|
import { clip, tailCut } from "./text.ts"
|
|
9
9
|
import { formatTokens, theme } from "./theme.ts"
|
|
10
|
-
import {
|
|
10
|
+
import type { Line } from "./transcript.tsx"
|
|
11
11
|
|
|
12
|
-
/**
|
|
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
|
+
*/
|
|
13
16
|
export const SECTION = {
|
|
14
17
|
session: theme.accent,
|
|
15
18
|
plan: theme.thought,
|
|
@@ -27,15 +30,28 @@ const ROLE_COLOR: Record<Role, string> = {
|
|
|
27
30
|
judge: theme.memory,
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
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 }) {
|
|
32
38
|
return (
|
|
33
|
-
<box
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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}
|
|
39
55
|
</box>
|
|
40
56
|
)
|
|
41
57
|
}
|
|
@@ -48,14 +64,21 @@ export type SessionInfo = {
|
|
|
48
64
|
tests: string
|
|
49
65
|
browser: string
|
|
50
66
|
domain?: string
|
|
67
|
+
/** The profile's price is set in llms.json and differs from the model's list price. */
|
|
68
|
+
price?: string
|
|
51
69
|
started?: number
|
|
52
70
|
requests: number
|
|
53
71
|
total?: { usage: Usage; cost?: Cost }
|
|
54
72
|
}
|
|
55
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
|
+
|
|
56
79
|
/** Top of the left pane: what this session runs with, and what it has used. */
|
|
57
|
-
export function
|
|
58
|
-
const room = Math.max(10, width -
|
|
80
|
+
export function SessionPanel({ info, width }: { info: SessionInfo; width: number }) {
|
|
81
|
+
const room = Math.max(10, width - 14)
|
|
59
82
|
const row = (label: string, value: string, color: string = theme.text) => (
|
|
60
83
|
<text key={label}>
|
|
61
84
|
<span fg={theme.muted}>{label.padEnd(9)}</span>
|
|
@@ -63,41 +86,54 @@ export function SessionBlock({ info, width }: { info: SessionInfo; width: number
|
|
|
63
86
|
</text>
|
|
64
87
|
)
|
|
65
88
|
const started = info.started ? new Date(info.started) : null
|
|
66
|
-
const
|
|
89
|
+
const since = started ? ` · since ${String(started.getHours()).padStart(2, "0")}:${String(started.getMinutes()).padStart(2, "0")}` : ""
|
|
67
90
|
const used = info.total ? `${formatTokens(info.total.usage.input + info.total.usage.output)} tokens${info.total.cost ? ` · ${formatCost(info.total.cost)}` : ""}` : "nothing yet"
|
|
68
91
|
return (
|
|
69
|
-
<
|
|
70
|
-
<SectionTitle title="SESSION" color={SECTION.session} right={`started ${clock}`} width={width} />
|
|
92
|
+
<Panel title={`Session${since}`} color={SECTION.session} width={width}>
|
|
71
93
|
{row("Model", `${info.profile || "none"} · ${info.model || "…"}`)}
|
|
72
94
|
{row("Sign-in", info.auth, info.auth.startsWith("Entra") ? theme.ok : theme.text)}
|
|
73
95
|
{row("Folder", tailCut(info.cwd, room))}
|
|
74
96
|
{row("Tests", info.tests, info.tests.startsWith("written") ? theme.ok : theme.muted)}
|
|
75
97
|
{row("Browser", info.browser)}
|
|
76
98
|
{info.domain ? row("Domain", info.domain, theme.memory) : null}
|
|
99
|
+
{info.price ? row("Price", info.price, theme.warn) : null}
|
|
77
100
|
{row("Used", `${info.requests} request${info.requests === 1 ? "" : "s"} · ${used}`, theme.muted)}
|
|
78
|
-
</
|
|
101
|
+
</Panel>
|
|
79
102
|
)
|
|
80
103
|
}
|
|
81
104
|
|
|
82
|
-
/** The right pane's
|
|
83
|
-
export function
|
|
84
|
-
const
|
|
85
|
-
const
|
|
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))
|
|
86
120
|
return (
|
|
87
|
-
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0 }}>
|
|
121
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
|
|
88
122
|
<box style={{ flexDirection: "row" }}>
|
|
89
|
-
{props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={
|
|
123
|
+
{props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
|
|
90
124
|
<text>
|
|
91
|
-
<span fg={
|
|
92
|
-
{props.
|
|
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>
|
|
93
127
|
</text>
|
|
94
128
|
</box>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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}
|
|
101
137
|
</box>
|
|
102
138
|
)
|
|
103
139
|
}
|
|
@@ -115,148 +151,198 @@ const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
|
|
|
115
151
|
skipped: ["■", theme.faint],
|
|
116
152
|
}
|
|
117
153
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
154
|
+
type PlanLine = { mark: string; color: string; text: string; textColor: string }
|
|
155
|
+
|
|
156
|
+
function planLines(s: Inspector): PlanLine[] {
|
|
157
|
+
const lines: PlanLine[] = []
|
|
121
158
|
for (const p of s.plan) {
|
|
122
159
|
const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
|
|
123
160
|
lines.push({ mark, color, text: p.text, textColor: p.status === "completed" || p.status === "cancelled" ? theme.muted : theme.text })
|
|
124
161
|
}
|
|
125
|
-
if (s.progress.items.length) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
}
|
|
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 })
|
|
131
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)
|
|
132
179
|
const done = s.plan.filter((p) => p.status === "completed").length
|
|
133
|
-
const
|
|
134
|
-
const shown = lines.slice(-Math.max(1,
|
|
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))
|
|
135
182
|
return (
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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>
|
|
148
217
|
</box>
|
|
149
218
|
)
|
|
150
219
|
}
|
|
151
220
|
|
|
152
221
|
/** Everything the agents do, newest at the bottom. */
|
|
153
|
-
export function
|
|
222
|
+
export function ActivityPanel(props: { lines: Line[]; scrollRef: RefObject<ScrollBoxRenderable | null>; width: number; height: number }) {
|
|
154
223
|
return (
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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>
|
|
165
237
|
)
|
|
166
238
|
}
|
|
167
239
|
|
|
168
|
-
const FILE_MARK: Record<FileUse["last"], [string, string]> = { read: ["◉", theme.accent], write: ["
|
|
240
|
+
const FILE_MARK: Record<FileUse["last"], [string, string]> = { read: ["◉", theme.accent], write: ["✚", theme.ok], edit: ["✎", theme.warn] }
|
|
169
241
|
|
|
170
|
-
|
|
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 }) {
|
|
171
254
|
const files = filesByUse(s)
|
|
172
|
-
const
|
|
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
|
|
173
258
|
return (
|
|
174
|
-
<
|
|
175
|
-
<SectionTitle title="FILES" color={SECTION.files} right={files.length ? String(files.length) : ""} width={width} />
|
|
259
|
+
<Panel title={`Files${files.length ? ` · ${files.length}` : ""}`} color={SECTION.files} width={width} height={height}>
|
|
176
260
|
{files.length ? (
|
|
177
|
-
|
|
261
|
+
listed.map((f) => {
|
|
178
262
|
const [mark, color] = FILE_MARK[f.last]
|
|
263
|
+
const [name, dir] = fileLabel(f.path, room)
|
|
179
264
|
const times = f.reads + f.changes
|
|
180
265
|
return (
|
|
181
266
|
<text key={f.path}>
|
|
182
267
|
<span fg={color}>{`${mark} `}</span>
|
|
183
|
-
<span fg={f.changes ? theme.text : theme.muted}>{
|
|
268
|
+
<span fg={f.changes ? theme.text : theme.muted}>{name}</span>
|
|
269
|
+
{dir ? <span fg={theme.faint}>{` ${dir}`}</span> : null}
|
|
184
270
|
{times > 1 ? <span fg={theme.faint}>{` ×${times}`}</span> : null}
|
|
185
271
|
</text>
|
|
186
272
|
)
|
|
187
273
|
})
|
|
188
274
|
) : (
|
|
189
|
-
<text fg={theme.faint}>{"
|
|
275
|
+
<text fg={theme.faint}>{"None yet"}</text>
|
|
190
276
|
)}
|
|
191
|
-
{files.length >
|
|
192
|
-
|
|
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>
|
|
277
|
+
{files.length > listed.length ? <text fg={theme.faint}>{`+${files.length - listed.length} more`}</text> : null}
|
|
278
|
+
</Panel>
|
|
201
279
|
)
|
|
202
280
|
}
|
|
203
281
|
|
|
204
282
|
export type ContextInfo = { tokens: number; limit: number; skills: string[]; knowledge: number; domain?: string; mcp: string[]; capabilities: number }
|
|
205
283
|
|
|
206
284
|
/** 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
|
|
285
|
+
export function ContextPanel({ c, width, height }: { c: ContextInfo; width: number; height: number }) {
|
|
286
|
+
const inner = Math.max(6, width - 4)
|
|
209
287
|
const share = c.limit ? Math.min(1, c.tokens / c.limit) : 0
|
|
210
|
-
const filled = Math.round(share *
|
|
288
|
+
const filled = Math.round(share * inner)
|
|
211
289
|
const color = share < 0.6 ? theme.ok : share < 0.85 ? theme.warn : theme.err
|
|
212
|
-
const room = Math.max(6,
|
|
290
|
+
const room = Math.max(6, inner - 11)
|
|
213
291
|
const row = (label: string, value: string, valueColor: string = theme.text) => (
|
|
214
292
|
<text key={label}>
|
|
215
|
-
<span fg={theme.muted}>{label.padEnd(
|
|
293
|
+
<span fg={theme.muted}>{label.padEnd(11)}</span>
|
|
216
294
|
<span fg={valueColor}>{clip(value, room)}</span>
|
|
217
295
|
</text>
|
|
218
296
|
)
|
|
219
297
|
return (
|
|
220
|
-
<
|
|
221
|
-
<SectionTitle title="CONTEXT" color={SECTION.context} right={c.tokens ? `${Math.round(share * 100)}%` : ""} width={width} />
|
|
298
|
+
<Panel title={`Context · ${Math.round(share * 100)}%`} color={SECTION.context} width={width} height={height}>
|
|
222
299
|
<text>
|
|
223
300
|
<span fg={color}>{"█".repeat(filled)}</span>
|
|
224
|
-
<span fg={theme.
|
|
225
|
-
<span fg={theme.muted}>{` ${formatTokens(c.tokens)}/${formatTokens(c.limit)}`}</span>
|
|
301
|
+
<span fg={theme.border}>{"█".repeat(inner - filled)}</span>
|
|
226
302
|
</text>
|
|
303
|
+
<text fg={theme.muted}>{`${formatTokens(c.tokens)} of ${formatTokens(c.limit)} tokens`}</text>
|
|
227
304
|
{row("Skills", c.skills.length ? c.skills.join(", ") : "none loaded", c.skills.length ? theme.ok : theme.faint)}
|
|
228
305
|
{row("Knowledge", c.knowledge ? `${c.knowledge} product file${c.knowledge === 1 ? "" : "s"}` : "none", c.knowledge ? theme.text : theme.faint)}
|
|
229
306
|
{row("Domain", c.domain || "not declared", c.domain ? theme.memory : theme.faint)}
|
|
230
307
|
{row("MCP", c.mcp.length ? c.mcp.join(", ") : "none", c.mcp.length ? theme.text : theme.faint)}
|
|
231
|
-
|
|
232
|
-
</box>
|
|
308
|
+
</Panel>
|
|
233
309
|
)
|
|
234
310
|
}
|
|
235
311
|
|
|
236
|
-
export function TokensPanel(
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
)
|
|
239
331
|
return (
|
|
240
|
-
<
|
|
241
|
-
|
|
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 ? (
|
|
332
|
+
<Panel title="Tokens" color={SECTION.tokens} width={width} height={props.height}>
|
|
333
|
+
{props.live ? (
|
|
253
334
|
<text>
|
|
254
|
-
<span fg={theme.
|
|
255
|
-
<span fg={theme.
|
|
335
|
+
<span fg={theme.warn}>{"This request".padEnd(labelWidth)}</span>
|
|
336
|
+
<span fg={theme.warn}>{formatTokens(props.live).padStart(7)}</span>
|
|
256
337
|
</text>
|
|
257
338
|
) : null}
|
|
258
|
-
<text fg={theme.faint}>{
|
|
259
|
-
|
|
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>
|
|
260
347
|
)
|
|
261
348
|
}
|
|
262
|
-
|
package/src/tui/session.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { FileAction, PlanItem } from "../types.ts"
|
|
|
8
8
|
|
|
9
9
|
export type FileUse = { path: string; reads: number; changes: number; last: FileAction; order: number }
|
|
10
10
|
|
|
11
|
-
export type Progress = { label: string; status: "running" | "ok" | "fail" | "skipped"; detail?: string }
|
|
11
|
+
export type Progress = { label: string; status: "running" | "ok" | "fail" | "skipped"; detail?: string; actions?: string[] }
|
|
12
12
|
|
|
13
13
|
export type Who = "ForgeQE's agent" | "Browser agent" | "Scribe"
|
|
14
14
|
|
|
@@ -74,10 +74,18 @@ export function stepStarted(s: Inspector, label: string): void {
|
|
|
74
74
|
if (s.progress.items.length > MAX_PROGRESS) s.progress.items.splice(0, s.progress.items.length - MAX_PROGRESS)
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
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 {
|
|
79
82
|
const running = [...s.progress.items].reverse().find((i) => i.status === "running" || i.status === "fail")
|
|
80
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
|
+
}
|
|
81
89
|
if (!ok) {
|
|
82
90
|
running.status = "fail"
|
|
83
91
|
if (detail) running.detail = detail
|
package/src/tui/theme.ts
CHANGED