@capagents/forgeqe 1.1.4 → 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 +64 -17
- package/README.md +2 -1
- package/browserstack.example.yaml +14 -2
- package/docs/user-guide.html +78 -23
- package/forgeqe.example.yaml +18 -5
- package/package.json +2 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +8 -1
- package/prompts/defect-rca.md +43 -0
- package/prompts/test-agent.md +13 -2
- package/prompts/test-reviewer.md +8 -3
- package/prompts/test-rules.md +10 -4
- package/py/forgeqe_engine/browser.py +1 -1
- package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
- package/skills/playwright-typescript/SKILL.md +2 -1
- package/src/brain/index.ts +1 -1
- package/src/capabilities/registry.ts +34 -6
- package/src/cases/bugs.ts +209 -22
- package/src/cases/defectHistory.ts +193 -0
- package/src/cases/defects.ts +255 -0
- package/src/cases/github.ts +162 -0
- package/src/cases/goal.ts +1 -1
- package/src/cases/index.ts +23 -4
- package/src/cases/predict.ts +372 -0
- package/src/cases/rca.ts +258 -0
- package/src/cases/readiness.ts +38 -1
- package/src/cases/repro.ts +202 -0
- package/src/cases/types.ts +8 -1
- package/src/cli/help.ts +8 -2
- package/src/cli/index.ts +36 -7
- package/src/code/graph/graph.ts +518 -0
- package/src/code/graph/locators.ts +398 -0
- package/src/code/graph/parse.ts +439 -0
- package/src/code/graph/tools.ts +113 -0
- package/src/code/handoff.ts +93 -18
- package/src/code/loop.ts +30 -5
- package/src/code/profile.ts +14 -3
- package/src/code/replay.ts +1 -1
- package/src/code/review.ts +22 -0
- package/src/code/runtime.ts +8 -1
- package/src/config.ts +142 -18
- package/src/mcp/attachtools.ts +223 -0
- package/src/mcp/commands.ts +1 -1
- package/src/mcp/defecttools.ts +106 -0
- package/src/mcp/devicetools.ts +125 -0
- package/src/mcp/external.ts +42 -1
- package/src/mcp/server.ts +3 -1
- package/src/mcp/tools.ts +95 -10
- package/src/monitor/run.ts +1 -1
- package/src/report/data.ts +14 -0
- package/src/report/html.ts +15 -3
- package/src/report/template.html +44 -43
- package/src/run.ts +4 -2
- package/src/suite/report.ts +44 -11
- package/src/suite/run.ts +163 -16
- package/src/tui/App.tsx +153 -94
- 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/usage.ts +1 -1
- package/testmu.example.yaml +11 -0
package/src/tui/App.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import { stepVerdictLines } from "../cases/types.ts"
|
|
|
14
14
|
import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
|
|
15
15
|
import { authStatus, openInBrowser, signIn, signOut, type AuthIO } from "../auth/signin.ts"
|
|
16
16
|
import { HELP, parseCommand } from "../commands.ts"
|
|
17
|
-
import { loadConfig, selectProfile, type AppConfig } from "../config.ts"
|
|
17
|
+
import { loadConfig, selectProfile, type AppConfig, type BrowserProvider } from "../config.ts"
|
|
18
18
|
import { TOOLS, type AskQuestion, type ToolEvent } from "../mcp/tools.ts"
|
|
19
19
|
import type { executeRun } from "../run.ts"
|
|
20
20
|
import { bundleLogs } from "../sessionLog.ts"
|
|
@@ -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,
|
|
@@ -89,6 +90,10 @@ export type TuiOptions = {
|
|
|
89
90
|
/** First message, sent as if typed. */
|
|
90
91
|
goal?: string
|
|
91
92
|
headed?: boolean
|
|
93
|
+
/** Browser provider pinned for the session's runs (--browser-provider). */
|
|
94
|
+
browserProvider?: BrowserProvider
|
|
95
|
+
/** Named devices pinned for the session's runs (--devices). */
|
|
96
|
+
devices?: string[]
|
|
92
97
|
replay?: ReplayMode
|
|
93
98
|
memory?: boolean
|
|
94
99
|
/** Repo the agent works in and writes tests for; null: no tests unless asked; undefined: code.repo. */
|
|
@@ -119,6 +124,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
119
124
|
widthRef.current = width
|
|
120
125
|
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
121
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)
|
|
122
129
|
const submitRef = useRef<(value: string) => void>(() => {})
|
|
123
130
|
/** Refines what is in the prompt (phase two of the palette work wires it). */
|
|
124
131
|
const refineRef = useRef<() => void>(() => {})
|
|
@@ -235,17 +242,33 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
235
242
|
thinkingRef.current = thinking
|
|
236
243
|
|
|
237
244
|
/** Lines go to the chat, or while a request runs and they are not results, to the activity pane; `pane` overrides that. */
|
|
238
|
-
const push = (kind: Line["kind"], text: string, pane?: Pane) => {
|
|
245
|
+
const push = (kind: Line["kind"], text: string, pane?: Pane, quiet?: boolean) => {
|
|
239
246
|
const parts = kind === "user" || kind === "reply" ? [text] : text.split("\n")
|
|
240
247
|
const where = pane || (answeringRef.current ? "chat" : paneFor(kind, abortRef.current !== null))
|
|
241
248
|
setLines((prev) => {
|
|
242
249
|
const next = [...prev]
|
|
243
|
-
for (const part of parts) next.push({ id: idRef.current++, kind, text: part, pane: where })
|
|
244
|
-
return next
|
|
250
|
+
for (const part of parts) next.push({ id: idRef.current++, kind, text: part, pane: where, ...(quiet ? { quiet } : {}) })
|
|
251
|
+
return trimLines(next)
|
|
245
252
|
})
|
|
246
253
|
}
|
|
247
|
-
|
|
248
|
-
|
|
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
|
+
}
|
|
249
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. */
|
|
250
273
|
const inspect = (e: CodeEvent) => {
|
|
251
274
|
const s = inspRef.current
|
|
@@ -257,8 +280,14 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
257
280
|
else return
|
|
258
281
|
bump()
|
|
259
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
|
+
}
|
|
260
288
|
/** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
|
|
261
|
-
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)
|
|
262
291
|
/** A short note in the hint row that clears itself: "opened report.html". */
|
|
263
292
|
const flashTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
264
293
|
const flash = (text: string) => {
|
|
@@ -642,6 +671,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
642
671
|
}
|
|
643
672
|
} else if (r.phase === "error") {
|
|
644
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)
|
|
645
677
|
} else if (key.name === "left" || (key.name === "tab" && key.shift)) setRefine({ ...r, choice: (r.choice + REFINE_CHOICES.length - 1) % REFINE_CHOICES.length })
|
|
646
678
|
else if (key.name === "right" || key.name === "tab") setRefine({ ...r, choice: (r.choice + 1) % REFINE_CHOICES.length })
|
|
647
679
|
else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") chooseRefine(REFINE_CHOICES[r.choice]!.id)
|
|
@@ -680,7 +712,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
680
712
|
const panes = (width >= SPLIT_MIN && !chatWide) || (width < SPLIT_MIN && narrowView === "details")
|
|
681
713
|
const insp = inspRef.current
|
|
682
714
|
const panelsOpen = width >= SPLIT_MIN && !chatWide
|
|
683
|
-
|
|
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)
|
|
684
718
|
if (panes && key.name === "tab" && key.shift && !entryRef.current) {
|
|
685
719
|
// Consumed here: the prompt can take focus within this key press and must not also act on it.
|
|
686
720
|
key.preventDefault()
|
|
@@ -725,6 +759,13 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
725
759
|
if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && panelsOpen) return setNav((n) => ({ ...n, focus: "chat" }))
|
|
726
760
|
if (!key.ctrl && !key.meta) return
|
|
727
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
|
+
}
|
|
728
769
|
// ? on an empty prompt: every key in one panel; esc or ? closes it.
|
|
729
770
|
if (shortcutsRef.current) {
|
|
730
771
|
if (key.name === "escape" || key.sequence === "?") setShortcuts(false)
|
|
@@ -805,11 +846,12 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
805
846
|
else if (!aborted()) setActivity(`Replaying step ${event.step} from memory…`)
|
|
806
847
|
}
|
|
807
848
|
if (event.type === "memory") {
|
|
808
|
-
|
|
849
|
+
record("memory", event.text, shows(event, detailRef.current))
|
|
809
850
|
markRef.current = Date.now()
|
|
810
851
|
}
|
|
811
852
|
if (event.type === "usage") useTokens(`run-${runIndex.current}`, event.usage.input + event.usage.output)
|
|
812
853
|
if (event.type === "log") {
|
|
854
|
+
// Engine info logs stay in the session log: they would bury the steps in the panel.
|
|
813
855
|
if (shows(event, detailRef.current)) push(event.level === "error" ? "err" : "sys", event.text)
|
|
814
856
|
if (!aborted()) setActivity(activityFromLog(event.text))
|
|
815
857
|
}
|
|
@@ -905,6 +947,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
905
947
|
if (ev.type === "usage") useTokens(`case-${s.index}`, ev.usage.input + ev.usage.output)
|
|
906
948
|
else if (!shows(ev, detailRef.current)) {
|
|
907
949
|
if (ev.type === "log" && !aborted()) setActivity(`${p}${activityFromLog(ev.text)}`)
|
|
950
|
+
else if (ev.type === "memory") record("memory", p + ev.text, false)
|
|
908
951
|
} else if (ev.type === "log") push(ev.level === "error" ? "err" : "sys", p + ev.text)
|
|
909
952
|
else if (ev.type === "memory") push("memory", p + ev.text)
|
|
910
953
|
else if (ev.type === "thought") push("thought", p + stepIntent(ev.step, ev.text, detailRef.current))
|
|
@@ -924,25 +967,32 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
924
967
|
case "info":
|
|
925
968
|
push(e.ok === undefined ? "code" : e.ok ? "ok" : "err", e.text)
|
|
926
969
|
return
|
|
970
|
+
case "call-start":
|
|
971
|
+
startCall(e.id, e.text)
|
|
972
|
+
if (!aborted()) setActivity(`Agent: ${clip(e.text, 60)}…`)
|
|
973
|
+
return
|
|
927
974
|
case "call":
|
|
928
|
-
pushTool(e.text, e.ok)
|
|
929
|
-
if (!aborted()) setActivity(
|
|
975
|
+
pushTool(e.text, e.ok, e.id)
|
|
976
|
+
if (!aborted()) setActivity(nowWorking())
|
|
930
977
|
return
|
|
931
978
|
case "agent": {
|
|
932
979
|
const ev = e.event
|
|
933
980
|
inspect(ev)
|
|
934
981
|
if (ev.kind === "plan") return
|
|
935
982
|
if (ev.kind === "usage") useTokens(`agent-tool-${runIndex.current}`, ev.usage.input + ev.usage.output)
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
else if (ev.kind === "
|
|
941
|
-
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)
|
|
942
991
|
}
|
|
943
992
|
if (aborted()) return
|
|
944
993
|
const who = codingRef.current ? "Coding agent" : "Agent"
|
|
945
|
-
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)}`)
|
|
946
996
|
else if (ev.kind === "info" && ev.text.startsWith("ForgeQE runs the test")) setActivity("ForgeQE is running the new test…")
|
|
947
997
|
else if (ev.kind === "say") setActivity(`${who} is working…`)
|
|
948
998
|
return
|
|
@@ -999,21 +1049,22 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
999
1049
|
return
|
|
1000
1050
|
}
|
|
1001
1051
|
// ForgeQE's own agent: its tool calls (Azure DevOps and other MCP servers, commands) and what it says between them,
|
|
1002
|
-
// one line each in brief;
|
|
1003
|
-
|
|
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))
|
|
1004
1056
|
else if (e.kind === "say") push("thought", briefText(e.text, detailRef.current, 200))
|
|
1005
|
-
else if (
|
|
1006
|
-
|
|
1007
|
-
if (e.kind === "tool") pushTool(e.text, e.ok)
|
|
1008
|
-
if (e.kind === "test") push(e.ok ? "ok" : "err", e.text)
|
|
1009
|
-
}
|
|
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)
|
|
1010
1059
|
if (abortRef.current?.signal.aborted) return
|
|
1011
|
-
if (e.kind === "
|
|
1012
|
-
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())
|
|
1013
1062
|
}
|
|
1014
1063
|
|
|
1015
1064
|
const toolContext = (agent: boolean, signal?: AbortSignal) => ({
|
|
1016
1065
|
headed: headedRef.current,
|
|
1066
|
+
browserProvider: options.browserProvider,
|
|
1067
|
+
devices: options.devices,
|
|
1017
1068
|
maxSteps: stepsRef.current,
|
|
1018
1069
|
replay: options.replay,
|
|
1019
1070
|
memory: options.memory,
|
|
@@ -1044,6 +1095,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1044
1095
|
}
|
|
1045
1096
|
|
|
1046
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))
|
|
1047
1100
|
setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on, followUps }))
|
|
1048
1101
|
progressDone(inspRef.current, finalStatus === "stopped")
|
|
1049
1102
|
bump()
|
|
@@ -1107,7 +1160,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1107
1160
|
const brain = brainRef.current
|
|
1108
1161
|
const onAbort = () => void brain.abort()
|
|
1109
1162
|
controller.signal.addEventListener("abort", onAbort, { once: true })
|
|
1110
|
-
if (!controller.signal.aborted) setActivity("
|
|
1163
|
+
if (!controller.signal.aborted) setActivity("Reading your request and deciding the first step…")
|
|
1111
1164
|
const answer = await brain.ask(fresh ? carryOver(convoRef.current, text) : text)
|
|
1112
1165
|
controller.signal.removeEventListener("abort", onAbort)
|
|
1113
1166
|
if (answer.outcome !== "stopped") convoRef.current.push({ user: text, reply: answer.reply })
|
|
@@ -1341,29 +1394,21 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1341
1394
|
const running = status === "running"
|
|
1342
1395
|
const elapsed = started.current != null ? formatDuration(Date.now() - started.current) : ""
|
|
1343
1396
|
const modeColor = running ? theme.warn : status === "fail" ? theme.err : theme.accent
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
? "coding agent"
|
|
1353
|
-
: "agent"
|
|
1354
|
-
: status === "done" || status === "fail" || status === "stopped"
|
|
1355
|
-
? status
|
|
1356
|
-
: options.url
|
|
1357
|
-
? hostOf(options.url)
|
|
1358
|
-
: headed
|
|
1359
|
-
? "headed"
|
|
1360
|
-
: ""
|
|
1361
|
-
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
|
+
: ""
|
|
1362
1405
|
const footerVersion = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
|
|
1363
|
-
//
|
|
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.
|
|
1364
1408
|
const sessionTokens = meterSince(sessionMeter.current).total
|
|
1365
1409
|
const usedTokens = sessionTokens.input + sessionTokens.output
|
|
1366
|
-
const
|
|
1410
|
+
const modelText = configRef.current ? `${modelLabel(configRef.current)}${profile ? ` (${profile})` : ""}` : model
|
|
1411
|
+
const footerRight = [modelText, usedTokens ? `${formatTokens(usedTokens)} tokens` : "", footerVersion].filter(Boolean).join(" · ")
|
|
1367
1412
|
// Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
|
|
1368
1413
|
const suggTrigger = triggerAt(promptText)
|
|
1369
1414
|
const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
|
|
@@ -1429,12 +1474,15 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1429
1474
|
</box>
|
|
1430
1475
|
)
|
|
1431
1476
|
|
|
1432
|
-
|
|
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) => {
|
|
1433
1479
|
if (refining && !dialog) {
|
|
1434
1480
|
return (
|
|
1435
1481
|
<RefinePanel
|
|
1436
1482
|
state={refining}
|
|
1437
1483
|
width={promptWidth}
|
|
1484
|
+
maxHeight={maxHeight}
|
|
1485
|
+
scrollRef={refineScroll}
|
|
1438
1486
|
onChoose={chooseRefine}
|
|
1439
1487
|
onHover={(i) => refining.phase === "done" && setRefine({ ...refining, choice: i })}
|
|
1440
1488
|
/>
|
|
@@ -1454,6 +1502,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1454
1502
|
selected={Math.min(choice, spec.options.length - 1)}
|
|
1455
1503
|
onChoose={choose}
|
|
1456
1504
|
onHover={setChoice}
|
|
1505
|
+
maxHeight={maxHeight}
|
|
1457
1506
|
/>
|
|
1458
1507
|
)
|
|
1459
1508
|
}
|
|
@@ -1507,11 +1556,6 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1507
1556
|
placeholder={entry ? `${entry.label}${entry.secret ? " (hidden as you type)" : ""}${entry.initial ? ` · Enter keeps ${entry.initial}` : ""} · esc cancels` : placeholder}
|
|
1508
1557
|
masked={entry?.secret ? secretLength : undefined}
|
|
1509
1558
|
modeColor={modeColor}
|
|
1510
|
-
profile={profile}
|
|
1511
|
-
model={model}
|
|
1512
|
-
right={right}
|
|
1513
|
-
rightColor={rightColor}
|
|
1514
|
-
compact={!home}
|
|
1515
1559
|
focused={!palette && !refining && (panelsOpen ? nav.focus === "chat" : wide || narrowView === "chat")}
|
|
1516
1560
|
initialValue={draft}
|
|
1517
1561
|
onText={(t) => {
|
|
@@ -1541,7 +1585,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1541
1585
|
let signIns: Record<string, string> = {}
|
|
1542
1586
|
try {
|
|
1543
1587
|
// "Azure DevOps (PAT)": how each system's server signs in, as the session panel says it.
|
|
1544
|
-
signIns = Object.fromEntries(externalMcp(cfgHome).servers.filter((x) => x.signIn).map((x) => [x.label.replace(/ MCP$/, ""), x.signIn!.startsWith("PAT") ? "PAT" : "az login"]))
|
|
1588
|
+
signIns = Object.fromEntries(externalMcp(cfgHome, Bun.which, process.env, { cwd: process.cwd() }).servers.filter((x) => x.signIn).map((x) => [x.label.replace(/ MCP$/, ""), x.signIn!.startsWith("PAT") ? "PAT" : x.signIn!.startsWith("Azure") ? "az login" : x.signIn!]))
|
|
1545
1589
|
} catch {}
|
|
1546
1590
|
setupRef.current = {
|
|
1547
1591
|
key: setupKey,
|
|
@@ -1549,7 +1593,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1549
1593
|
start: startActions(cfgHome, 4, undefined, recent.some((r) => r.kind === "run")),
|
|
1550
1594
|
recent,
|
|
1551
1595
|
first: getStarted(cfgHome, items),
|
|
1552
|
-
facts: projectLine(cfgHome, process.cwd(), {
|
|
1596
|
+
facts: projectLine(cfgHome, process.cwd(), { signIns }),
|
|
1553
1597
|
}
|
|
1554
1598
|
}
|
|
1555
1599
|
const { items: setup, start, recent, first, facts } = setupRef.current
|
|
@@ -1569,10 +1613,12 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1569
1613
|
const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
|
|
1570
1614
|
const recentRows = recent.length ? 1 + 1 + recent.length : 0
|
|
1571
1615
|
const setupRows = loading || open.length ? 2 : 0
|
|
1572
|
-
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
|
|
1573
1617
|
const room = height - fixed
|
|
1574
1618
|
const want = Math.max(5, Math.min(promptLines, room - 1))
|
|
1575
|
-
|
|
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
|
|
1576
1622
|
const showStart = startRows > 0 && left >= startRows
|
|
1577
1623
|
if (showStart) left -= startRows
|
|
1578
1624
|
const showRecent = recentRows > 0 && left >= recentRows
|
|
@@ -1602,7 +1648,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1602
1648
|
</box>
|
|
1603
1649
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1604
1650
|
<box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0 }}>
|
|
1605
|
-
{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)}
|
|
1606
1652
|
{showStart ? (
|
|
1607
1653
|
<box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
|
|
1608
1654
|
{heading(offerTitle)}
|
|
@@ -1685,18 +1731,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1685
1731
|
let mcp: string[] = []
|
|
1686
1732
|
try {
|
|
1687
1733
|
// "Azure DevOps (az login)": which account a server works as decides what it may see, so the panel says it.
|
|
1688
|
-
mcp = externalMcp(cfgNow).servers.map((x) => (x.signIn ? `${x.label.replace(/ MCP$/, "")} (${x.signIn.startsWith("PAT") ? "PAT" : "az login"})` : x.label))
|
|
1734
|
+
mcp = externalMcp(cfgNow, Bun.which, process.env, { cwd: process.cwd() }).servers.map((x) => (x.signIn ? `${x.label.replace(/ MCP$/, "")} (${x.signIn.startsWith("PAT") ? "PAT" : x.signIn.startsWith("Azure") ? "az login" : x.signIn})` : x.label))
|
|
1689
1735
|
} catch {}
|
|
1690
1736
|
contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
|
|
1691
1737
|
}
|
|
1692
|
-
const llm = cfgNow?.llm
|
|
1693
|
-
const sessionInfo: SessionInfo = {
|
|
1694
|
-
model: cfgNow ? `${llm?.provider === "mock" ? "offline demo" : llm?.deployment || llm?.model || "none"}${profile ? ` (${profile})` : ""}` : "…",
|
|
1695
|
-
cwd: shortCwd(),
|
|
1696
|
-
...(cfgNow?.domain ? { domain: `${cfgNow.domain.industry}${cfgNow.domain.regulations.length ? ` (${cfgNow.domain.regulations.join(", ")})` : ""}` } : {}),
|
|
1697
|
-
started: sessionStarted.current,
|
|
1698
|
-
requests: requests.current,
|
|
1699
|
-
}
|
|
1700
1738
|
const contextPanel: ContextInfo = {
|
|
1701
1739
|
tokens: insp.context,
|
|
1702
1740
|
limit: CONTEXT_LIMIT,
|
|
@@ -1715,22 +1753,38 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1715
1753
|
const activityLines = lines.filter((l) => l.pane === "activity")
|
|
1716
1754
|
const stop = () => openDialog("stop")
|
|
1717
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
|
+
|
|
1718
1773
|
/**
|
|
1719
|
-
* The
|
|
1720
|
-
* 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.
|
|
1721
1776
|
*/
|
|
1722
|
-
const
|
|
1723
|
-
const
|
|
1724
|
-
const plan = planRows(insp, Math.max(3, Math.min(
|
|
1725
|
-
const activityRows = Math.max(5, rowsAvailable -
|
|
1726
|
-
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)
|
|
1727
1781
|
const focusOn = (f: Focus) => () => setNav((n) => ({ ...n, focus: f }))
|
|
1728
|
-
const sel =
|
|
1782
|
+
const sel = nav.focus === "plan" ? Math.min(nav.plan, Math.max(0, planLines(insp).length - 1)) : undefined
|
|
1729
1783
|
return (
|
|
1730
1784
|
<box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
|
|
1731
1785
|
<StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress || planProgress(insp)} actions={paneButtons} />
|
|
1732
1786
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1733
|
-
{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}
|
|
1734
1788
|
<ActivityPanel
|
|
1735
1789
|
lines={activityLines}
|
|
1736
1790
|
scrollRef={activityScroll}
|
|
@@ -1740,17 +1794,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1740
1794
|
focused={nav.focus === "activity"}
|
|
1741
1795
|
onFocus={focusOn("activity")}
|
|
1742
1796
|
onFilter={(filter) => setNav((n) => ({ ...n, filter, focus: "activity" }))}
|
|
1797
|
+
now={running ? where : undefined}
|
|
1743
1798
|
/>
|
|
1744
|
-
<box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
|
|
1745
|
-
<FilesPanel s={insp} width={third} height={bottom} selected={sel("files")} onFocus={focusOn("files")} onOpen={openFile} />
|
|
1746
|
-
<ContextPanel c={contextPanel} width={third} height={bottom} />
|
|
1747
|
-
<TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
|
|
1748
|
-
</box>
|
|
1749
1799
|
</box>
|
|
1750
1800
|
)
|
|
1751
1801
|
}
|
|
1752
1802
|
|
|
1753
|
-
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 (
|
|
1754
1807
|
<scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
|
|
1755
1808
|
{all.map((line, i) => (
|
|
1756
1809
|
<TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
|
|
@@ -1761,28 +1814,31 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1761
1814
|
<Spinner kind="dots" color={theme.memory} />
|
|
1762
1815
|
<text>
|
|
1763
1816
|
<span fg={theme.memory}>{` ${activity || "Working…"}`}</span>
|
|
1764
|
-
<span fg={theme.faint}>{` (${elapsed
|
|
1817
|
+
<span fg={theme.faint}>{` (${[elapsed, progressText, tokens ? `${formatTokens(tokens)} tokens` : "", "esc to interrupt"].filter(Boolean).join(" · ")})`}</span>
|
|
1765
1818
|
</text>
|
|
1766
1819
|
</box>
|
|
1767
1820
|
) : null}
|
|
1768
1821
|
</scrollbox>
|
|
1769
|
-
|
|
1822
|
+
)
|
|
1823
|
+
}
|
|
1770
1824
|
// A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
|
|
1771
|
-
|
|
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))
|
|
1772
1827
|
|
|
1773
1828
|
const split = panelsOpen
|
|
1774
1829
|
if (split) {
|
|
1775
|
-
|
|
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)
|
|
1776
1832
|
return (
|
|
1777
1833
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1778
1834
|
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1779
1835
|
<box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1780
|
-
|
|
1836
|
+
{infoPanels(leftWidth - 3)}
|
|
1781
1837
|
{transcript(chatLines, {})}
|
|
1782
1838
|
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
|
|
1783
1839
|
</box>
|
|
1784
1840
|
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1785
|
-
{
|
|
1841
|
+
{activityColumn(width - leftWidth - 3, height - 2)}
|
|
1786
1842
|
</box>
|
|
1787
1843
|
</box>
|
|
1788
1844
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
@@ -1795,7 +1851,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1795
1851
|
if (!wide && narrowView === "details") {
|
|
1796
1852
|
return (
|
|
1797
1853
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1798
|
-
<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>
|
|
1799
1858
|
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
|
|
1800
1859
|
{dialog ? (
|
|
1801
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>
|