@capagents/forgeqe 1.0.2 → 1.0.4
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 +31 -32
- package/src/tui/components.tsx +11 -11
- package/src/tui/inspector.tsx +187 -139
- 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,
|
|
@@ -83,6 +83,9 @@ export const SPLIT_MIN = 120
|
|
|
83
83
|
export function App({ options }: { options: TuiOptions }) {
|
|
84
84
|
const renderer = useRenderer()
|
|
85
85
|
const { width, height } = useTerminalDimensions()
|
|
86
|
+
/** The terminal's width when a request ends (callbacks outlive the render they were made in). */
|
|
87
|
+
const widthRef = useRef(width)
|
|
88
|
+
widthRef.current = width
|
|
86
89
|
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
87
90
|
const activityScroll = useRef<ScrollBoxRenderable>(null)
|
|
88
91
|
const submitRef = useRef<(value: string) => void>(() => {})
|
|
@@ -488,7 +491,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
488
491
|
}
|
|
489
492
|
if (event.type === "outcome") {
|
|
490
493
|
push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
|
|
491
|
-
|
|
494
|
+
// The action itself, without the result after a colon or the locator in brackets: "click "Search"".
|
|
495
|
+
const action = event.text.split(/: | {2}\[/)[0]!.trim()
|
|
496
|
+
stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(event.text, 80), clip(action, 60))
|
|
492
497
|
bump()
|
|
493
498
|
}
|
|
494
499
|
return
|
|
@@ -773,7 +778,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
773
778
|
} finally {
|
|
774
779
|
pushProduced()
|
|
775
780
|
// What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
|
|
776
|
-
|
|
781
|
+
// The split view's session and tokens panels show this; only the one-pane view needs it in the chat.
|
|
782
|
+
const spent = widthRef.current < SPLIT_MIN ? requestLine(meterStart) : ""
|
|
777
783
|
if (spent) push("sys", spent, "chat")
|
|
778
784
|
finish(finalStatus)
|
|
779
785
|
}
|
|
@@ -846,8 +852,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
846
852
|
return
|
|
847
853
|
}
|
|
848
854
|
if (!text) return
|
|
855
|
+
// Setup notes come before the request, so they do not read as its answer; the split view shows them in its session panel.
|
|
856
|
+
const notes = priceNotesRef.current.splice(0)
|
|
857
|
+
if (width < SPLIT_MIN) for (const w of notes) push("sys", `Price check: ${w}`, "chat")
|
|
849
858
|
push("user", text)
|
|
850
|
-
for (const w of priceNotesRef.current.splice(0)) push("sys", `Price check: ${w}`)
|
|
851
859
|
const cmd = parseCommand(text)
|
|
852
860
|
if (cmd.type === "help") {
|
|
853
861
|
push("sys", HELP)
|
|
@@ -1060,22 +1068,18 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1060
1068
|
if (cfgNow && contextInfo.current.key !== contextKey) {
|
|
1061
1069
|
let mcp: string[] = []
|
|
1062
1070
|
try {
|
|
1063
|
-
|
|
1071
|
+
// "Azure DevOps (az login)": which account a server works as decides what it may see, so the panel says it.
|
|
1072
|
+
mcp = externalMcp(cfgNow).servers.map((x) => (x.signIn ? `${x.label.replace(/ MCP$/, "")} (${x.signIn.startsWith("PAT") ? "PAT" : "az login"})` : x.label))
|
|
1064
1073
|
} catch {}
|
|
1065
1074
|
contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
|
|
1066
1075
|
}
|
|
1067
1076
|
const llm = cfgNow?.llm
|
|
1068
1077
|
const sessionInfo: SessionInfo = {
|
|
1069
|
-
profile,
|
|
1070
|
-
model: model || (cfgNow ? modelLabel(cfgNow) : ""),
|
|
1071
|
-
auth: !llm ? "–" : llm.auth === "entra" ? "Entra ID, no key" : llm.apiKeyEnv ? `API key in ${llm.apiKeyEnv}` : llm.provider === "mock" ? "offline demo" : llm.provider,
|
|
1078
|
+
model: cfgNow ? `${llm?.provider === "mock" ? "offline demo" : llm?.deployment || llm?.model || "none"}${profile ? ` (${profile})` : ""}` : "…",
|
|
1072
1079
|
cwd: shortCwd(),
|
|
1073
|
-
tests: testsRef.current.on ? "written to this folder" : requests.current ? `not written (${testsRef.current.why || "off"})` : "set on the first request",
|
|
1074
|
-
browser: `${headed ? "visible" : "headless"} · up to ${maxSteps} steps${options.url ? ` · ${hostOf(options.url)}` : ""}`,
|
|
1075
1080
|
...(cfgNow?.domain ? { domain: `${cfgNow.domain.industry}${cfgNow.domain.regulations.length ? ` (${cfgNow.domain.regulations.join(", ")})` : ""}` } : {}),
|
|
1076
1081
|
started: sessionStarted.current,
|
|
1077
1082
|
requests: requests.current,
|
|
1078
|
-
total: spent.roles.length ? { usage: spent.total, cost: spent.cost } : undefined,
|
|
1079
1083
|
}
|
|
1080
1084
|
const contextPanel: ContextInfo = {
|
|
1081
1085
|
tokens: insp.context,
|
|
@@ -1096,28 +1100,24 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1096
1100
|
const stop = () => openDialog("stop")
|
|
1097
1101
|
|
|
1098
1102
|
/**
|
|
1099
|
-
* The right pane:
|
|
1100
|
-
* left to flex: the activity
|
|
1103
|
+
* The right pane: the status bar, then bordered panels for the plan, the activity, and files, context, and tokens.
|
|
1104
|
+
* Rows are counted, not left to flex: the activity gets what the others leave, so nothing is pushed off screen.
|
|
1101
1105
|
*/
|
|
1102
1106
|
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)
|
|
1107
|
+
const bottom = 6 + 2 // the context panel's six rows, and the border
|
|
1108
|
+
const plan = planRows(insp, Math.max(3, Math.min(8, Math.floor(rowsAvailable / 5))))
|
|
1109
|
+
const activityRows = Math.max(5, rowsAvailable - 2 - plan - bottom)
|
|
1110
|
+
const third = Math.floor(paneWidth / 3)
|
|
1110
1111
|
return (
|
|
1111
1112
|
<box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
|
|
1112
|
-
<
|
|
1113
|
+
<StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} />
|
|
1113
1114
|
<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} />
|
|
1115
|
+
{plan ? <PlanPanel s={insp} width={paneWidth} height={plan} /> : null}
|
|
1116
|
+
<ActivityPanel lines={activityLines} scrollRef={activityScroll} width={paneWidth} height={activityRows} />
|
|
1117
|
+
<box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
|
|
1118
|
+
<FilesPanel s={insp} width={third} height={bottom} />
|
|
1119
|
+
<ContextPanel c={contextPanel} width={third} height={bottom} />
|
|
1120
|
+
<TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
|
|
1121
1121
|
</box>
|
|
1122
1122
|
</box>
|
|
1123
1123
|
)
|
|
@@ -1141,17 +1141,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1141
1141
|
const split = width >= SPLIT_MIN
|
|
1142
1142
|
if (split) {
|
|
1143
1143
|
const leftWidth = Math.floor(width * 0.4)
|
|
1144
|
-
const rightWidth = width - leftWidth - 3
|
|
1145
1144
|
return (
|
|
1146
1145
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1147
1146
|
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1148
1147
|
<box style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1149
|
-
<
|
|
1148
|
+
<SessionPanel info={sessionInfo} width={leftWidth - 3} />
|
|
1150
1149
|
{transcript(chatLines, {})}
|
|
1151
1150
|
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
|
|
1152
1151
|
</box>
|
|
1153
|
-
<box
|
|
1154
|
-
{details(
|
|
1152
|
+
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1153
|
+
{details(width - leftWidth - 3, height - 2)}
|
|
1155
1154
|
</box>
|
|
1156
1155
|
</box>
|
|
1157
1156
|
<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,15 +1,17 @@
|
|
|
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
|
-
import { formatCost, type Cost, type Role } from "../usage.ts"
|
|
6
5
|
import { Spinner } from "./components.tsx"
|
|
7
6
|
import { filesByUse, type FileUse, type Inspector, type Progress } from "./session.ts"
|
|
8
7
|
import { clip, tailCut } from "./text.ts"
|
|
9
8
|
import { formatTokens, theme } from "./theme.ts"
|
|
10
|
-
import {
|
|
9
|
+
import type { Line } from "./transcript.tsx"
|
|
11
10
|
|
|
12
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* The split view's panels. Each is a rounded box with its title in the border, in its section's colour from the
|
|
13
|
+
* shell's palette; status colours keep their meaning inside (ok done, warn running or attention, err failed).
|
|
14
|
+
*/
|
|
13
15
|
export const SECTION = {
|
|
14
16
|
session: theme.accent,
|
|
15
17
|
plan: theme.thought,
|
|
@@ -19,85 +21,100 @@ export const SECTION = {
|
|
|
19
21
|
tokens: theme.ok,
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
const ROLE_COLOR: Record<Role, string> = {
|
|
23
|
-
"ForgeQE's agent": theme.accent,
|
|
24
|
-
"browser agent": theme.thought,
|
|
25
|
-
Scribe: theme.ok,
|
|
26
|
-
reviewer: theme.warn,
|
|
27
|
-
judge: theme.memory,
|
|
28
|
-
}
|
|
29
24
|
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
/** Rows a panel's border takes: top and bottom. */
|
|
26
|
+
export const PANEL_CHROME = 2
|
|
27
|
+
|
|
28
|
+
/** A titled, rounded panel; `width` and `height` include the border. */
|
|
29
|
+
export function Panel(props: { title: string; color: string; width?: number; height?: number; children: ReactNode }) {
|
|
32
30
|
return (
|
|
33
|
-
<box
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
<box
|
|
32
|
+
border
|
|
33
|
+
borderStyle="rounded"
|
|
34
|
+
borderColor={theme.border}
|
|
35
|
+
title={` ${props.title} `}
|
|
36
|
+
titleColor={props.color}
|
|
37
|
+
style={{
|
|
38
|
+
flexDirection: "column",
|
|
39
|
+
paddingLeft: 1,
|
|
40
|
+
paddingRight: 1,
|
|
41
|
+
flexShrink: 0,
|
|
42
|
+
...(props.width !== undefined ? { width: props.width } : {}),
|
|
43
|
+
...(props.height !== undefined ? { height: props.height } : {}),
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
{props.children}
|
|
39
47
|
</box>
|
|
40
48
|
)
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
export type SessionInfo = {
|
|
44
|
-
|
|
52
|
+
/** "gpt-5.4 (azure-gpt54)": the model, and the llms.json profile it comes from. */
|
|
45
53
|
model: string
|
|
46
|
-
auth: string
|
|
47
54
|
cwd: string
|
|
48
|
-
tests: string
|
|
49
|
-
browser: string
|
|
50
55
|
domain?: string
|
|
51
56
|
started?: number
|
|
52
57
|
requests: number
|
|
53
|
-
total?: { usage: Usage; cost?: Cost }
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
/**
|
|
57
|
-
export function
|
|
58
|
-
|
|
60
|
+
/** Rows SessionPanel takes, border included. */
|
|
61
|
+
export function sessionRows(info: SessionInfo): number {
|
|
62
|
+
return 3 + (info.domain ? 1 : 0) + PANEL_CHROME
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Top of the left pane: what the session works with, nothing more; tokens are in their own panel. */
|
|
66
|
+
export function SessionPanel({ info, width }: { info: SessionInfo; width: number }) {
|
|
67
|
+
const room = Math.max(10, width - 15)
|
|
59
68
|
const row = (label: string, value: string, color: string = theme.text) => (
|
|
60
69
|
<text key={label}>
|
|
61
|
-
<span fg={theme.muted}>{label.padEnd(
|
|
70
|
+
<span fg={theme.muted}>{label.padEnd(10)}</span>
|
|
62
71
|
<span fg={color}>{clip(value, room)}</span>
|
|
63
72
|
</text>
|
|
64
73
|
)
|
|
65
74
|
const started = info.started ? new Date(info.started) : null
|
|
66
|
-
const
|
|
67
|
-
const used = info.total ? `${formatTokens(info.total.usage.input + info.total.usage.output)} tokens${info.total.cost ? ` · ${formatCost(info.total.cost)}` : ""}` : "nothing yet"
|
|
75
|
+
const since = started ? ` · since ${String(started.getHours()).padStart(2, "0")}:${String(started.getMinutes()).padStart(2, "0")}` : ""
|
|
68
76
|
return (
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
{row("Model", `${info.profile || "none"} · ${info.model || "…"}`)}
|
|
72
|
-
{row("Sign-in", info.auth, info.auth.startsWith("Entra") ? theme.ok : theme.text)}
|
|
77
|
+
<Panel title={`Session${since}`} color={SECTION.session} width={width}>
|
|
78
|
+
{row("Model", info.model)}
|
|
73
79
|
{row("Folder", tailCut(info.cwd, room))}
|
|
74
|
-
{row("Tests", info.tests, info.tests.startsWith("written") ? theme.ok : theme.muted)}
|
|
75
|
-
{row("Browser", info.browser)}
|
|
76
80
|
{info.domain ? row("Domain", info.domain, theme.memory) : null}
|
|
77
|
-
{row("
|
|
78
|
-
</
|
|
81
|
+
{row("Requests", String(info.requests), theme.muted)}
|
|
82
|
+
</Panel>
|
|
79
83
|
)
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
/** The right pane's
|
|
83
|
-
export function
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
+
/** The right pane's first line: who is working on what and for how long, and Stop while something runs. */
|
|
87
|
+
export function StatusBar(props: { running: boolean; who: string; activity: string; elapsed: string; status: string; width: number; onStop: () => void }) {
|
|
88
|
+
const done = props.status === "done"
|
|
89
|
+
const failed = props.status === "fail"
|
|
90
|
+
const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
|
|
91
|
+
const what = props.running
|
|
92
|
+
? props.activity || "working…"
|
|
93
|
+
: done
|
|
94
|
+
? "Last request done"
|
|
95
|
+
: failed
|
|
96
|
+
? "Last request failed"
|
|
97
|
+
: props.status === "stopped"
|
|
98
|
+
? "Last request stopped"
|
|
99
|
+
: "Ready for your next request"
|
|
100
|
+
const room = Math.max(10, props.width - props.who.length - (props.running ? 30 : 6))
|
|
86
101
|
return (
|
|
87
|
-
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0 }}>
|
|
102
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
|
|
88
103
|
<box style={{ flexDirection: "row" }}>
|
|
89
|
-
{props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={
|
|
104
|
+
{props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
|
|
90
105
|
<text>
|
|
91
|
-
<span fg={
|
|
92
|
-
{props.
|
|
106
|
+
{props.running ? <span fg={theme.text}>{` ${props.who}`}</span> : null}
|
|
107
|
+
<span fg={props.running ? theme.muted : color}>{`${props.running ? " " : " "}${clip(what, room)}`}</span>
|
|
93
108
|
</text>
|
|
94
109
|
</box>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
{props.running ? (
|
|
111
|
+
<box style={{ flexDirection: "row" }}>
|
|
112
|
+
<text fg={theme.faint}>{`${props.elapsed} `}</text>
|
|
113
|
+
<box onMouseDown={props.onStop} style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.warn }}>
|
|
114
|
+
<text fg={theme.onButton}>{"■ Stop · esc"}</text>
|
|
115
|
+
</box>
|
|
116
|
+
</box>
|
|
117
|
+
) : null}
|
|
101
118
|
</box>
|
|
102
119
|
)
|
|
103
120
|
}
|
|
@@ -115,148 +132,179 @@ const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
|
|
|
115
132
|
skipped: ["■", theme.faint],
|
|
116
133
|
}
|
|
117
134
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
type PlanLine = { mark: string; color: string; text: string; textColor: string }
|
|
136
|
+
|
|
137
|
+
function planLines(s: Inspector): PlanLine[] {
|
|
138
|
+
const lines: PlanLine[] = []
|
|
121
139
|
for (const p of s.plan) {
|
|
122
140
|
const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
|
|
123
141
|
lines.push({ mark, color, text: p.text, textColor: p.status === "completed" || p.status === "cancelled" ? theme.muted : theme.text })
|
|
124
142
|
}
|
|
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
|
-
}
|
|
143
|
+
if (s.progress.items.length && s.plan.length) lines.push({ mark: "─", color: theme.border, text: s.progress.title, textColor: theme.faint })
|
|
144
|
+
for (const i of s.progress.items) {
|
|
145
|
+
const [mark, color] = PROGRESS_MARK[i.status]
|
|
146
|
+
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
147
|
}
|
|
148
|
+
return lines
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Rows the plan panel takes, border included; 0 while there is nothing to plan or follow. */
|
|
152
|
+
export function planRows(s: Inspector, cap: number): number {
|
|
153
|
+
const n = planLines(s).length
|
|
154
|
+
return n ? Math.min(cap, n) + PANEL_CHROME : 0
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The agent's to-do list, then the browser steps or suite cases; the latest that fit. */
|
|
158
|
+
export function PlanPanel({ s, width, height }: { s: Inspector; width: number; height: number }) {
|
|
159
|
+
const lines = planLines(s)
|
|
132
160
|
const done = s.plan.filter((p) => p.status === "completed").length
|
|
133
|
-
const
|
|
134
|
-
const shown = lines.slice(-Math.max(1,
|
|
161
|
+
const count = s.plan.length ? ` · ${done} of ${s.plan.length}` : s.progress.items.length ? ` · ${clip(s.progress.title, 40)}` : ""
|
|
162
|
+
const shown = lines.slice(-Math.max(1, height - PANEL_CHROME))
|
|
135
163
|
return (
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
164
|
+
<Panel title={`Plan${count}`} color={SECTION.plan} width={width} height={height}>
|
|
165
|
+
{shown.map((l, i) => (
|
|
166
|
+
<text key={i}>
|
|
167
|
+
<span fg={l.color}>{`${l.mark} `}</span>
|
|
168
|
+
<span fg={l.textColor}>{clip(l.text, Math.max(10, width - 8))}</span>
|
|
169
|
+
</text>
|
|
170
|
+
))}
|
|
171
|
+
</Panel>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Each kind of activity line: its mark, the mark's colour, and the text's colour. */
|
|
176
|
+
const ACTIVITY_STYLE: Record<string, [string, string, string]> = {
|
|
177
|
+
tool: ["⚙", theme.faint, theme.muted],
|
|
178
|
+
file: ["✎", theme.accent, theme.text],
|
|
179
|
+
code: ["▸", theme.memory, theme.memory],
|
|
180
|
+
timing: ["+", theme.warn, theme.warn],
|
|
181
|
+
memory: ["↻", theme.memory, theme.memory],
|
|
182
|
+
thought: [" ", theme.thought, theme.thought],
|
|
183
|
+
ok: ["✓", theme.ok, theme.text],
|
|
184
|
+
err: ["✗", theme.err, theme.text],
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** One activity line, its mark in a column of its own so wrapped text stays under the text. */
|
|
188
|
+
export function ActivityRow({ line }: { line: Line }) {
|
|
189
|
+
const [mark, markColor, textColor] = ACTIVITY_STYLE[line.kind] || ["·", theme.faint, theme.muted]
|
|
190
|
+
return (
|
|
191
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
192
|
+
<text fg={markColor} style={{ width: 2, flexShrink: 0 }}>
|
|
193
|
+
{mark}
|
|
194
|
+
</text>
|
|
195
|
+
<text fg={textColor} style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
196
|
+
{line.text || " "}
|
|
197
|
+
</text>
|
|
148
198
|
</box>
|
|
149
199
|
)
|
|
150
200
|
}
|
|
151
201
|
|
|
152
202
|
/** Everything the agents do, newest at the bottom. */
|
|
153
|
-
export function
|
|
203
|
+
export function ActivityPanel(props: { lines: Line[]; scrollRef: RefObject<ScrollBoxRenderable | null>; width: number; height: number }) {
|
|
154
204
|
return (
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
205
|
+
<Panel title={`Activity${props.lines.length ? ` · ${props.lines.length}` : ""}`} color={SECTION.activity} width={props.width} height={props.height}>
|
|
206
|
+
{props.lines.length ? (
|
|
207
|
+
<scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
|
|
208
|
+
{props.lines.map((line) => (
|
|
209
|
+
<ActivityRow key={line.id} line={line} />
|
|
210
|
+
))}
|
|
211
|
+
</scrollbox>
|
|
212
|
+
) : (
|
|
213
|
+
<box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
|
|
214
|
+
<text fg={theme.faint}>{clip("Tool calls, browser steps, and the agents' reasoning show here", Math.max(10, props.width - 4))}</text>
|
|
215
|
+
</box>
|
|
216
|
+
)}
|
|
217
|
+
</Panel>
|
|
165
218
|
)
|
|
166
219
|
}
|
|
167
220
|
|
|
168
|
-
const FILE_MARK: Record<FileUse["last"], [string, string]> = { read: ["◉", theme.accent], write: ["
|
|
221
|
+
const FILE_MARK: Record<FileUse["last"], [string, string]> = { read: ["◉", theme.accent], write: ["✚", theme.ok], edit: ["✎", theme.warn] }
|
|
169
222
|
|
|
170
|
-
|
|
223
|
+
/** "PayBeneficiaryTests.cs tests/Payments": the name first, its folder after it, dimmed. */
|
|
224
|
+
function fileLabel(path: string, room: number): [string, string] {
|
|
225
|
+
const cut = path.lastIndexOf("/")
|
|
226
|
+
const name = cut < 0 ? path : path.slice(cut + 1)
|
|
227
|
+
const dir = cut < 0 ? "" : path.slice(0, cut)
|
|
228
|
+
if (name.length >= room - 2) return [clip(name, room), ""]
|
|
229
|
+
// A folder cut to a few characters says nothing: leave it out rather than show "…ts".
|
|
230
|
+
const space = room - name.length - 2
|
|
231
|
+
return [name, dir && (dir.length <= space || space >= 12) ? tailCut(dir, space) : ""]
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function FilesPanel({ s, width, height }: { s: Inspector; width: number; height: number }) {
|
|
171
235
|
const files = filesByUse(s)
|
|
172
|
-
const
|
|
236
|
+
const rows = height - PANEL_CHROME
|
|
237
|
+
const room = Math.max(8, width - 10)
|
|
238
|
+
const listed = files.length > rows ? files.slice(0, rows - 1) : files
|
|
173
239
|
return (
|
|
174
|
-
<
|
|
175
|
-
<SectionTitle title="FILES" color={SECTION.files} right={files.length ? String(files.length) : ""} width={width} />
|
|
240
|
+
<Panel title={`Files${files.length ? ` · ${files.length}` : ""}`} color={SECTION.files} width={width} height={height}>
|
|
176
241
|
{files.length ? (
|
|
177
|
-
|
|
242
|
+
listed.map((f) => {
|
|
178
243
|
const [mark, color] = FILE_MARK[f.last]
|
|
244
|
+
const [name, dir] = fileLabel(f.path, room)
|
|
179
245
|
const times = f.reads + f.changes
|
|
180
246
|
return (
|
|
181
247
|
<text key={f.path}>
|
|
182
248
|
<span fg={color}>{`${mark} `}</span>
|
|
183
|
-
<span fg={f.changes ? theme.text : theme.muted}>{
|
|
249
|
+
<span fg={f.changes ? theme.text : theme.muted}>{name}</span>
|
|
250
|
+
{dir ? <span fg={theme.faint}>{` ${dir}`}</span> : null}
|
|
184
251
|
{times > 1 ? <span fg={theme.faint}>{` ×${times}`}</span> : null}
|
|
185
252
|
</text>
|
|
186
253
|
)
|
|
187
254
|
})
|
|
188
255
|
) : (
|
|
189
|
-
<text fg={theme.faint}>{"
|
|
256
|
+
<text fg={theme.faint}>{"None yet"}</text>
|
|
190
257
|
)}
|
|
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>
|
|
258
|
+
{files.length > listed.length ? <text fg={theme.faint}>{`+${files.length - listed.length} more`}</text> : null}
|
|
259
|
+
</Panel>
|
|
201
260
|
)
|
|
202
261
|
}
|
|
203
262
|
|
|
204
263
|
export type ContextInfo = { tokens: number; limit: number; skills: string[]; knowledge: number; domain?: string; mcp: string[]; capabilities: number }
|
|
205
264
|
|
|
206
265
|
/** 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
|
|
266
|
+
export function ContextPanel({ c, width, height }: { c: ContextInfo; width: number; height: number }) {
|
|
267
|
+
const inner = Math.max(6, width - 4)
|
|
209
268
|
const share = c.limit ? Math.min(1, c.tokens / c.limit) : 0
|
|
210
|
-
const filled = Math.round(share *
|
|
269
|
+
const filled = Math.round(share * inner)
|
|
211
270
|
const color = share < 0.6 ? theme.ok : share < 0.85 ? theme.warn : theme.err
|
|
212
|
-
const room = Math.max(6,
|
|
271
|
+
const room = Math.max(6, inner - 11)
|
|
213
272
|
const row = (label: string, value: string, valueColor: string = theme.text) => (
|
|
214
273
|
<text key={label}>
|
|
215
|
-
<span fg={theme.muted}>{label.padEnd(
|
|
274
|
+
<span fg={theme.muted}>{label.padEnd(11)}</span>
|
|
216
275
|
<span fg={valueColor}>{clip(value, room)}</span>
|
|
217
276
|
</text>
|
|
218
277
|
)
|
|
219
278
|
return (
|
|
220
|
-
<
|
|
221
|
-
<SectionTitle title="CONTEXT" color={SECTION.context} right={c.tokens ? `${Math.round(share * 100)}%` : ""} width={width} />
|
|
279
|
+
<Panel title={`Context · ${Math.round(share * 100)}%`} color={SECTION.context} width={width} height={height}>
|
|
222
280
|
<text>
|
|
223
281
|
<span fg={color}>{"█".repeat(filled)}</span>
|
|
224
|
-
<span fg={theme.
|
|
225
|
-
<span fg={theme.muted}>{` ${formatTokens(c.tokens)}/${formatTokens(c.limit)}`}</span>
|
|
282
|
+
<span fg={theme.border}>{"█".repeat(inner - filled)}</span>
|
|
226
283
|
</text>
|
|
284
|
+
<text fg={theme.muted}>{`${formatTokens(c.tokens)} of ${formatTokens(c.limit)} tokens`}</text>
|
|
227
285
|
{row("Skills", c.skills.length ? c.skills.join(", ") : "none loaded", c.skills.length ? theme.ok : theme.faint)}
|
|
228
286
|
{row("Knowledge", c.knowledge ? `${c.knowledge} product file${c.knowledge === 1 ? "" : "s"}` : "none", c.knowledge ? theme.text : theme.faint)}
|
|
229
287
|
{row("Domain", c.domain || "not declared", c.domain ? theme.memory : theme.faint)}
|
|
230
288
|
{row("MCP", c.mcp.length ? c.mcp.join(", ") : "none", c.mcp.length ? theme.text : theme.faint)}
|
|
231
|
-
|
|
232
|
-
</box>
|
|
289
|
+
</Panel>
|
|
233
290
|
)
|
|
234
291
|
}
|
|
235
292
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
293
|
+
/** What the session used, in tokens: the total and how it splits. Costs are left to reports and `forgeqe models`. */
|
|
294
|
+
export function TokensPanel(props: { total: Usage; width: number; height: number }) {
|
|
295
|
+
const { total, width } = props
|
|
296
|
+
const line = (label: string, n: number, color: string = theme.text) => (
|
|
297
|
+
<text key={label}>
|
|
298
|
+
<span fg={theme.muted}>{label.padEnd(10)}</span>
|
|
299
|
+
<span fg={color}>{formatTokens(n).padStart(Math.max(7, Math.min(10, width - 14)))}</span>
|
|
300
|
+
</text>
|
|
301
|
+
)
|
|
239
302
|
return (
|
|
240
|
-
<
|
|
241
|
-
|
|
242
|
-
{
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
<span fg={theme.text}>{amount(r.usage, r.cost)}</span>
|
|
247
|
-
</text>
|
|
248
|
-
))
|
|
249
|
-
) : (
|
|
250
|
-
<text fg={theme.faint}>{"Nothing used yet"}</text>
|
|
251
|
-
)}
|
|
252
|
-
{roles.length > 1 ? (
|
|
253
|
-
<text>
|
|
254
|
-
<span fg={theme.muted}>{"Total".padEnd(labelWidth)}</span>
|
|
255
|
-
<span fg={theme.ok}>{amount(total.usage, total.cost)}</span>
|
|
256
|
-
</text>
|
|
257
|
-
) : null}
|
|
258
|
-
<text fg={theme.faint}>{`in ${formatTokens(total.usage.input)} · out ${formatTokens(total.usage.output)}`}</text>
|
|
259
|
-
</box>
|
|
303
|
+
<Panel title="Tokens" color={SECTION.tokens} width={width} height={props.height}>
|
|
304
|
+
{line("Total", total.input + total.output, theme.ok)}
|
|
305
|
+
{line("Input", total.input)}
|
|
306
|
+
{line("Output", total.output)}
|
|
307
|
+
{line("Cached", total.cached || 0, theme.muted)}
|
|
308
|
+
</Panel>
|
|
260
309
|
)
|
|
261
310
|
}
|
|
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