@capagents/forgeqe 1.1.5 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +19 -14
- package/docs/user-guide.html +17 -14
- package/package.json +1 -1
- package/prompts/brain.md +1 -0
- package/prompts/test-agent.md +1 -0
- package/prompts/test-reviewer.md +3 -1
- package/prompts/test-rules.md +2 -0
- package/src/code/graph/graph.ts +7 -0
- package/src/code/runtime.ts +8 -1
- package/src/mcp/server.ts +3 -1
- package/src/mcp/tools.ts +3 -1
- package/src/tui/App.tsx +144 -91
- package/src/tui/components.tsx +27 -23
- package/src/tui/inspector.tsx +140 -83
- package/src/tui/landing.ts +3 -4
- package/src/tui/refine-panel.tsx +102 -52
- package/src/tui/shortcuts.tsx +2 -0
- package/src/tui/transcript.tsx +32 -2
- package/src/types.ts +3 -1
package/src/tui/App.tsx
CHANGED
|
@@ -30,9 +30,9 @@ import { initWorkspace } from "../init.ts"
|
|
|
30
30
|
import { ShortcutsPanel } from "./shortcuts.tsx"
|
|
31
31
|
import { ago, getStarted, projectLine, setupSentence, type Fact, type GetStarted, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
|
|
32
32
|
import { TIPS, formatDuration, formatTokens, packageVersion, shortCwd, theme } from "./theme.ts"
|
|
33
|
-
import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
|
|
33
|
+
import { emptyProduced, Line, Produced, producedCard, settleCall, TestsSetting, TranscriptLine, trimLines } from "./transcript.tsx"
|
|
34
34
|
import { Dialog, dialogSpec } from "./dialogs.ts"
|
|
35
|
-
import { activityFromLog, clip,
|
|
35
|
+
import { activityFromLog, clip, modelLabel } from "./text.ts"
|
|
36
36
|
import { featuresText } from "../capabilities/render.ts"
|
|
37
37
|
import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
|
|
38
38
|
import { commandSuggestions, complete, lookupWorkItem, recentRuns, referenceSuggestions, repoFiles, triggerAt, type Suggestion, type WorkItem } from "./suggest.ts"
|
|
@@ -49,18 +49,19 @@ import {
|
|
|
49
49
|
ActivityPanel,
|
|
50
50
|
ContextPanel,
|
|
51
51
|
FilesPanel,
|
|
52
|
+
InfoStrip,
|
|
53
|
+
nowNext,
|
|
52
54
|
nextFocus,
|
|
53
55
|
planLines,
|
|
54
56
|
PlanPanel,
|
|
55
57
|
planProgress,
|
|
56
58
|
planRows,
|
|
57
|
-
|
|
59
|
+
STATUS_ROWS,
|
|
58
60
|
StatusBar,
|
|
59
61
|
TokensPanel,
|
|
60
62
|
type ActivityFilter,
|
|
61
63
|
type ContextInfo,
|
|
62
64
|
type Focus,
|
|
63
|
-
type SessionInfo,
|
|
64
65
|
} from "./inspector.tsx"
|
|
65
66
|
import {
|
|
66
67
|
addSkill,
|
|
@@ -123,6 +124,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
123
124
|
widthRef.current = width
|
|
124
125
|
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
125
126
|
const activityScroll = useRef<ScrollBoxRenderable>(null)
|
|
127
|
+
/** Refine's body, which scrolls when the refined request is longer than the screen has room for. */
|
|
128
|
+
const refineScroll = useRef<ScrollBoxRenderable>(null)
|
|
126
129
|
const submitRef = useRef<(value: string) => void>(() => {})
|
|
127
130
|
/** Refines what is in the prompt (phase two of the palette work wires it). */
|
|
128
131
|
const refineRef = useRef<() => void>(() => {})
|
|
@@ -239,17 +242,33 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
239
242
|
thinkingRef.current = thinking
|
|
240
243
|
|
|
241
244
|
/** Lines go to the chat, or while a request runs and they are not results, to the activity pane; `pane` overrides that. */
|
|
242
|
-
const push = (kind: Line["kind"], text: string, pane?: Pane) => {
|
|
245
|
+
const push = (kind: Line["kind"], text: string, pane?: Pane, quiet?: boolean) => {
|
|
243
246
|
const parts = kind === "user" || kind === "reply" ? [text] : text.split("\n")
|
|
244
247
|
const where = pane || (answeringRef.current ? "chat" : paneFor(kind, abortRef.current !== null))
|
|
245
248
|
setLines((prev) => {
|
|
246
249
|
const next = [...prev]
|
|
247
|
-
for (const part of parts) next.push({ id: idRef.current++, kind, text: part, pane: where })
|
|
248
|
-
return next
|
|
250
|
+
for (const part of parts) next.push({ id: idRef.current++, kind, text: part, pane: where, ...(quiet ? { quiet } : {}) })
|
|
251
|
+
return trimLines(next)
|
|
249
252
|
})
|
|
250
253
|
}
|
|
251
|
-
|
|
252
|
-
|
|
254
|
+
// The pane is decided when the event arrives: the updater runs later, when the request may already have finished.
|
|
255
|
+
const pushLine = (line: Omit<Line, "id">) => {
|
|
256
|
+
const next = { ...line, pane: line.pane || paneFor(line.kind, abortRef.current !== null), id: idRef.current++ }
|
|
257
|
+
setLines((prev) => trimLines([...prev, next]))
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* What the agents do always reaches the Activity panel; `shown` says whether the conversation shows it too at this
|
|
261
|
+
* detail level (/details). Hidden lines were dropped before, so the panel missed most of the agents' work.
|
|
262
|
+
*/
|
|
263
|
+
const record = (kind: Line["kind"], text: string, shown: boolean) => push(kind, text, shown ? undefined : "activity", !shown)
|
|
264
|
+
/** A tool call started: one running line in Activity, which its result replaces. */
|
|
265
|
+
const startCall = (id: string, text: string) => pushLine({ kind: "running", text, pane: "activity", quiet: true, call: id })
|
|
266
|
+
/** A tool call finished: its running line becomes the result, shown in the conversation too when `shown`. */
|
|
267
|
+
const endCall = (id: string | undefined, kind: Line["kind"], text: string, shown: boolean) => {
|
|
268
|
+
const line = { kind, text, pane: shown ? paneFor(kind, abortRef.current !== null) : ("activity" as const), quiet: !shown }
|
|
269
|
+
const n = idRef.current++
|
|
270
|
+
setLines((prev) => trimLines(settleCall(prev, id, line, n)))
|
|
271
|
+
}
|
|
253
272
|
/** What an agent's event tells the right pane: a file it used, a skill it loaded, its plan, how full its context is. */
|
|
254
273
|
const inspect = (e: CodeEvent) => {
|
|
255
274
|
const s = inspRef.current
|
|
@@ -261,8 +280,14 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
261
280
|
else return
|
|
262
281
|
bump()
|
|
263
282
|
}
|
|
283
|
+
/** What the agent is on between calls: the plan's task in progress, rather than a bare "working". */
|
|
284
|
+
const nowWorking = () => {
|
|
285
|
+
const task = inspRef.current.plan.find((p) => p.status === "in_progress")
|
|
286
|
+
return task ? `Working on: ${clip(task.text, 70)}` : "The agent is deciding what to do next…"
|
|
287
|
+
}
|
|
264
288
|
/** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
|
|
265
|
-
const
|
|
289
|
+
const toolKind = (text: string, ok: boolean): Line["kind"] => (!ok ? "err" : /^(Writing|Editing) /.test(text) ? "file" : "tool")
|
|
290
|
+
const pushTool = (text: string, ok: boolean, id?: string, shown = true) => endCall(id, toolKind(text, ok), text, shown)
|
|
266
291
|
/** A short note in the hint row that clears itself: "opened report.html". */
|
|
267
292
|
const flashTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
268
293
|
const flash = (text: string) => {
|
|
@@ -646,6 +671,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
646
671
|
}
|
|
647
672
|
} else if (r.phase === "error") {
|
|
648
673
|
if (key.name === "escape" || key.name === "return" || key.name === "enter") setRefine(null)
|
|
674
|
+
} else if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") {
|
|
675
|
+
const page = Math.max(3, Math.floor(height / 3))
|
|
676
|
+
refineScroll.current?.scrollBy(key.name === "up" ? -1 : key.name === "down" ? 1 : key.name === "pageup" ? -page : page)
|
|
649
677
|
} else if (key.name === "left" || (key.name === "tab" && key.shift)) setRefine({ ...r, choice: (r.choice + REFINE_CHOICES.length - 1) % REFINE_CHOICES.length })
|
|
650
678
|
else if (key.name === "right" || key.name === "tab") setRefine({ ...r, choice: (r.choice + 1) % REFINE_CHOICES.length })
|
|
651
679
|
else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") chooseRefine(REFINE_CHOICES[r.choice]!.id)
|
|
@@ -684,7 +712,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
684
712
|
const panes = (width >= SPLIT_MIN && !chatWide) || (width < SPLIT_MIN && narrowView === "details")
|
|
685
713
|
const insp = inspRef.current
|
|
686
714
|
const panelsOpen = width >= SPLIT_MIN && !chatWide
|
|
687
|
-
|
|
715
|
+
// Files takes the keys only where it is a panel: a narrow column shows it as a line of the info strip.
|
|
716
|
+
const filesPanel = (panelsOpen ? Math.floor(width / 2) - 3 : width - 4) >= 80
|
|
717
|
+
const shown = (f: Focus) => f === "activity" || (f === "files" && filesPanel) || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && panelsOpen)
|
|
688
718
|
if (panes && key.name === "tab" && key.shift && !entryRef.current) {
|
|
689
719
|
// Consumed here: the prompt can take focus within this key press and must not also act on it.
|
|
690
720
|
key.preventDefault()
|
|
@@ -729,6 +759,13 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
729
759
|
if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && panelsOpen) return setNav((n) => ({ ...n, focus: "chat" }))
|
|
730
760
|
if (!key.ctrl && !key.meta) return
|
|
731
761
|
}
|
|
762
|
+
// pgup and pgdn scroll the conversation from the prompt, as the wheel does; pgdn at the end follows new lines again.
|
|
763
|
+
if ((key.name === "pageup" || key.name === "pagedown") && !shortcutsRef.current && !entryRef.current?.secret) {
|
|
764
|
+
key.preventDefault()
|
|
765
|
+
const page = Math.max(3, height - 10)
|
|
766
|
+
scrollRef.current?.scrollBy(key.name === "pageup" ? -page : page)
|
|
767
|
+
return
|
|
768
|
+
}
|
|
732
769
|
// ? on an empty prompt: every key in one panel; esc or ? closes it.
|
|
733
770
|
if (shortcutsRef.current) {
|
|
734
771
|
if (key.name === "escape" || key.sequence === "?") setShortcuts(false)
|
|
@@ -809,11 +846,12 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
809
846
|
else if (!aborted()) setActivity(`Replaying step ${event.step} from memory…`)
|
|
810
847
|
}
|
|
811
848
|
if (event.type === "memory") {
|
|
812
|
-
|
|
849
|
+
record("memory", event.text, shows(event, detailRef.current))
|
|
813
850
|
markRef.current = Date.now()
|
|
814
851
|
}
|
|
815
852
|
if (event.type === "usage") useTokens(`run-${runIndex.current}`, event.usage.input + event.usage.output)
|
|
816
853
|
if (event.type === "log") {
|
|
854
|
+
// Engine info logs stay in the session log: they would bury the steps in the panel.
|
|
817
855
|
if (shows(event, detailRef.current)) push(event.level === "error" ? "err" : "sys", event.text)
|
|
818
856
|
if (!aborted()) setActivity(activityFromLog(event.text))
|
|
819
857
|
}
|
|
@@ -909,6 +947,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
909
947
|
if (ev.type === "usage") useTokens(`case-${s.index}`, ev.usage.input + ev.usage.output)
|
|
910
948
|
else if (!shows(ev, detailRef.current)) {
|
|
911
949
|
if (ev.type === "log" && !aborted()) setActivity(`${p}${activityFromLog(ev.text)}`)
|
|
950
|
+
else if (ev.type === "memory") record("memory", p + ev.text, false)
|
|
912
951
|
} else if (ev.type === "log") push(ev.level === "error" ? "err" : "sys", p + ev.text)
|
|
913
952
|
else if (ev.type === "memory") push("memory", p + ev.text)
|
|
914
953
|
else if (ev.type === "thought") push("thought", p + stepIntent(ev.step, ev.text, detailRef.current))
|
|
@@ -928,25 +967,32 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
928
967
|
case "info":
|
|
929
968
|
push(e.ok === undefined ? "code" : e.ok ? "ok" : "err", e.text)
|
|
930
969
|
return
|
|
970
|
+
case "call-start":
|
|
971
|
+
startCall(e.id, e.text)
|
|
972
|
+
if (!aborted()) setActivity(`Agent: ${clip(e.text, 60)}…`)
|
|
973
|
+
return
|
|
931
974
|
case "call":
|
|
932
|
-
pushTool(e.text, e.ok)
|
|
933
|
-
if (!aborted()) setActivity(
|
|
975
|
+
pushTool(e.text, e.ok, e.id)
|
|
976
|
+
if (!aborted()) setActivity(nowWorking())
|
|
934
977
|
return
|
|
935
978
|
case "agent": {
|
|
936
979
|
const ev = e.event
|
|
937
980
|
inspect(ev)
|
|
938
981
|
if (ev.kind === "plan") return
|
|
939
982
|
if (ev.kind === "usage") useTokens(`agent-tool-${runIndex.current}`, ev.usage.input + ev.usage.output)
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
else if (ev.kind === "
|
|
945
|
-
else if (ev.kind === "
|
|
983
|
+
else {
|
|
984
|
+
// Every line reaches Activity; the conversation keeps the ones this detail level shows.
|
|
985
|
+
const shown = shows(ev, detailRef.current)
|
|
986
|
+
if (ev.kind === "start") startCall(ev.id, ev.text)
|
|
987
|
+
else if (ev.kind === "info") record("code", ev.text, shown)
|
|
988
|
+
else if (ev.kind === "say") record("thought", ev.text, shown)
|
|
989
|
+
else if (ev.kind === "tool") pushTool(ev.text, ev.ok, ev.id, shown)
|
|
990
|
+
else if (ev.kind === "test") record(ev.ok ? "ok" : "err", ev.text, shown)
|
|
946
991
|
}
|
|
947
992
|
if (aborted()) return
|
|
948
993
|
const who = codingRef.current ? "Coding agent" : "Agent"
|
|
949
|
-
if (ev.kind === "
|
|
994
|
+
if (ev.kind === "start") setActivity(`${who}: ${clip(ev.text, 60)}…`)
|
|
995
|
+
else if (ev.kind === "tool") setActivity(`${who}: ${clip(ev.text, 60)}`)
|
|
950
996
|
else if (ev.kind === "info" && ev.text.startsWith("ForgeQE runs the test")) setActivity("ForgeQE is running the new test…")
|
|
951
997
|
else if (ev.kind === "say") setActivity(`${who} is working…`)
|
|
952
998
|
return
|
|
@@ -1003,17 +1049,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1003
1049
|
return
|
|
1004
1050
|
}
|
|
1005
1051
|
// ForgeQE's own agent: its tool calls (Azure DevOps and other MCP servers, commands) and what it says between them,
|
|
1006
|
-
// one line each in brief;
|
|
1007
|
-
|
|
1052
|
+
// one line each in brief in the conversation; its file reads and searches only in Activity, or with /details.
|
|
1053
|
+
const shown = shows(e, detailRef.current)
|
|
1054
|
+
if (e.kind === "start") startCall(e.id, e.text)
|
|
1055
|
+
else if (e.kind === "tool") pushTool(e.text, e.ok, e.id, showsAgentTool(e.text, e.ok, detailRef.current))
|
|
1008
1056
|
else if (e.kind === "say") push("thought", briefText(e.text, detailRef.current, 200))
|
|
1009
|
-
else if (
|
|
1010
|
-
|
|
1011
|
-
if (e.kind === "tool") pushTool(e.text, e.ok)
|
|
1012
|
-
if (e.kind === "test") push(e.ok ? "ok" : "err", e.text)
|
|
1013
|
-
}
|
|
1057
|
+
else if (e.kind === "info") record("code", e.text, shown)
|
|
1058
|
+
else if (e.kind === "test") record(e.ok ? "ok" : "err", e.text, shown)
|
|
1014
1059
|
if (abortRef.current?.signal.aborted) return
|
|
1015
|
-
if (e.kind === "
|
|
1016
|
-
else if (e.kind === "
|
|
1060
|
+
if (e.kind === "start") setActivity(`Agent: ${clip(e.text, 60)}…`)
|
|
1061
|
+
else if (e.kind === "tool" || e.kind === "say") setActivity(nowWorking())
|
|
1017
1062
|
}
|
|
1018
1063
|
|
|
1019
1064
|
const toolContext = (agent: boolean, signal?: AbortSignal) => ({
|
|
@@ -1050,6 +1095,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1050
1095
|
}
|
|
1051
1096
|
|
|
1052
1097
|
const finish = (finalStatus: string, followUps: FollowUp[] = []) => {
|
|
1098
|
+
// A call the request ended inside of (stopped, or its result never came) is not left spinning in Activity.
|
|
1099
|
+
setLines((prev) => (prev.some((l) => l.kind === "running") ? prev.map((l) => (l.kind === "running" ? { ...l, kind: "tool" as const, text: `${l.text} · ${finalStatus === "stopped" ? "stopped" : "no result"}` } : l)) : prev))
|
|
1053
1100
|
setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on, followUps }))
|
|
1054
1101
|
progressDone(inspRef.current, finalStatus === "stopped")
|
|
1055
1102
|
bump()
|
|
@@ -1113,7 +1160,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1113
1160
|
const brain = brainRef.current
|
|
1114
1161
|
const onAbort = () => void brain.abort()
|
|
1115
1162
|
controller.signal.addEventListener("abort", onAbort, { once: true })
|
|
1116
|
-
if (!controller.signal.aborted) setActivity("
|
|
1163
|
+
if (!controller.signal.aborted) setActivity("Reading your request and deciding the first step…")
|
|
1117
1164
|
const answer = await brain.ask(fresh ? carryOver(convoRef.current, text) : text)
|
|
1118
1165
|
controller.signal.removeEventListener("abort", onAbort)
|
|
1119
1166
|
if (answer.outcome !== "stopped") convoRef.current.push({ user: text, reply: answer.reply })
|
|
@@ -1347,29 +1394,21 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1347
1394
|
const running = status === "running"
|
|
1348
1395
|
const elapsed = started.current != null ? formatDuration(Date.now() - started.current) : ""
|
|
1349
1396
|
const modeColor = running ? theme.warn : status === "fail" ? theme.err : theme.accent
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
? "coding agent"
|
|
1359
|
-
: "agent"
|
|
1360
|
-
: status === "done" || status === "fail" || status === "stopped"
|
|
1361
|
-
? status
|
|
1362
|
-
: options.url
|
|
1363
|
-
? hostOf(options.url)
|
|
1364
|
-
: headed
|
|
1365
|
-
? "headed"
|
|
1366
|
-
: ""
|
|
1367
|
-
const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
|
|
1397
|
+
// What a running request has got to, for the line under the conversation: a suite's case or the browser's step.
|
|
1398
|
+
const progressText = suiteProgress
|
|
1399
|
+
? `case ${Math.min(suiteProgress.done + 1, suiteProgress.total)}/${suiteProgress.total}`
|
|
1400
|
+
: browsing
|
|
1401
|
+
? step > maxSteps
|
|
1402
|
+
? `step ${step}, past ${maxSteps}`
|
|
1403
|
+
: `step ${step}/${maxSteps}`
|
|
1404
|
+
: ""
|
|
1368
1405
|
const footerVersion = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
|
|
1369
|
-
//
|
|
1406
|
+
// The footer is the status line, the one place for the model and its profile (tab switches it), what the session
|
|
1407
|
+
// used, and the version; the conversation's footer adds the folder on its left. Nothing above repeats them.
|
|
1370
1408
|
const sessionTokens = meterSince(sessionMeter.current).total
|
|
1371
1409
|
const usedTokens = sessionTokens.input + sessionTokens.output
|
|
1372
|
-
const
|
|
1410
|
+
const modelText = configRef.current ? `${modelLabel(configRef.current)}${profile ? ` (${profile})` : ""}` : model
|
|
1411
|
+
const footerRight = [modelText, usedTokens ? `${formatTokens(usedTokens)} tokens` : "", footerVersion].filter(Boolean).join(" · ")
|
|
1373
1412
|
// Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
|
|
1374
1413
|
const suggTrigger = triggerAt(promptText)
|
|
1375
1414
|
const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
|
|
@@ -1435,12 +1474,15 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1435
1474
|
</box>
|
|
1436
1475
|
)
|
|
1437
1476
|
|
|
1438
|
-
|
|
1477
|
+
/** The prompt and its row of buttons, or a panel in their place; `maxHeight` is the rows a panel may take. */
|
|
1478
|
+
const renderControls = (promptWidth: number, placeholder: string, rows: [number, number], maxHeight: number) => {
|
|
1439
1479
|
if (refining && !dialog) {
|
|
1440
1480
|
return (
|
|
1441
1481
|
<RefinePanel
|
|
1442
1482
|
state={refining}
|
|
1443
1483
|
width={promptWidth}
|
|
1484
|
+
maxHeight={maxHeight}
|
|
1485
|
+
scrollRef={refineScroll}
|
|
1444
1486
|
onChoose={chooseRefine}
|
|
1445
1487
|
onHover={(i) => refining.phase === "done" && setRefine({ ...refining, choice: i })}
|
|
1446
1488
|
/>
|
|
@@ -1460,6 +1502,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1460
1502
|
selected={Math.min(choice, spec.options.length - 1)}
|
|
1461
1503
|
onChoose={choose}
|
|
1462
1504
|
onHover={setChoice}
|
|
1505
|
+
maxHeight={maxHeight}
|
|
1463
1506
|
/>
|
|
1464
1507
|
)
|
|
1465
1508
|
}
|
|
@@ -1513,11 +1556,6 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1513
1556
|
placeholder={entry ? `${entry.label}${entry.secret ? " (hidden as you type)" : ""}${entry.initial ? ` · Enter keeps ${entry.initial}` : ""} · esc cancels` : placeholder}
|
|
1514
1557
|
masked={entry?.secret ? secretLength : undefined}
|
|
1515
1558
|
modeColor={modeColor}
|
|
1516
|
-
profile={profile}
|
|
1517
|
-
model={model}
|
|
1518
|
-
right={right}
|
|
1519
|
-
rightColor={rightColor}
|
|
1520
|
-
compact={!home}
|
|
1521
1559
|
focused={!palette && !refining && (panelsOpen ? nav.focus === "chat" : wide || narrowView === "chat")}
|
|
1522
1560
|
initialValue={draft}
|
|
1523
1561
|
onText={(t) => {
|
|
@@ -1555,7 +1593,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1555
1593
|
start: startActions(cfgHome, 4, undefined, recent.some((r) => r.kind === "run")),
|
|
1556
1594
|
recent,
|
|
1557
1595
|
first: getStarted(cfgHome, items),
|
|
1558
|
-
facts: projectLine(cfgHome, process.cwd(), {
|
|
1596
|
+
facts: projectLine(cfgHome, process.cwd(), { signIns }),
|
|
1559
1597
|
}
|
|
1560
1598
|
}
|
|
1561
1599
|
const { items: setup, start, recent, first, facts } = setupRef.current
|
|
@@ -1575,10 +1613,12 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1575
1613
|
const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
|
|
1576
1614
|
const recentRows = recent.length ? 1 + 1 + recent.length : 0
|
|
1577
1615
|
const setupRows = loading || open.length ? 2 : 0
|
|
1578
|
-
const fixed = 5 + 2 + 1 +
|
|
1616
|
+
const fixed = 5 + 2 + 1 + 3 + 1 + 1 + 1 // logo, project line, gap, prompt border, buttons, footer, one row to spare
|
|
1579
1617
|
const room = height - fixed
|
|
1580
1618
|
const want = Math.max(5, Math.min(promptLines, room - 1))
|
|
1581
|
-
|
|
1619
|
+
// Refine or a question in the prompt's place takes the room the sections would: they are for starting, not deciding.
|
|
1620
|
+
const deciding = Boolean(refining || dialog)
|
|
1621
|
+
let left = deciding ? 0 : room - want
|
|
1582
1622
|
const showStart = startRows > 0 && left >= startRows
|
|
1583
1623
|
if (showStart) left -= startRows
|
|
1584
1624
|
const showRecent = recentRows > 0 && left >= recentRows
|
|
@@ -1608,7 +1648,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1608
1648
|
</box>
|
|
1609
1649
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1610
1650
|
<box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0 }}>
|
|
1611
|
-
{renderControls(promptWidth, "Describe what you want to test…", [5, maxRows])}
|
|
1651
|
+
{renderControls(promptWidth, "Describe what you want to test…", [5, maxRows], height - 5 - 2 - 1 - 1 - 1)}
|
|
1612
1652
|
{showStart ? (
|
|
1613
1653
|
<box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
|
|
1614
1654
|
{heading(offerTitle)}
|
|
@@ -1695,14 +1735,6 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1695
1735
|
} catch {}
|
|
1696
1736
|
contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
|
|
1697
1737
|
}
|
|
1698
|
-
const llm = cfgNow?.llm
|
|
1699
|
-
const sessionInfo: SessionInfo = {
|
|
1700
|
-
model: cfgNow ? `${llm?.provider === "mock" ? "offline demo" : llm?.deployment || llm?.model || "none"}${profile ? ` (${profile})` : ""}` : "…",
|
|
1701
|
-
cwd: shortCwd(),
|
|
1702
|
-
...(cfgNow?.domain ? { domain: `${cfgNow.domain.industry}${cfgNow.domain.regulations.length ? ` (${cfgNow.domain.regulations.join(", ")})` : ""}` } : {}),
|
|
1703
|
-
started: sessionStarted.current,
|
|
1704
|
-
requests: requests.current,
|
|
1705
|
-
}
|
|
1706
1738
|
const contextPanel: ContextInfo = {
|
|
1707
1739
|
tokens: insp.context,
|
|
1708
1740
|
limit: CONTEXT_LIMIT,
|
|
@@ -1721,22 +1753,38 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1721
1753
|
const activityLines = lines.filter((l) => l.pane === "activity")
|
|
1722
1754
|
const stop = () => openDialog("stop")
|
|
1723
1755
|
|
|
1756
|
+
// Files, context, and tokens as three panels where there is room for them, else as one line.
|
|
1757
|
+
const INFO_PANELS_MIN = 80
|
|
1758
|
+
const infoRows = (paneWidth: number) => (paneWidth >= INFO_PANELS_MIN ? 8 : 1)
|
|
1759
|
+
const infoPanels = (paneWidth: number) => {
|
|
1760
|
+
if (paneWidth < INFO_PANELS_MIN) return <InfoStrip s={insp} c={contextPanel} total={spent.total} width={paneWidth} />
|
|
1761
|
+
const bottom = 8 // the context panel's six rows, and the border
|
|
1762
|
+
const third = Math.floor(paneWidth / 3)
|
|
1763
|
+
const sel = nav.focus === "files" ? Math.min(nav.files, Math.max(0, filesByUse(insp).length - 1)) : undefined
|
|
1764
|
+
return (
|
|
1765
|
+
<box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
|
|
1766
|
+
<FilesPanel s={insp} width={third} height={bottom} selected={sel} onFocus={() => setNav((n) => ({ ...n, focus: "files" }))} onOpen={openFile} />
|
|
1767
|
+
<ContextPanel c={contextPanel} width={third} height={bottom} />
|
|
1768
|
+
<TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
|
|
1769
|
+
</box>
|
|
1770
|
+
)
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1724
1773
|
/**
|
|
1725
|
-
* The
|
|
1726
|
-
* Rows are counted, not left to flex: the activity gets what the
|
|
1774
|
+
* The Activities column: who is working and on what, the agent's plan with each task's state, and every call, step,
|
|
1775
|
+
* and note as it happens. Rows are counted, not left to flex: the activity gets what the plan leaves.
|
|
1727
1776
|
*/
|
|
1728
|
-
const
|
|
1729
|
-
const
|
|
1730
|
-
const plan = planRows(insp, Math.max(3, Math.min(
|
|
1731
|
-
const activityRows = Math.max(5, rowsAvailable -
|
|
1732
|
-
const third = Math.floor(paneWidth / 3)
|
|
1777
|
+
const activityColumn = (paneWidth: number, rowsAvailable: number) => {
|
|
1778
|
+
const where = nowNext(insp)
|
|
1779
|
+
const plan = planRows(insp, Math.max(3, Math.min(10, Math.floor(rowsAvailable / 4))))
|
|
1780
|
+
const activityRows = Math.max(5, rowsAvailable - STATUS_ROWS - 1 - plan)
|
|
1733
1781
|
const focusOn = (f: Focus) => () => setNav((n) => ({ ...n, focus: f }))
|
|
1734
|
-
const sel =
|
|
1782
|
+
const sel = nav.focus === "plan" ? Math.min(nav.plan, Math.max(0, planLines(insp).length - 1)) : undefined
|
|
1735
1783
|
return (
|
|
1736
1784
|
<box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
|
|
1737
1785
|
<StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress || planProgress(insp)} actions={paneButtons} />
|
|
1738
1786
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1739
|
-
{plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel
|
|
1787
|
+
{plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel} onFocus={focusOn("plan")} /> : null}
|
|
1740
1788
|
<ActivityPanel
|
|
1741
1789
|
lines={activityLines}
|
|
1742
1790
|
scrollRef={activityScroll}
|
|
@@ -1746,17 +1794,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1746
1794
|
focused={nav.focus === "activity"}
|
|
1747
1795
|
onFocus={focusOn("activity")}
|
|
1748
1796
|
onFilter={(filter) => setNav((n) => ({ ...n, filter, focus: "activity" }))}
|
|
1797
|
+
now={running ? where : undefined}
|
|
1749
1798
|
/>
|
|
1750
|
-
<box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
|
|
1751
|
-
<FilesPanel s={insp} width={third} height={bottom} selected={sel("files")} onFocus={focusOn("files")} onOpen={openFile} />
|
|
1752
|
-
<ContextPanel c={contextPanel} width={third} height={bottom} />
|
|
1753
|
-
<TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
|
|
1754
|
-
</box>
|
|
1755
1799
|
</box>
|
|
1756
1800
|
)
|
|
1757
1801
|
}
|
|
1758
1802
|
|
|
1759
|
-
const transcript = (
|
|
1803
|
+
const transcript = (every: Line[], style: Record<string, unknown>) => {
|
|
1804
|
+
// Quiet lines (reads, searches, calls in progress) are the Activity panel's; the conversation stays readable.
|
|
1805
|
+
const all = every.filter((l) => !l.quiet)
|
|
1806
|
+
return (
|
|
1760
1807
|
<scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
|
|
1761
1808
|
{all.map((line, i) => (
|
|
1762
1809
|
<TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
|
|
@@ -1767,28 +1814,31 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1767
1814
|
<Spinner kind="dots" color={theme.memory} />
|
|
1768
1815
|
<text>
|
|
1769
1816
|
<span fg={theme.memory}>{` ${activity || "Working…"}`}</span>
|
|
1770
|
-
<span fg={theme.faint}>{` (${elapsed
|
|
1817
|
+
<span fg={theme.faint}>{` (${[elapsed, progressText, tokens ? `${formatTokens(tokens)} tokens` : "", "esc to interrupt"].filter(Boolean).join(" · ")})`}</span>
|
|
1771
1818
|
</text>
|
|
1772
1819
|
</box>
|
|
1773
1820
|
) : null}
|
|
1774
1821
|
</scrollbox>
|
|
1775
|
-
|
|
1822
|
+
)
|
|
1823
|
+
}
|
|
1776
1824
|
// A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
|
|
1777
|
-
|
|
1825
|
+
// A panel in the prompt's place (Refine, a question) may take most of the screen, leaving the conversation a few rows.
|
|
1826
|
+
const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.floor(height * 0.45))], Math.max(8, height - 8))
|
|
1778
1827
|
|
|
1779
1828
|
const split = panelsOpen
|
|
1780
1829
|
if (split) {
|
|
1781
|
-
|
|
1830
|
+
// Half the width for the Activities column; the conversation, with files, context, and tokens above it, the other half.
|
|
1831
|
+
const leftWidth = Math.floor(width / 2)
|
|
1782
1832
|
return (
|
|
1783
1833
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1784
1834
|
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1785
1835
|
<box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1786
|
-
|
|
1836
|
+
{infoPanels(leftWidth - 3)}
|
|
1787
1837
|
{transcript(chatLines, {})}
|
|
1788
1838
|
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
|
|
1789
1839
|
</box>
|
|
1790
1840
|
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1791
|
-
{
|
|
1841
|
+
{activityColumn(width - leftWidth - 3, height - 2)}
|
|
1792
1842
|
</box>
|
|
1793
1843
|
</box>
|
|
1794
1844
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
@@ -1801,7 +1851,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1801
1851
|
if (!wide && narrowView === "details") {
|
|
1802
1852
|
return (
|
|
1803
1853
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1804
|
-
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>
|
|
1854
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>
|
|
1855
|
+
{activityColumn(promptWidth, height - 3 - (dialog ? 8 : 0) - infoRows(promptWidth))}
|
|
1856
|
+
{infoPanels(promptWidth)}
|
|
1857
|
+
</box>
|
|
1805
1858
|
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
|
|
1806
1859
|
{dialog ? (
|
|
1807
1860
|
// Stop, a question, or "your turn": the same panel the chat view shows in place of the prompt.
|
package/src/tui/components.tsx
CHANGED
|
@@ -42,14 +42,8 @@ export type PromptProps = {
|
|
|
42
42
|
maxRows: number
|
|
43
43
|
placeholder: string
|
|
44
44
|
modeColor: string
|
|
45
|
-
profile: string
|
|
46
|
-
model: string
|
|
47
|
-
right?: string
|
|
48
|
-
rightColor?: string
|
|
49
45
|
/** A secret being entered: the box shows one dot per character instead of a text field, so the value is never drawn. */
|
|
50
46
|
masked?: number
|
|
51
|
-
/** Beside a session panel that names the model and profile, and a status bar: no bottom row. */
|
|
52
|
-
compact?: boolean
|
|
53
47
|
/** Text the box starts with when `inputKey` changes (a refined request, an example from the palette). */
|
|
54
48
|
initialValue?: string
|
|
55
49
|
/** False while an overlay (the palette, a suggestion list) takes the keyboard. Default true. */
|
|
@@ -94,7 +88,6 @@ const PROMPT_KEYS: KeyBinding[] = [
|
|
|
94
88
|
]
|
|
95
89
|
|
|
96
90
|
export function Prompt(props: PromptProps) {
|
|
97
|
-
const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
|
|
98
91
|
const ref = useRef<TextareaRenderable>(null)
|
|
99
92
|
// The rows the text needs; the box shows that many between minRows and maxRows, so it also grows when the screen
|
|
100
93
|
// around it makes more room (the start screen folds a section for a long request).
|
|
@@ -158,20 +151,6 @@ export function Prompt(props: PromptProps) {
|
|
|
158
151
|
)}
|
|
159
152
|
</box>
|
|
160
153
|
</box>
|
|
161
|
-
{props.compact ? null : (
|
|
162
|
-
<box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
|
|
163
|
-
<text>
|
|
164
|
-
<span fg={theme.muted}>{"Model "}</span>
|
|
165
|
-
<span fg={theme.text}>{props.model || "…"}</span>
|
|
166
|
-
</text>
|
|
167
|
-
<text>
|
|
168
|
-
{props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
|
|
169
|
-
{props.right ? <span fg={theme.faint}>{" · "}</span> : null}
|
|
170
|
-
<span fg={theme.muted}>{"profile "}</span>
|
|
171
|
-
<span fg={theme.text}>{props.profile || "none"}</span>
|
|
172
|
-
</text>
|
|
173
|
-
</box>
|
|
174
|
-
)}
|
|
175
154
|
</box>
|
|
176
155
|
)
|
|
177
156
|
}
|
|
@@ -301,11 +280,33 @@ export type ChoicePanelProps = {
|
|
|
301
280
|
onHover: (index: number) => void
|
|
302
281
|
/** One option per line, for many or long options. */
|
|
303
282
|
vertical?: boolean
|
|
283
|
+
/** Rows the panel may take; a long detail is cut and a long list shows a window around the selected option. */
|
|
284
|
+
maxHeight?: number
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The options a vertical list shows in `room` rows, around the selected one: [first, last) and what is hidden. */
|
|
288
|
+
export function optionWindow(count: number, selected: number, room: number): { from: number; to: number } {
|
|
289
|
+
if (count <= room) return { from: 0, to: count }
|
|
290
|
+
// Two rows go to "↑ n more" and "↓ n more"; the selected option sits in the middle of the rest when it can.
|
|
291
|
+
const shown = Math.max(1, room - 2)
|
|
292
|
+
const from = Math.max(0, Math.min(count - shown, selected - Math.floor(shown / 2)))
|
|
293
|
+
return { from, to: from + shown }
|
|
304
294
|
}
|
|
305
295
|
|
|
306
296
|
/** Question with buttons, as the agent runtime shows for permissions: arrows or tab move, enter confirms, click works too. */
|
|
307
297
|
export function ChoicePanel(props: ChoicePanelProps) {
|
|
308
298
|
const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
|
|
299
|
+
// The detail's columns: the bar and padding take five, its own indent two, and one more for a word that wraps early.
|
|
300
|
+
const inner = Math.max(10, props.width - 8)
|
|
301
|
+
// Padding, title, the gap, the keys row under a vertical list, and the edge below: the rest is detail and options.
|
|
302
|
+
const budget = Math.max(6, (props.maxHeight ?? 999) - 6 - (props.vertical ? 1 : 0))
|
|
303
|
+
const optionRows = props.vertical ? Math.min(props.options.length, Math.max(3, budget - 2)) : 1
|
|
304
|
+
const detailRoom = Math.max(1, budget - optionRows)
|
|
305
|
+
const detailRows = props.detail ? wrappedLines(props.detail, inner) : 0
|
|
306
|
+
// A detail longer than its room is cut at a word, so the options stay on screen.
|
|
307
|
+
const detail = detailRows > detailRoom ? `${props.detail.slice(0, detailRoom * inner - 2).replace(/\s+\S*$/, "")}…` : props.detail
|
|
308
|
+
const win = props.vertical ? optionWindow(props.options.length, props.selected, optionRows) : { from: 0, to: props.options.length }
|
|
309
|
+
const more = (n: number, arrow: string) => (n > 0 ? <text fg={theme.faint}>{` ${arrow} ${n} more`}</text> : null)
|
|
309
310
|
return (
|
|
310
311
|
<box style={{ flexDirection: "column", width: props.width, flexShrink: 0 }}>
|
|
311
312
|
<box
|
|
@@ -326,10 +327,12 @@ export function ChoicePanel(props: ChoicePanelProps) {
|
|
|
326
327
|
<span fg={props.color}>{"△ "}</span>
|
|
327
328
|
<span fg={theme.text}>{props.title}</span>
|
|
328
329
|
</text>
|
|
329
|
-
{
|
|
330
|
+
{detail ? <text fg={theme.muted}>{` ${detail}`}</text> : null}
|
|
330
331
|
<box style={{ flexDirection: props.vertical ? "column" : "row", justifyContent: "space-between", marginTop: 1 }}>
|
|
331
332
|
<box style={{ flexDirection: props.vertical ? "column" : "row", alignItems: "flex-start" }}>
|
|
332
|
-
{props.
|
|
333
|
+
{props.vertical ? more(win.from, "↑") : null}
|
|
334
|
+
{props.options.slice(win.from, win.to).map((option, j) => {
|
|
335
|
+
const i = win.from + j
|
|
333
336
|
const active = i === props.selected
|
|
334
337
|
return (
|
|
335
338
|
<box
|
|
@@ -342,6 +345,7 @@ export function ChoicePanel(props: ChoicePanelProps) {
|
|
|
342
345
|
</box>
|
|
343
346
|
)
|
|
344
347
|
})}
|
|
348
|
+
{props.vertical ? more(props.options.length - win.to, "↓") : null}
|
|
345
349
|
</box>
|
|
346
350
|
<text style={{ marginTop: props.vertical ? 1 : 0 }}>
|
|
347
351
|
<span fg={theme.text}>{props.vertical ? "↑↓" : "⇆"}</span>
|