@capagents/forgeqe 1.0.8 → 1.1.0
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 +67 -11
- package/docs/user-guide.html +65 -11
- package/forgeqe.example.yaml +1 -1
- package/package.json +1 -1
- package/prompts/brain.md +8 -2
- package/prompts/refine.md +8 -4
- package/src/brain/index.ts +9 -2
- package/src/code/runtime.ts +12 -0
- package/src/commands.ts +6 -1
- package/src/mcp/external.ts +28 -5
- package/src/mcp/tools.ts +2 -2
- package/src/tui/App.tsx +265 -74
- package/src/tui/components.tsx +96 -62
- package/src/tui/landing.ts +201 -0
- package/src/tui/next.ts +1 -1
- package/src/tui/palette.tsx +3 -1
- package/src/tui/refine-panel.tsx +23 -2
- package/src/tui/shortcuts.tsx +66 -0
- package/src/tui/transcript.tsx +155 -150
package/src/tui/App.tsx
CHANGED
|
@@ -26,14 +26,17 @@ import { meterSnapshot, requestLine } from "../usage.ts"
|
|
|
26
26
|
import { cleanSelection, copyText } from "./clipboard.ts"
|
|
27
27
|
import { ButtonBar, ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Button, type Hint } from "./components.tsx"
|
|
28
28
|
import { setupChecklist, type SetupItem } from "./setup.ts"
|
|
29
|
-
import {
|
|
29
|
+
import { initWorkspace } from "../init.ts"
|
|
30
|
+
import { ShortcutsPanel } from "./shortcuts.tsx"
|
|
31
|
+
import { ago, getStarted, projectLine, setupSentence, type Fact, type GetStarted, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
|
|
32
|
+
import { TIPS, formatDuration, formatTokens, packageVersion, shortCwd, theme } from "./theme.ts"
|
|
30
33
|
import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
|
|
31
34
|
import { Dialog, dialogSpec } from "./dialogs.ts"
|
|
32
35
|
import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
|
|
33
36
|
import { featuresText } from "../capabilities/render.ts"
|
|
34
37
|
import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
|
|
35
38
|
import { commandSuggestions, complete, lookupWorkItem, recentRuns, referenceSuggestions, repoFiles, triggerAt, type Suggestion, type WorkItem } from "./suggest.ts"
|
|
36
|
-
import { REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
|
|
39
|
+
import { initialChoice, REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
|
|
37
40
|
import { refine } from "../refine.ts"
|
|
38
41
|
import { nextSteps, type NextStep } from "./next.ts"
|
|
39
42
|
import { CAPABILITIES } from "../capabilities/registry.ts"
|
|
@@ -154,6 +157,12 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
154
157
|
const [next, setNext] = useState<NextStep[]>([])
|
|
155
158
|
/** What the prompt holds, for suggestions; and the suggestion state: which is selected, which word was dismissed. */
|
|
156
159
|
const [promptText, setPromptText] = useState("")
|
|
160
|
+
// The rows the request in the prompt needs; the start screen gives a long one room by folding its other sections.
|
|
161
|
+
const [promptLines, setPromptLines] = useState(1)
|
|
162
|
+
const [tipIndex, setTipIndex] = useState(() => Math.floor(Math.random() * TIPS.length))
|
|
163
|
+
const [shortcuts, setShortcuts] = useState(false)
|
|
164
|
+
const shortcutsRef = useRef(false)
|
|
165
|
+
shortcutsRef.current = shortcuts
|
|
157
166
|
const [sugg, setSugg] = useState<{ selected: number; dismissed?: string }>({ selected: 0 })
|
|
158
167
|
const [workItem, setWorkItem] = useState<WorkItem | null | undefined>(undefined)
|
|
159
168
|
const suggRef = useRef<{ list: Suggestion[]; trigger: ReturnType<typeof triggerAt> }>({ list: [], trigger: null })
|
|
@@ -170,8 +179,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
170
179
|
/** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
|
|
171
180
|
const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
|
|
172
181
|
// The split view's chat at the full width, for reading a long answer; the panels come back with Split view.
|
|
173
|
-
const [chatWide, setChatWide] = useState(
|
|
174
|
-
const setupRef = useRef<{ key: string; items: SetupItem[] }>({ key: "", items: [] })
|
|
182
|
+
const [chatWide, setChatWide] = useState(true)
|
|
183
|
+
const setupRef = useRef<{ key: string; items: SetupItem[]; start: StartAction[]; recent: RecentItem[]; first: GetStarted[]; facts: Fact[] }>({ key: "", items: [], start: [], recent: [], first: [], facts: [] })
|
|
175
184
|
// Which panel the keys go to (shift+tab moves on), the row selected in the plan and files, and the activity filter.
|
|
176
185
|
const [nav, setNavState] = useState<{ focus: Focus; plan: number; files: number; filter: ActivityFilter }>({ focus: "chat", plan: 0, files: 0, filter: "all" })
|
|
177
186
|
const navRef = useRef(nav)
|
|
@@ -183,6 +192,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
183
192
|
const contextInfo = useRef<{ key: string; knowledge: number; mcp: string[] }>({ key: "", knowledge: 0, mcp: [] })
|
|
184
193
|
|
|
185
194
|
const [lines, setLines] = useState<Line[]>([])
|
|
195
|
+
const linesRef = useRef(0)
|
|
196
|
+
linesRef.current = lines.length
|
|
186
197
|
const [headed, setHeaded] = useState(Boolean(options.headed))
|
|
187
198
|
const [maxSteps, setMaxSteps] = useState(40)
|
|
188
199
|
const [profile, setProfile] = useState(options.profile || "")
|
|
@@ -201,8 +212,6 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
201
212
|
const [coding, setCoding] = useState(false)
|
|
202
213
|
const [suiteProgress, setSuiteProgress] = useState<{ done: number; total: number } | null>(null)
|
|
203
214
|
const [copied, setCopied] = useState("")
|
|
204
|
-
const [example] = useState(() => pick(EXAMPLES))
|
|
205
|
-
const [tip] = useState(() => pick(TIPS))
|
|
206
215
|
const started = useRef<number | null>(null)
|
|
207
216
|
|
|
208
217
|
const detailRef = useRef<Detail>(detailFrom(options.verbose))
|
|
@@ -289,6 +298,13 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
289
298
|
return () => clearInterval(timer)
|
|
290
299
|
}, [status])
|
|
291
300
|
|
|
301
|
+
// The start screen's tip changes every 8 seconds; only while it shows, so a session does not re-render for it.
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (lines.length || status === "running") return
|
|
304
|
+
const timer = setInterval(() => setTipIndex((n) => n + 1), 8000)
|
|
305
|
+
return () => clearInterval(timer)
|
|
306
|
+
}, [lines.length, status])
|
|
307
|
+
|
|
292
308
|
// The terminal hands mouse drags to the TUI, so text selected with the mouse is copied here when the drag ends.
|
|
293
309
|
useEffect(() => {
|
|
294
310
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -521,7 +537,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
521
537
|
void (options.refine || refine)({ config: cfg, cwd: sessionSetup(cfg, options.repo).cwd, text, recent: convoRef.current, signal: controller.signal })
|
|
522
538
|
.then((result) => {
|
|
523
539
|
if (controller.signal.aborted || refiningRef.current?.phase !== "working") return
|
|
524
|
-
setRefine({ phase: "done", original: text, result, choice:
|
|
540
|
+
setRefine({ phase: "done", original: text, result, choice: initialChoice(result.missing) })
|
|
525
541
|
})
|
|
526
542
|
.catch((err) => {
|
|
527
543
|
if (controller.signal.aborted) return
|
|
@@ -562,6 +578,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
562
578
|
else if (run.id === "details" || run.id === "chat") setNarrowView(run.id)
|
|
563
579
|
else if (run.id === "refine") refineRef.current()
|
|
564
580
|
else if (run.id === "wide") setChatWide((w) => !w)
|
|
581
|
+
else if (run.id === "shortcuts") setShortcuts(true)
|
|
565
582
|
else if (run.id === "panels") setNav((n) => ({ ...n, focus: planLines(inspRef.current).length ? "plan" : "activity" }))
|
|
566
583
|
}
|
|
567
584
|
|
|
@@ -575,6 +592,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
575
592
|
}
|
|
576
593
|
|
|
577
594
|
useKeyboard((key) => {
|
|
595
|
+
// Esc is the shell's (stop, close a list or panel, cancel): the prompt never acts on it, or it would lose focus.
|
|
596
|
+
if (key.name === "escape") key.preventDefault()
|
|
578
597
|
const pal = paletteRef.current
|
|
579
598
|
if (pal) {
|
|
580
599
|
// Keys can arrive faster than renders: every change builds on the latest state, never on this render's copy.
|
|
@@ -650,17 +669,22 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
650
669
|
// The panels: shift+tab moves focus through those on screen; a focused panel takes the arrows, enter, and esc.
|
|
651
670
|
const panes = (width >= SPLIT_MIN && !chatWide) || (width < SPLIT_MIN && narrowView === "details")
|
|
652
671
|
const insp = inspRef.current
|
|
653
|
-
const
|
|
672
|
+
const panelsOpen = width >= SPLIT_MIN && !chatWide
|
|
673
|
+
const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && panelsOpen)
|
|
654
674
|
if (panes && key.name === "tab" && key.shift && !entryRef.current) {
|
|
675
|
+
// Consumed here: the prompt can take focus within this key press and must not also act on it.
|
|
676
|
+
key.preventDefault()
|
|
655
677
|
setNav((n) => ({ ...n, focus: nextFocus(n.focus, shown) }))
|
|
656
678
|
return
|
|
657
679
|
}
|
|
658
680
|
const at = navRef.current.focus
|
|
659
681
|
if (panes && at !== "chat" && shown(at)) {
|
|
682
|
+
// Keys a panel takes are its own: the prompt, focused again by esc, must not see the same esc (it would blur).
|
|
683
|
+
if (key.name === "escape" || key.name === "return" || key.name === "enter" || key.name === "kpenter") key.preventDefault()
|
|
660
684
|
const list = at === "plan" ? planLines(insp).length : at === "files" ? filesByUse(insp).length : 0
|
|
661
685
|
const move = (d: number) => setNav((n) => ({ ...n, [at]: Math.max(0, Math.min(list - 1, (n[at as "plan" | "files"] ?? 0) + d)) }))
|
|
662
686
|
const page = Math.max(3, Math.floor(height / 3))
|
|
663
|
-
if (key.name === "escape") return setNav((n) => ({ ...n, focus:
|
|
687
|
+
if (key.name === "escape") return setNav((n) => ({ ...n, focus: panelsOpen ? "chat" : "activity" }))
|
|
664
688
|
if (at === "activity") {
|
|
665
689
|
const box = activityScroll.current
|
|
666
690
|
if (key.name === "up") return box?.scrollBy(-1)
|
|
@@ -688,9 +712,29 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
688
712
|
}
|
|
689
713
|
}
|
|
690
714
|
// Anything typed goes back to the chat, where the prompt is.
|
|
691
|
-
if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " &&
|
|
715
|
+
if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && panelsOpen) return setNav((n) => ({ ...n, focus: "chat" }))
|
|
692
716
|
if (!key.ctrl && !key.meta) return
|
|
693
717
|
}
|
|
718
|
+
// ? on an empty prompt: every key in one panel; esc or ? closes it.
|
|
719
|
+
if (shortcutsRef.current) {
|
|
720
|
+
if (key.name === "escape" || key.sequence === "?") setShortcuts(false)
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
if (key.sequence === "?" && !promptTextRef.current.trim() && !entryRef.current && !dialogRef.current) {
|
|
724
|
+
setShortcuts(true)
|
|
725
|
+
setDraft("")
|
|
726
|
+
setInputKey((k) => k + 1)
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
// The start screen's actions: alt+1 to alt+4 put one in the prompt.
|
|
730
|
+
if (key.meta && /^[1-4]$/.test(key.name || "") && linesRef.current === 0 && !abortRef.current) {
|
|
731
|
+
const n = Number(key.name) - 1
|
|
732
|
+
const a = setupRef.current.start[n]
|
|
733
|
+
const g = setupRef.current.first[n]
|
|
734
|
+
if (a) fillPrompt(a.request)
|
|
735
|
+
else if (!setupRef.current.start.length && g) g.command ? submitRef.current(g.command) : g.fix && push("sys", g.fix, "chat")
|
|
736
|
+
return
|
|
737
|
+
}
|
|
694
738
|
if (suggRef.current.list.length && suggRef.current.trigger && !entryRef.current) {
|
|
695
739
|
const n = suggRef.current.list.length
|
|
696
740
|
if (key.name === "up") return setSugg((x) => ({ ...x, selected: (x.selected + n - 1) % n }))
|
|
@@ -709,7 +753,11 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
709
753
|
return
|
|
710
754
|
}
|
|
711
755
|
if (key.name === "r" && key.ctrl && !entryRef.current) startRefine()
|
|
712
|
-
|
|
756
|
+
// ctrl+o: the panels (plan, activity, files, context, tokens) beside the chat on a wide terminal, in its place on a narrow one.
|
|
757
|
+
if (key.name === "o" && key.ctrl) {
|
|
758
|
+
if (width >= SPLIT_MIN) setChatWide((w) => !w)
|
|
759
|
+
else setNarrowView((v) => (v === "chat" ? "details" : "chat"))
|
|
760
|
+
}
|
|
713
761
|
if (key.name === "escape" && abortRef.current) openDialog("stop")
|
|
714
762
|
})
|
|
715
763
|
|
|
@@ -1065,7 +1113,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1065
1113
|
pushProduced()
|
|
1066
1114
|
// What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
|
|
1067
1115
|
// The split view's session and tokens panels show this; only the one-pane view needs it in the chat.
|
|
1068
|
-
|
|
1116
|
+
// The footer and the tokens panel show what a request used; the conversation stays about the work.
|
|
1117
|
+
const spent = ""
|
|
1118
|
+
void meterStart
|
|
1069
1119
|
if (spent) push("sys", spent, "chat")
|
|
1070
1120
|
finish(finalStatus, followUps)
|
|
1071
1121
|
}
|
|
@@ -1090,6 +1140,19 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1090
1140
|
else void askAgent(text, cfg)
|
|
1091
1141
|
}
|
|
1092
1142
|
|
|
1143
|
+
/** Reads forgeqe.yaml and llms.json again (after /init wrote them), keeping the session going. */
|
|
1144
|
+
const reloadConfig = () => {
|
|
1145
|
+
let cfg = loadConfig({ configPath: options.configPath, profile: options.profile })
|
|
1146
|
+
if (options.profile) cfg = selectProfile(cfg, options.profile)
|
|
1147
|
+
configRef.current = cfg
|
|
1148
|
+
setProfile(cfg.activeProfile)
|
|
1149
|
+
setModel(modelLabel(cfg))
|
|
1150
|
+
setupRef.current.key = ""
|
|
1151
|
+
contextInfo.current.key = ""
|
|
1152
|
+
closeBrain()
|
|
1153
|
+
bump()
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1093
1156
|
useEffect(() => {
|
|
1094
1157
|
if (booted.current) return
|
|
1095
1158
|
booted.current = true
|
|
@@ -1159,7 +1222,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1159
1222
|
if (!text) return
|
|
1160
1223
|
// Setup notes come before the request, so they do not read as its answer; the split view shows them in its session panel.
|
|
1161
1224
|
const notes = priceNotesRef.current.splice(0)
|
|
1162
|
-
|
|
1225
|
+
// Price notes stay out of the conversation: forgeqe models and the reports say what a run cost.
|
|
1226
|
+
void notes
|
|
1163
1227
|
push("user", text)
|
|
1164
1228
|
const cmd = parseCommand(text)
|
|
1165
1229
|
if (cmd.type === "help") {
|
|
@@ -1201,6 +1265,17 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1201
1265
|
}
|
|
1202
1266
|
return
|
|
1203
1267
|
}
|
|
1268
|
+
if (cmd.type === "init") {
|
|
1269
|
+
// forgeqe init in this folder, then the session reads what it wrote: the start screen updates at once.
|
|
1270
|
+
try {
|
|
1271
|
+
const r = initWorkspace(process.cwd(), {})
|
|
1272
|
+
push("sys", [r.created.length ? `Created in ${shortCwd()}:` : "Nothing to create: this folder is set up.", ...r.created.map((f) => ` ✚ ${path.relative(process.cwd(), f) || f}`), ...r.notes].join("\n"), "chat")
|
|
1273
|
+
reloadConfig()
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
push("err", (err as Error).message, "chat")
|
|
1276
|
+
}
|
|
1277
|
+
return
|
|
1278
|
+
}
|
|
1204
1279
|
if (cmd.type === "features") {
|
|
1205
1280
|
if (configRef.current) push("sys", featuresText(configRef.current))
|
|
1206
1281
|
else push("err", "Config is not loaded.")
|
|
@@ -1270,7 +1345,11 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1270
1345
|
? "headed"
|
|
1271
1346
|
: ""
|
|
1272
1347
|
const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
|
|
1273
|
-
const
|
|
1348
|
+
const footerVersion = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
|
|
1349
|
+
// In the conversation, the footer is the status line: the model, what the session used, and the version.
|
|
1350
|
+
const sessionTokens = meterSince(sessionMeter.current).total
|
|
1351
|
+
const usedTokens = sessionTokens.input + sessionTokens.output
|
|
1352
|
+
const footerRight = lines.length ? [configRef.current ? modelLabel(configRef.current) : model, usedTokens ? `${formatTokens(usedTokens)} tokens` : "", footerVersion].filter(Boolean).join(" · ") : footerVersion
|
|
1274
1353
|
// Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
|
|
1275
1354
|
const suggTrigger = triggerAt(promptText)
|
|
1276
1355
|
const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
|
|
@@ -1297,8 +1376,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1297
1376
|
|
|
1298
1377
|
const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
|
|
1299
1378
|
const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
|
|
1300
|
-
//
|
|
1379
|
+
// One column by default, as in Claude Code; ctrl+o (or Panels) opens the plan, activity, files, context, and
|
|
1380
|
+
// tokens beside it on a wide terminal, or in its place on a narrow one.
|
|
1301
1381
|
const wide = width >= SPLIT_MIN
|
|
1382
|
+
const panelsOpen = wide && !chatWide
|
|
1302
1383
|
const openPalette = () => {
|
|
1303
1384
|
paletteRef.current = { query: "", selected: 0 }
|
|
1304
1385
|
setPalette(paletteRef.current)
|
|
@@ -1308,23 +1389,26 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1308
1389
|
// `drop`: the order they give way on a narrow row; Refine, Search, and Stop always stay.
|
|
1309
1390
|
const refineButton: Button = { icon: "✦", label: "Refine", keys: "ctrl+r", color: theme.memory, onPress: () => refineRef.current(), active: Boolean(promptText.trim()) }
|
|
1310
1391
|
const searchButton: Button = { icon: "⌕", label: "Search", keys: "ctrl+p", color: theme.accent, onPress: openPalette }
|
|
1311
|
-
// The view:
|
|
1312
|
-
//
|
|
1392
|
+
// The view: with the panels open, moving into them and closing them sit in their status line; otherwise Panels
|
|
1393
|
+
// (Details on a narrow terminal) opens them.
|
|
1313
1394
|
const toggleWide = () => setChatWide((w) => !w)
|
|
1314
1395
|
const paneButtons: Button[] = [
|
|
1315
|
-
{ icon: "▦", label: "
|
|
1316
|
-
{ icon: "
|
|
1396
|
+
{ icon: "▦", label: "Focus panels", keys: "shift+tab", color: theme.thought, onPress: focusPanels, drop: 2 },
|
|
1397
|
+
{ icon: "✕", label: "Close", keys: "ctrl+o", color: theme.muted, onPress: toggleWide, drop: 1 },
|
|
1317
1398
|
]
|
|
1318
1399
|
const viewButtons: Button[] = wide
|
|
1319
1400
|
? chatWide
|
|
1320
|
-
? [{ icon: "
|
|
1401
|
+
? [{ icon: "▦", label: "Panels", keys: "ctrl+o", color: theme.thought, onPress: toggleWide, drop: 2 }]
|
|
1321
1402
|
: []
|
|
1322
1403
|
: [{ icon: "◧", label: "Details", keys: "ctrl+o", color: theme.thought, onPress: () => setNarrowView("details"), drop: 1 }]
|
|
1323
1404
|
const idleButtons: Button[] = [refineButton, searchButton, ...viewButtons, ...(wide ? [] : [{ icon: "⇄", label: "Profile", keys: "tab", color: theme.muted, onPress: cycleProfile, drop: 4 }])]
|
|
1324
1405
|
const runningButtons: Button[] = [{ icon: "■", label: "Stop", keys: "esc", color: theme.warn, onPress: () => openDialog("stop") }, searchButton, ...viewButtons]
|
|
1325
|
-
const
|
|
1406
|
+
const shortcutsHint: Hint = { key: "?", label: "shortcuts", onPress: () => setShortcuts(true) }
|
|
1407
|
+
// ? is offered where there is room for it: the start screen of a terminal 110 columns or wider; it works everywhere.
|
|
1408
|
+
const idleHints: Hint[] = [...copiedHint, ...(home && width >= 110 ? [shortcutsHint] : []), closeHint]
|
|
1326
1409
|
// The one-pane view has no tokens panel, so the tokens and time show here.
|
|
1327
|
-
|
|
1410
|
+
// While a request runs, the line above the prompt says what is happening, for how long, and with how many tokens.
|
|
1411
|
+
const runningHints: Hint[] = [...copiedHint, closeHint]
|
|
1328
1412
|
const runningLeft = (
|
|
1329
1413
|
<box style={{ flexDirection: "row", marginRight: 1 }}>
|
|
1330
1414
|
<Spinner kind="bar" />
|
|
@@ -1413,59 +1497,167 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1413
1497
|
model={model}
|
|
1414
1498
|
right={right}
|
|
1415
1499
|
rightColor={rightColor}
|
|
1416
|
-
compact={
|
|
1417
|
-
focused={!palette && !refining && (nav.focus === "chat"
|
|
1500
|
+
compact={!home}
|
|
1501
|
+
focused={!palette && !refining && (panelsOpen ? nav.focus === "chat" : wide || narrowView === "chat")}
|
|
1418
1502
|
initialValue={draft}
|
|
1419
1503
|
onText={(t) => {
|
|
1420
1504
|
promptTextRef.current = t
|
|
1421
1505
|
setPromptText(t)
|
|
1422
1506
|
}}
|
|
1507
|
+
onLines={setPromptLines}
|
|
1423
1508
|
/>
|
|
1424
1509
|
<ButtonBar width={promptWidth} buttons={running ? runningButtons : idleButtons} hints={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
|
|
1425
1510
|
</>
|
|
1426
1511
|
)
|
|
1427
1512
|
}
|
|
1428
1513
|
|
|
1514
|
+
// Back on the start screen after /clear, its sections are read again: new reports and runs show up.
|
|
1515
|
+
if (!home) setupRef.current.key = ""
|
|
1516
|
+
const shortcutsOverlay = shortcuts ? <ShortcutsPanel width={width} wide={width >= SPLIT_MIN} onClose={() => setShortcuts(false)} /> : null
|
|
1517
|
+
|
|
1429
1518
|
if (home) {
|
|
1430
|
-
|
|
1519
|
+
// One column, as wide as the prompt, every section on its left edge.
|
|
1520
|
+
const promptWidth = Math.max(40, Math.min(width - 8, 140))
|
|
1431
1521
|
// What is set up, while something is not: each open item says how, or runs the command that does it.
|
|
1432
1522
|
const cfgHome = configRef.current
|
|
1433
1523
|
const setupKey = cfgHome ? `${cfgHome.configPath}|${cfgHome.activeProfile}` : ""
|
|
1434
|
-
if (cfgHome && setupRef.current.key !== setupKey)
|
|
1435
|
-
|
|
1524
|
+
if (cfgHome && setupRef.current.key !== setupKey) {
|
|
1525
|
+
const items = setupChecklist(cfgHome)
|
|
1526
|
+
const recent = recentWork(path.resolve(cfgHome.export.outDir))
|
|
1527
|
+
let signIns: Record<string, string> = {}
|
|
1528
|
+
try {
|
|
1529
|
+
// "Azure DevOps (PAT)": how each system's server signs in, as the session panel says it.
|
|
1530
|
+
signIns = Object.fromEntries(externalMcp(cfgHome).servers.filter((x) => x.signIn).map((x) => [x.label.replace(/ MCP$/, ""), x.signIn!.startsWith("PAT") ? "PAT" : "az login"]))
|
|
1531
|
+
} catch {}
|
|
1532
|
+
setupRef.current = {
|
|
1533
|
+
key: setupKey,
|
|
1534
|
+
items,
|
|
1535
|
+
start: startActions(cfgHome, 4, undefined, recent.some((r) => r.kind === "run")),
|
|
1536
|
+
recent,
|
|
1537
|
+
first: getStarted(cfgHome, items),
|
|
1538
|
+
facts: projectLine(cfgHome, process.cwd(), { model: modelLabel(cfgHome), signIns }),
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const { items: setup, start, recent, first, facts } = setupRef.current
|
|
1436
1542
|
const open = setup.filter((i) => !i.done)
|
|
1437
|
-
|
|
1543
|
+
// Each missing item with how to set it up, in the chat; the first that a command sets up (/auth) is offered last.
|
|
1544
|
+
const explainSetup = (items: SetupItem[]) => push("sys", items.map((i) => `○ ${i.fix}${i.command ? ` Type ${i.command} to start.` : ""}`).join("\n"), "chat")
|
|
1545
|
+
|
|
1546
|
+
// Rows: the logo, the project line, and the prompt always; then Start with, Continue, and Setup while they fit.
|
|
1547
|
+
// A long request takes rows from them, last section first, so the box never scrolls while there is room.
|
|
1548
|
+
const loading = !cfgHome
|
|
1549
|
+
// Each section is a blank row and then its own rows: a heading and its items, or the setup line.
|
|
1550
|
+
// Get started takes Start with's place while there is nothing to start with: no model, or no forgeqe.yaml.
|
|
1551
|
+
const offer: { id: string; label: string; detail: string; run: () => void }[] = start.length
|
|
1552
|
+
? start.map((a) => ({ id: a.id, label: a.label, detail: a.detail, run: () => fillPrompt(a.request) }))
|
|
1553
|
+
: first.map((g) => ({ id: g.id, label: g.label, detail: g.detail, run: () => (g.command ? submitRef.current(g.command) : g.fix && push("sys", g.fix, "chat")) }))
|
|
1554
|
+
const offerTitle = start.length ? "Start with" : "Get started"
|
|
1555
|
+
const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
|
|
1556
|
+
const recentRows = recent.length ? 1 + 1 + recent.length : 0
|
|
1557
|
+
const setupRows = loading || open.length ? 2 : 0
|
|
1558
|
+
const fixed = 5 + 2 + 1 + 5 + 1 + 1 + 1 // logo, project line, gap, prompt chrome, buttons, footer, one row to spare
|
|
1559
|
+
const room = height - fixed
|
|
1560
|
+
const want = Math.max(5, Math.min(promptLines, room - 1))
|
|
1561
|
+
let left = room - want
|
|
1562
|
+
const showStart = startRows > 0 && left >= startRows
|
|
1563
|
+
if (showStart) left -= startRows
|
|
1564
|
+
const showRecent = recentRows > 0 && left >= recentRows
|
|
1565
|
+
if (showRecent) left -= recentRows
|
|
1566
|
+
const showSetup = setupRows > 0 && left >= setupRows
|
|
1567
|
+
const maxRows = Math.max(5, room - (showStart ? startRows : 0) - (showRecent ? recentRows : 0) - (showSetup ? setupRows : 0))
|
|
1568
|
+
const heading = (t: string) => <text fg={theme.muted}>{t}</text>
|
|
1569
|
+
const keyCell = (k: string) => <span fg={theme.faint}>{k.padEnd(4)}</span>
|
|
1570
|
+
const done = setup.length - open.length
|
|
1438
1571
|
return (
|
|
1439
1572
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1440
|
-
|
|
1573
|
+
{/* Clipped, never pushed: the footer stays on screen whatever the sections above it need. */}
|
|
1574
|
+
<box style={{ flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", alignItems: "center", justifyContent: "center" }}>
|
|
1441
1575
|
<Logo />
|
|
1442
|
-
<box style={{ height: 2 }}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1576
|
+
<box style={{ paddingTop: 1, height: 2, flexShrink: 0 }}>
|
|
1577
|
+
<text>
|
|
1578
|
+
{facts.length ? (
|
|
1579
|
+
facts.map((f, i) => (
|
|
1580
|
+
<span key={i} fg={f.tone === "name" ? theme.text : f.tone === "warn" ? theme.warn : theme.muted}>
|
|
1581
|
+
{`${i ? " · " : ""}${f.text}`}
|
|
1582
|
+
</span>
|
|
1583
|
+
))
|
|
1584
|
+
) : (
|
|
1585
|
+
<span fg={theme.faint}>{" "}</span>
|
|
1586
|
+
)}
|
|
1587
|
+
</text>
|
|
1588
|
+
</box>
|
|
1589
|
+
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1590
|
+
<box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0 }}>
|
|
1591
|
+
{renderControls(promptWidth, "Describe what you want to test…", [5, maxRows])}
|
|
1592
|
+
{showStart ? (
|
|
1593
|
+
<box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
|
|
1594
|
+
{heading(offerTitle)}
|
|
1595
|
+
{loading
|
|
1596
|
+
? [0, 1, 2, 3].map((i) => <text key={i} fg={theme.faint}>{i ? " " : " Loading what works in this setup…"}</text>)
|
|
1597
|
+
: offer.map((a, i) => (
|
|
1598
|
+
<box key={a.id} onMouseDown={a.run} style={{ flexDirection: "row", justifyContent: "space-between", flexShrink: 0 }}>
|
|
1599
|
+
<text>
|
|
1600
|
+
<span fg={theme.accent}>{" ▸ "}</span>
|
|
1601
|
+
<span fg={theme.text}>{a.label.padEnd(32)}</span>
|
|
1602
|
+
<span fg={theme.faint}>{clip(a.detail, Math.max(10, promptWidth - 50))}</span>
|
|
1603
|
+
</text>
|
|
1604
|
+
<text>{keyCell(`alt+${i + 1}`)}</text>
|
|
1605
|
+
</box>
|
|
1606
|
+
))}
|
|
1607
|
+
</box>
|
|
1608
|
+
) : null}
|
|
1609
|
+
{showRecent ? (
|
|
1610
|
+
<box style={{ flexDirection: "column", marginTop: 1, height: recentRows - 1, flexShrink: 0 }}>
|
|
1611
|
+
{heading("Continue")}
|
|
1612
|
+
{recent.map((r) => (
|
|
1613
|
+
<box key={r.dir} style={{ flexDirection: "row", justifyContent: "space-between", flexShrink: 0 }}>
|
|
1614
|
+
<text onMouseDown={() => openFile(r.open)}>
|
|
1615
|
+
<span fg={r.kind === "report" ? theme.memory : r.status === "complete" || r.status === "passed" ? theme.ok : r.status ? theme.err : theme.muted}>
|
|
1616
|
+
{r.kind === "report" ? " ▤ " : r.status === "complete" || r.status === "passed" ? " ✓ " : r.status ? " ✗ " : " ▸ "}
|
|
1617
|
+
</span>
|
|
1618
|
+
<span fg={theme.muted}>{r.kind === "report" ? "Report " : "Run "}</span>
|
|
1619
|
+
<span fg={theme.text}>{clip(r.title, Math.max(10, promptWidth - 40))}</span>
|
|
1454
1620
|
</text>
|
|
1621
|
+
<box style={{ flexDirection: "row" }}>
|
|
1622
|
+
<text fg={theme.faint}>{`${ago(r.when)} `}</text>
|
|
1623
|
+
<text fg={theme.accent} onMouseDown={() => openFile(r.open)}>
|
|
1624
|
+
{"open"}
|
|
1625
|
+
</text>
|
|
1626
|
+
{r.kind === "run" ? (
|
|
1627
|
+
<text fg={theme.accent} onMouseDown={() => fillPrompt(`Run this goal again: ${r.title}`)}>
|
|
1628
|
+
{" rerun"}
|
|
1629
|
+
</text>
|
|
1630
|
+
) : null}
|
|
1631
|
+
</box>
|
|
1455
1632
|
</box>
|
|
1456
1633
|
))}
|
|
1457
1634
|
</box>
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1635
|
+
) : null}
|
|
1636
|
+
{showSetup ? (
|
|
1637
|
+
// Setup in one line: how far it is, what is missing in words, and one link that says how to set it up.
|
|
1638
|
+
<box style={{ flexDirection: "row", height: 1, flexShrink: 0, marginTop: 1 }}>
|
|
1639
|
+
<text>
|
|
1640
|
+
<span fg={theme.muted}>{"Setup "}</span>
|
|
1641
|
+
<span fg={theme.ok}>{"▰".repeat(done)}</span>
|
|
1642
|
+
<span fg={theme.muted}>{"▱".repeat(open.length)}</span>
|
|
1643
|
+
<span fg={theme.muted}>{loading ? " checking…" : ` ${done} of ${setup.length} · `}</span>
|
|
1644
|
+
{loading ? null : <span fg={theme.text}>{setupSentence(open.map((i) => i.label), promptWidth - 44)}</span>}
|
|
1645
|
+
</text>
|
|
1646
|
+
{loading ? null : (
|
|
1647
|
+
<box onMouseDown={() => explainSetup(open)} style={{ marginLeft: 2, paddingLeft: 1, paddingRight: 1, backgroundColor: theme.button }}>
|
|
1648
|
+
<text>
|
|
1649
|
+
<span fg={theme.warn}>{"▸ "}</span>
|
|
1650
|
+
<span fg={theme.text}>{"how to set up"}</span>
|
|
1651
|
+
</text>
|
|
1652
|
+
</box>
|
|
1653
|
+
)}
|
|
1654
|
+
</box>
|
|
1655
|
+
) : null}
|
|
1656
|
+
</box>
|
|
1466
1657
|
</box>
|
|
1467
|
-
<Footer left={
|
|
1658
|
+
<Footer left={`● Tip ${TIPS[tipIndex % TIPS.length]}`} right={footerRight} width={width} cut="end" />
|
|
1468
1659
|
{paletteOverlay}
|
|
1660
|
+
{shortcutsOverlay}
|
|
1469
1661
|
</box>
|
|
1470
1662
|
)
|
|
1471
1663
|
}
|
|
@@ -1504,9 +1696,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1504
1696
|
? coding
|
|
1505
1697
|
? "The coding agent is writing a test · esc to stop"
|
|
1506
1698
|
: `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
|
|
1507
|
-
:
|
|
1508
|
-
? "Ask ForgeQE · / commands · @ references"
|
|
1509
|
-
: "Ask ForgeQE: a goal, test cases, a test, a pipeline… (/ commands, @ references)"
|
|
1699
|
+
: "Ask ForgeQE… (/ for commands, @ to reference a run, file, or work item)"
|
|
1510
1700
|
const chatLines = lines.filter((l) => l.pane !== "activity")
|
|
1511
1701
|
const activityLines = lines.filter((l) => l.pane === "activity")
|
|
1512
1702
|
const stop = () => openDialog("stop")
|
|
@@ -1548,37 +1738,38 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1548
1738
|
|
|
1549
1739
|
const transcript = (all: Line[], style: Record<string, unknown>) => (
|
|
1550
1740
|
<scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
|
|
1551
|
-
{all.map((line) => (
|
|
1552
|
-
<TranscriptLine key={line.id} line={line} />
|
|
1741
|
+
{all.map((line, i) => (
|
|
1742
|
+
<TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
|
|
1553
1743
|
))}
|
|
1554
|
-
{running &&
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
<
|
|
1744
|
+
{running && !panelsOpen ? (
|
|
1745
|
+
// What is happening now, for how long, and with how many tokens, as one line under the conversation.
|
|
1746
|
+
<box style={{ flexDirection: "row", marginTop: 1, flexShrink: 0 }}>
|
|
1747
|
+
<Spinner kind="dots" color={theme.memory} />
|
|
1748
|
+
<text>
|
|
1749
|
+
<span fg={theme.memory}>{` ${activity || "Working…"}`}</span>
|
|
1750
|
+
<span fg={theme.faint}>{` (${elapsed}${tokens ? ` · ${formatTokens(tokens)} tokens` : ""} · esc to interrupt)`}</span>
|
|
1751
|
+
</text>
|
|
1558
1752
|
</box>
|
|
1559
1753
|
) : null}
|
|
1560
1754
|
</scrollbox>
|
|
1561
1755
|
)
|
|
1562
|
-
|
|
1756
|
+
// A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
|
|
1757
|
+
const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.floor(height * 0.45))])
|
|
1563
1758
|
|
|
1564
|
-
const split =
|
|
1759
|
+
const split = panelsOpen
|
|
1565
1760
|
if (split) {
|
|
1566
|
-
|
|
1567
|
-
const leftWidth = chatWide ? width : Math.floor(width * 0.4)
|
|
1761
|
+
const leftWidth = Math.floor(width * 0.4)
|
|
1568
1762
|
return (
|
|
1569
1763
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1570
1764
|
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1571
|
-
<box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight:
|
|
1572
|
-
<SessionPanel info={sessionInfo} width={leftWidth -
|
|
1573
|
-
{
|
|
1574
|
-
{
|
|
1575
|
-
|
|
1765
|
+
<box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1766
|
+
<SessionPanel info={sessionInfo} width={leftWidth - 3} />
|
|
1767
|
+
{transcript(chatLines, {})}
|
|
1768
|
+
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
|
|
1769
|
+
</box>
|
|
1770
|
+
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1771
|
+
{details(width - leftWidth - 3, height - 2)}
|
|
1576
1772
|
</box>
|
|
1577
|
-
{chatWide ? null : (
|
|
1578
|
-
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1579
|
-
{details(width - leftWidth - 3, height - 2)}
|
|
1580
|
-
</box>
|
|
1581
|
-
)}
|
|
1582
1773
|
</box>
|
|
1583
1774
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1584
1775
|
{paletteOverlay}
|
|
@@ -1587,7 +1778,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1587
1778
|
}
|
|
1588
1779
|
|
|
1589
1780
|
const promptWidth = Math.max(40, width - 4)
|
|
1590
|
-
if (narrowView === "details") {
|
|
1781
|
+
if (!wide && narrowView === "details") {
|
|
1591
1782
|
return (
|
|
1592
1783
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1593
1784
|
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>{details(promptWidth, height - 3 - (dialog ? 8 : 0))}</box>
|