@capagents/forgeqe 1.0.4 → 1.0.7
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 +60 -3
- package/docs/user-guide.html +63 -5
- package/package.json +1 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +4 -1
- package/prompts/refine.md +17 -0
- package/src/brain/index.ts +4 -3
- package/src/brain/prompt.ts +34 -2
- package/src/capabilities/registry.ts +12 -0
- package/src/capabilities/status.ts +4 -3
- package/src/mcp/reporttools.ts +101 -0
- package/src/mcp/tools.ts +4 -0
- package/src/refine.ts +116 -0
- package/src/tui/App.tsx +477 -47
- package/src/tui/components.tsx +78 -3
- package/src/tui/inspector.tsx +181 -39
- package/src/tui/next.ts +43 -0
- package/src/tui/palette.tsx +173 -0
- package/src/tui/refine-panel.tsx +94 -0
- package/src/tui/setup.ts +37 -0
- package/src/tui/suggest.ts +111 -0
- package/src/tui/transcript.tsx +125 -7
- package/src/usage.ts +1 -1
package/src/tui/App.tsx
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { ScrollBoxRenderable, Selection } from "@opentui/core"
|
|
2
2
|
import { useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
3
|
+
import { existsSync } from "node:fs"
|
|
3
4
|
import path from "node:path"
|
|
4
5
|
import { useEffect, useRef, useState } from "react"
|
|
5
6
|
|
|
6
7
|
import { NO_MODEL_SET_UP } from "../llm.ts"
|
|
7
8
|
import type { CodeEvent, ReplayMode } from "../types.ts"
|
|
8
9
|
import { brainUnavailable, carryOver, openBrain, sessionSetup, type Brain, type BrainOptions } from "../brain/index.ts"
|
|
10
|
+
import type { FollowUp } from "../brain/prompt.ts"
|
|
9
11
|
import { INSTALL_HINT } from "../code/runtime.ts"
|
|
10
12
|
import { briefText, detailFrom, shows, showsAgentTool, stepIntent, type Detail } from "../detail.ts"
|
|
11
13
|
import { stepVerdictLines } from "../cases/types.ts"
|
|
@@ -22,22 +24,46 @@ import { counts } from "../suite/report.ts"
|
|
|
22
24
|
import { buildSummary, shortPath } from "../summary.ts"
|
|
23
25
|
import { meterSnapshot, requestLine } from "../usage.ts"
|
|
24
26
|
import { cleanSelection, copyText } from "./clipboard.ts"
|
|
25
|
-
import { ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Hint } from "./components.tsx"
|
|
27
|
+
import { ButtonBar, ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Button, type Hint } from "./components.tsx"
|
|
28
|
+
import { setupChecklist, type SetupItem } from "./setup.ts"
|
|
26
29
|
import { EXAMPLES, TIPS, formatDuration, formatTokens, packageVersion, pick, shortCwd, theme } from "./theme.ts"
|
|
27
30
|
import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
|
|
28
31
|
import { Dialog, dialogSpec } from "./dialogs.ts"
|
|
29
32
|
import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
|
|
30
33
|
import { featuresText } from "../capabilities/render.ts"
|
|
34
|
+
import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
|
|
35
|
+
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"
|
|
37
|
+
import { refine } from "../refine.ts"
|
|
38
|
+
import { nextSteps, type NextStep } from "./next.ts"
|
|
31
39
|
import { CAPABILITIES } from "../capabilities/registry.ts"
|
|
32
40
|
import { CONTEXT_LIMIT } from "../code/runtime.ts"
|
|
33
41
|
import { readKnowledge } from "../knowledge.ts"
|
|
34
42
|
import { externalMcp } from "../mcp/external.ts"
|
|
35
43
|
import { meterSince } from "../usage.ts"
|
|
36
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
ACTIVITY_FILTERS,
|
|
46
|
+
ActivityPanel,
|
|
47
|
+
ContextPanel,
|
|
48
|
+
FilesPanel,
|
|
49
|
+
nextFocus,
|
|
50
|
+
planLines,
|
|
51
|
+
PlanPanel,
|
|
52
|
+
planProgress,
|
|
53
|
+
planRows,
|
|
54
|
+
SessionPanel,
|
|
55
|
+
StatusBar,
|
|
56
|
+
TokensPanel,
|
|
57
|
+
type ActivityFilter,
|
|
58
|
+
type ContextInfo,
|
|
59
|
+
type Focus,
|
|
60
|
+
type SessionInfo,
|
|
61
|
+
} from "./inspector.tsx"
|
|
37
62
|
import {
|
|
38
63
|
addSkill,
|
|
39
64
|
caseDone,
|
|
40
65
|
caseStarted,
|
|
66
|
+
filesByUse,
|
|
41
67
|
newInspector,
|
|
42
68
|
newRequest,
|
|
43
69
|
paneFor,
|
|
@@ -72,6 +98,8 @@ export type TuiOptions = {
|
|
|
72
98
|
copy?: (text: string) => boolean
|
|
73
99
|
/** Show everything the agents do (their reasoning, every file read, engine logs); /details toggles it. */
|
|
74
100
|
verbose?: boolean
|
|
101
|
+
/** Replaces Refine's model session in tests. */
|
|
102
|
+
refine?: typeof refine
|
|
75
103
|
/** Replaces opening a page in the browser (sign-in) in tests. */
|
|
76
104
|
openUrl?: (url: string) => void
|
|
77
105
|
}
|
|
@@ -89,6 +117,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
89
117
|
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
90
118
|
const activityScroll = useRef<ScrollBoxRenderable>(null)
|
|
91
119
|
const submitRef = useRef<(value: string) => void>(() => {})
|
|
120
|
+
/** Refines what is in the prompt (phase two of the palette work wires it). */
|
|
121
|
+
const refineRef = useRef<() => void>(() => {})
|
|
92
122
|
const configRef = useRef<AppConfig | null>(null)
|
|
93
123
|
const abortRef = useRef<AbortController | null>(null)
|
|
94
124
|
const brainRef = useRef<Brain | null>(null)
|
|
@@ -110,6 +140,25 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
110
140
|
const codeRunDir = useRef("")
|
|
111
141
|
/** The right pane's live state: plan, steps or cases, files, context. */
|
|
112
142
|
const inspRef = useRef(newInspector())
|
|
143
|
+
/** The command palette: open, what is typed in it, and which match is selected. */
|
|
144
|
+
const [palette, setPalette] = useState<{ query: string; selected: number } | null>(null)
|
|
145
|
+
const paletteRef = useRef(palette)
|
|
146
|
+
paletteRef.current = palette
|
|
147
|
+
/** Refine (ctrl+r): what the prompt holds, the panel's state, and how to cancel it. */
|
|
148
|
+
const promptTextRef = useRef("")
|
|
149
|
+
const [refining, setRefining] = useState<RefineState | null>(null)
|
|
150
|
+
const refiningRef = useRef(refining)
|
|
151
|
+
refiningRef.current = refining
|
|
152
|
+
const refineAbort = useRef<AbortController | null>(null)
|
|
153
|
+
/** One-click follow-ups after a request, from what it produced. */
|
|
154
|
+
const [next, setNext] = useState<NextStep[]>([])
|
|
155
|
+
/** What the prompt holds, for suggestions; and the suggestion state: which is selected, which word was dismissed. */
|
|
156
|
+
const [promptText, setPromptText] = useState("")
|
|
157
|
+
const [sugg, setSugg] = useState<{ selected: number; dismissed?: string }>({ selected: 0 })
|
|
158
|
+
const [workItem, setWorkItem] = useState<WorkItem | null | undefined>(undefined)
|
|
159
|
+
const suggRef = useRef<{ list: Suggestion[]; trigger: ReturnType<typeof triggerAt> }>({ list: [], trigger: null })
|
|
160
|
+
/** Text the prompt starts with on its next remount (an example from the palette, a refined request). */
|
|
161
|
+
const [draft, setDraft] = useState("")
|
|
113
162
|
/** Set while a typed line is handled, so the shell's answers to it go to the chat. */
|
|
114
163
|
const answeringRef = useRef(false)
|
|
115
164
|
const [, setRev] = useState(0)
|
|
@@ -120,6 +169,17 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
120
169
|
const requests = useRef(0)
|
|
121
170
|
/** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
|
|
122
171
|
const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
|
|
172
|
+
// 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(false)
|
|
174
|
+
const setupRef = useRef<{ key: string; items: SetupItem[] }>({ key: "", items: [] })
|
|
175
|
+
// Which panel the keys go to (shift+tab moves on), the row selected in the plan and files, and the activity filter.
|
|
176
|
+
const [nav, setNavState] = useState<{ focus: Focus; plan: number; files: number; filter: ActivityFilter }>({ focus: "chat", plan: 0, files: 0, filter: "all" })
|
|
177
|
+
const navRef = useRef(nav)
|
|
178
|
+
// Keys can arrive faster than renders: each change builds on the ref, which the next key reads at once.
|
|
179
|
+
const setNav = (f: (n: typeof nav) => typeof nav) => {
|
|
180
|
+
navRef.current = f(navRef.current)
|
|
181
|
+
setNavState(navRef.current)
|
|
182
|
+
}
|
|
123
183
|
const contextInfo = useRef<{ key: string; knowledge: number; mcp: string[] }>({ key: "", knowledge: 0, mcp: [] })
|
|
124
184
|
|
|
125
185
|
const [lines, setLines] = useState<Line[]>([])
|
|
@@ -190,6 +250,19 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
190
250
|
}
|
|
191
251
|
/** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
|
|
192
252
|
const pushTool = (text: string, ok: boolean) => push(!ok ? "err" : /^(Writing|Editing) /.test(text) ? "file" : "tool", text)
|
|
253
|
+
/** A short note in the hint row that clears itself: "opened report.html". */
|
|
254
|
+
const flashTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
255
|
+
const flash = (text: string) => {
|
|
256
|
+
setCopied(text)
|
|
257
|
+
clearTimeout(flashTimer.current)
|
|
258
|
+
flashTimer.current = setTimeout(() => setCopied(""), 2500)
|
|
259
|
+
}
|
|
260
|
+
const openFile = (file: string) => {
|
|
261
|
+
const abs = path.resolve(file)
|
|
262
|
+
if (!existsSync(abs)) return flash(`${path.basename(file)} is no longer there`)
|
|
263
|
+
;(options.openUrl || openInBrowser)(`file://${abs}`)
|
|
264
|
+
flash(`opened ${path.basename(file)}`)
|
|
265
|
+
}
|
|
193
266
|
const pushProduced = () => {
|
|
194
267
|
const card = producedCard(producedRef.current, testsRef.current)
|
|
195
268
|
if (card) pushLine({ kind: "card", text: card.title, card })
|
|
@@ -200,6 +273,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
200
273
|
setTokens([...usedRef.current.values()].reduce((a, b) => a + b, 0))
|
|
201
274
|
}
|
|
202
275
|
|
|
276
|
+
// "@1761": look the work item up (once per id, after a pause in typing) so its title can be suggested.
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
const t = triggerAt(promptText)
|
|
279
|
+
const cfg = configRef.current
|
|
280
|
+
if (!t || t.kind !== "@" || !/^\d{2,}$/.test(t.token) || !cfg?.sources.ado) return
|
|
281
|
+
const id = Number(t.token)
|
|
282
|
+
const timer = setTimeout(() => void lookupWorkItem(cfg, id).then((item) => setWorkItem(item)), 300)
|
|
283
|
+
return () => clearTimeout(timer)
|
|
284
|
+
}, [promptText])
|
|
285
|
+
|
|
203
286
|
useEffect(() => {
|
|
204
287
|
if (status !== "running") return
|
|
205
288
|
const timer = setInterval(() => setTick((n) => n + 1), 1000)
|
|
@@ -392,6 +475,96 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
392
475
|
}
|
|
393
476
|
}
|
|
394
477
|
|
|
478
|
+
/** Everything the palette offers now, in its order. */
|
|
479
|
+
const paletteAll = (): PaletteItem[] =>
|
|
480
|
+
paletteItems({
|
|
481
|
+
running: abortRef.current !== null,
|
|
482
|
+
headed: headedRef.current,
|
|
483
|
+
detailsFull: detailRef.current === "full",
|
|
484
|
+
profiles: Object.keys(configRef.current?.profiles || {}),
|
|
485
|
+
active: configRef.current?.activeProfile || "",
|
|
486
|
+
narrow: widthRef.current < SPLIT_MIN,
|
|
487
|
+
view: narrowView,
|
|
488
|
+
chatWide,
|
|
489
|
+
})
|
|
490
|
+
const paletteMatches = (query: string) => searchPalette(query, paletteAll())
|
|
491
|
+
|
|
492
|
+
/** Puts text in the prompt to edit and send. */
|
|
493
|
+
const fillPrompt = (text: string) => {
|
|
494
|
+
promptTextRef.current = text
|
|
495
|
+
setPromptText(text)
|
|
496
|
+
setDraft(text)
|
|
497
|
+
setInputKey((k) => k + 1)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const setRefine = (next: RefineState | null) => {
|
|
501
|
+
refiningRef.current = next
|
|
502
|
+
setRefining(next)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Refines what is in the prompt; the user then decides what is sent. */
|
|
506
|
+
const startRefine = () => {
|
|
507
|
+
const text = promptTextRef.current.trim()
|
|
508
|
+
const cfg = configRef.current
|
|
509
|
+
if (!text) {
|
|
510
|
+
push("sys", "Type a request first, then press ctrl+r to refine it.", "chat")
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
const why = cfg ? brainUnavailable(cfg) : "Config is not loaded."
|
|
514
|
+
if (!cfg || why) {
|
|
515
|
+
push("err", `Refine needs ForgeQE's agent: ${why}`, "chat")
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
const controller = new AbortController()
|
|
519
|
+
refineAbort.current = controller
|
|
520
|
+
setRefine({ phase: "working", original: text })
|
|
521
|
+
void (options.refine || refine)({ config: cfg, cwd: sessionSetup(cfg, options.repo).cwd, text, recent: convoRef.current, signal: controller.signal })
|
|
522
|
+
.then((result) => {
|
|
523
|
+
if (controller.signal.aborted || refiningRef.current?.phase !== "working") return
|
|
524
|
+
setRefine({ phase: "done", original: text, result, choice: 0 })
|
|
525
|
+
})
|
|
526
|
+
.catch((err) => {
|
|
527
|
+
if (controller.signal.aborted) return
|
|
528
|
+
setRefine({ phase: "error", original: text, message: (err as Error).message })
|
|
529
|
+
fillPrompt(text)
|
|
530
|
+
})
|
|
531
|
+
.finally(() => {
|
|
532
|
+
if (refineAbort.current === controller) refineAbort.current = null
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
refineRef.current = startRefine
|
|
536
|
+
|
|
537
|
+
const chooseRefine = (id: RefineChoice) => {
|
|
538
|
+
const r = refiningRef.current
|
|
539
|
+
if (!r) return
|
|
540
|
+
setRefine(null)
|
|
541
|
+
if (r.phase !== "done") {
|
|
542
|
+
fillPrompt(r.original)
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
if (id === "use") submitRef.current(r.result.refined)
|
|
546
|
+
else fillPrompt(id === "edit" ? r.result.refined : r.original)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const takeNext = (step: NextStep) => {
|
|
550
|
+
setNext([])
|
|
551
|
+
if (step.run.kind === "insert") fillPrompt(step.run.text)
|
|
552
|
+
else (options.openUrl || openInBrowser)(`file://${path.resolve(step.run.path)}`)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const pickPalette = (item: PaletteItem) => {
|
|
556
|
+
paletteRef.current = null
|
|
557
|
+
setPalette(null)
|
|
558
|
+
const run = item.run
|
|
559
|
+
if (run.kind === "insert") fillPrompt(run.text)
|
|
560
|
+
else if (run.kind === "command") submitRef.current(run.text)
|
|
561
|
+
else if (run.id === "stop") openDialog("stop")
|
|
562
|
+
else if (run.id === "details" || run.id === "chat") setNarrowView(run.id)
|
|
563
|
+
else if (run.id === "refine") refineRef.current()
|
|
564
|
+
else if (run.id === "wide") setChatWide((w) => !w)
|
|
565
|
+
else if (run.id === "panels") setNav((n) => ({ ...n, focus: planLines(inspRef.current).length ? "plan" : "activity" }))
|
|
566
|
+
}
|
|
567
|
+
|
|
395
568
|
const cycleProfile = () => {
|
|
396
569
|
const cfg = configRef.current
|
|
397
570
|
if (!cfg || abortRef.current) return
|
|
@@ -402,6 +575,51 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
402
575
|
}
|
|
403
576
|
|
|
404
577
|
useKeyboard((key) => {
|
|
578
|
+
const pal = paletteRef.current
|
|
579
|
+
if (pal) {
|
|
580
|
+
// Keys can arrive faster than renders: every change builds on the latest state, never on this render's copy.
|
|
581
|
+
const edit = (f: (p: { query: string; selected: number }) => { query: string; selected: number } | null) => {
|
|
582
|
+
setPalette((p) => {
|
|
583
|
+
const next = p ? f(p) : p
|
|
584
|
+
paletteRef.current = next
|
|
585
|
+
return next
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
if (key.name === "escape" || (key.name === "p" && key.ctrl)) edit(() => null)
|
|
589
|
+
else if (key.name === "up") edit((p) => ({ ...p, selected: Math.max(0, p.selected - 1) }))
|
|
590
|
+
else if (key.name === "down") edit((p) => ({ ...p, selected: Math.min(Math.max(0, paletteMatches(p.query).length - 1), p.selected + 1) }))
|
|
591
|
+
else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") {
|
|
592
|
+
const current = paletteRef.current!
|
|
593
|
+
const item = paletteMatches(current.query)[current.selected]
|
|
594
|
+
if (item) pickPalette(item)
|
|
595
|
+
else {
|
|
596
|
+
edit(() => null)
|
|
597
|
+
if (current.query.trim()) fillPrompt(current.query.trim())
|
|
598
|
+
}
|
|
599
|
+
} else if (key.name === "backspace") edit((p) => ({ query: p.query.slice(0, -1), selected: 0 }))
|
|
600
|
+
else if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence >= " ") {
|
|
601
|
+
const ch = key.sequence
|
|
602
|
+
edit((p) => ({ query: p.query + ch, selected: 0 }))
|
|
603
|
+
}
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
const r = refiningRef.current
|
|
607
|
+
if (r && !dialogRef.current && !(key.name === "c" && key.ctrl)) {
|
|
608
|
+
if (r.phase === "working") {
|
|
609
|
+
if (key.name === "escape") {
|
|
610
|
+
refineAbort.current?.abort()
|
|
611
|
+
setRefine(null)
|
|
612
|
+
fillPrompt(r.original)
|
|
613
|
+
}
|
|
614
|
+
} else if (r.phase === "error") {
|
|
615
|
+
if (key.name === "escape" || key.name === "return" || key.name === "enter") setRefine(null)
|
|
616
|
+
} else if (key.name === "left" || (key.name === "tab" && key.shift)) setRefine({ ...r, choice: (r.choice + REFINE_CHOICES.length - 1) % REFINE_CHOICES.length })
|
|
617
|
+
else if (key.name === "right" || key.name === "tab") setRefine({ ...r, choice: (r.choice + 1) % REFINE_CHOICES.length })
|
|
618
|
+
else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") chooseRefine(REFINE_CHOICES[r.choice]!.id)
|
|
619
|
+
else if (key.name === "e") chooseRefine("edit")
|
|
620
|
+
else if (key.name === "escape") chooseRefine("keep")
|
|
621
|
+
return
|
|
622
|
+
}
|
|
405
623
|
const open = dialogRef.current
|
|
406
624
|
if (key.name === "c" && key.ctrl) {
|
|
407
625
|
if (open === "quit") quit()
|
|
@@ -429,8 +647,68 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
429
647
|
else if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence >= " ") setSecret(secretRef.current + key.sequence)
|
|
430
648
|
return
|
|
431
649
|
}
|
|
650
|
+
// The panels: shift+tab moves focus through those on screen; a focused panel takes the arrows, enter, and esc.
|
|
651
|
+
const panes = (width >= SPLIT_MIN && !chatWide) || (width < SPLIT_MIN && narrowView === "details")
|
|
652
|
+
const insp = inspRef.current
|
|
653
|
+
const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && width >= SPLIT_MIN)
|
|
654
|
+
if (panes && key.name === "tab" && key.shift && !entryRef.current) {
|
|
655
|
+
setNav((n) => ({ ...n, focus: nextFocus(n.focus, shown) }))
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
const at = navRef.current.focus
|
|
659
|
+
if (panes && at !== "chat" && shown(at)) {
|
|
660
|
+
const list = at === "plan" ? planLines(insp).length : at === "files" ? filesByUse(insp).length : 0
|
|
661
|
+
const move = (d: number) => setNav((n) => ({ ...n, [at]: Math.max(0, Math.min(list - 1, (n[at as "plan" | "files"] ?? 0) + d)) }))
|
|
662
|
+
const page = Math.max(3, Math.floor(height / 3))
|
|
663
|
+
if (key.name === "escape") return setNav((n) => ({ ...n, focus: width >= SPLIT_MIN ? "chat" : "activity" }))
|
|
664
|
+
if (at === "activity") {
|
|
665
|
+
const box = activityScroll.current
|
|
666
|
+
if (key.name === "up") return box?.scrollBy(-1)
|
|
667
|
+
if (key.name === "down") return box?.scrollBy(1)
|
|
668
|
+
if (key.name === "pageup") return box?.scrollBy(-page)
|
|
669
|
+
if (key.name === "pagedown") return box?.scrollBy(page)
|
|
670
|
+
const f = ACTIVITY_FILTERS.find((x) => x.key === key.name && !key.ctrl && !key.meta)
|
|
671
|
+
if (f) return setNav((n) => ({ ...n, filter: f.id }))
|
|
672
|
+
} else {
|
|
673
|
+
if (key.name === "up") return move(-1)
|
|
674
|
+
if (key.name === "down") return move(1)
|
|
675
|
+
if (key.name === "pageup") return move(-page)
|
|
676
|
+
if (key.name === "pagedown") return move(page)
|
|
677
|
+
if (key.name === "home") return move(-list)
|
|
678
|
+
if (key.name === "end") return move(list)
|
|
679
|
+
if (key.name === "return" || key.name === "enter" || key.name === "kpenter") {
|
|
680
|
+
if (at === "files") {
|
|
681
|
+
const f = filesByUse(insp)[Math.min(navRef.current.files, list - 1)]
|
|
682
|
+
if (f) openFile(f.path)
|
|
683
|
+
} else {
|
|
684
|
+
const l = planLines(insp)[Math.min(navRef.current.plan, list - 1)]
|
|
685
|
+
if (l) push("sys", `Plan ${l.mark} ${l.text}`, "chat")
|
|
686
|
+
}
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// 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 > " " && width >= SPLIT_MIN) return setNav((n) => ({ ...n, focus: "chat" }))
|
|
692
|
+
if (!key.ctrl && !key.meta) return
|
|
693
|
+
}
|
|
694
|
+
if (suggRef.current.list.length && suggRef.current.trigger && !entryRef.current) {
|
|
695
|
+
const n = suggRef.current.list.length
|
|
696
|
+
if (key.name === "up") return setSugg((x) => ({ ...x, selected: (x.selected + n - 1) % n }))
|
|
697
|
+
if (key.name === "down") return setSugg((x) => ({ ...x, selected: (x.selected + 1) % n }))
|
|
698
|
+
if (key.name === "tab" && !key.shift) return acceptSuggestion(Math.min(sugg.selected, n - 1))
|
|
699
|
+
if (key.name === "escape") return setSugg({ selected: 0, dismissed: `${suggRef.current.trigger.kind}${suggRef.current.trigger.token}` })
|
|
700
|
+
}
|
|
432
701
|
if (key.name === "tab" && !key.shift) cycleProfile()
|
|
433
|
-
if (key.name === "p" && key.ctrl)
|
|
702
|
+
if (key.name === "p" && key.ctrl) {
|
|
703
|
+
paletteRef.current = { query: "", selected: 0 }
|
|
704
|
+
setPalette(paletteRef.current)
|
|
705
|
+
}
|
|
706
|
+
if (key.meta && /^[1-3]$/.test(key.name || "") && showNext) {
|
|
707
|
+
const step = next[Number(key.name) - 1]
|
|
708
|
+
if (step) takeNext(step)
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
if (key.name === "r" && key.ctrl && !entryRef.current) startRefine()
|
|
434
712
|
if (key.name === "o" && key.ctrl && width < SPLIT_MIN) setNarrowView((v) => (v === "chat" ? "details" : "chat"))
|
|
435
713
|
if (key.name === "escape" && abortRef.current) openDialog("stop")
|
|
436
714
|
})
|
|
@@ -493,7 +771,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
493
771
|
push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
|
|
494
772
|
// The action itself, without the result after a colon or the locator in brackets: "click "Search"".
|
|
495
773
|
const action = event.text.split(/: | {2}\[/)[0]!.trim()
|
|
496
|
-
|
|
774
|
+
// The step is named by its action, so the failure detail is what follows it: "element not found".
|
|
775
|
+
const why = event.text.slice(action.length).replace(/^[\s:\[\]]+/, "").trim() || event.text
|
|
776
|
+
stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(why, 80), clip(action, 60))
|
|
497
777
|
bump()
|
|
498
778
|
}
|
|
499
779
|
return
|
|
@@ -643,6 +923,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
643
923
|
case "card":
|
|
644
924
|
pushLine({ kind: "card", text: e.card.title, card: e.card })
|
|
645
925
|
return
|
|
926
|
+
case "report":
|
|
927
|
+
producedRef.current.reports.push({ title: e.title, html: e.html })
|
|
928
|
+
return
|
|
646
929
|
}
|
|
647
930
|
}
|
|
648
931
|
|
|
@@ -698,7 +981,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
698
981
|
return controller
|
|
699
982
|
}
|
|
700
983
|
|
|
701
|
-
const finish = (finalStatus: string) => {
|
|
984
|
+
const finish = (finalStatus: string, followUps: FollowUp[] = []) => {
|
|
985
|
+
setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on, followUps }))
|
|
702
986
|
progressDone(inspRef.current, finalStatus === "stopped")
|
|
703
987
|
bump()
|
|
704
988
|
setStatus(finalStatus)
|
|
@@ -742,6 +1026,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
742
1026
|
const setup = sessionSetup(cfg, options.repo)
|
|
743
1027
|
testsRef.current = { on: setup.writeTests, why: "the session was started with --no-code" }
|
|
744
1028
|
let finalStatus = "fail"
|
|
1029
|
+
let followUps: FollowUp[] = []
|
|
745
1030
|
let fresh = false
|
|
746
1031
|
try {
|
|
747
1032
|
if (!brainRef.current) {
|
|
@@ -769,6 +1054,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
769
1054
|
finalStatus = "stopped"
|
|
770
1055
|
} else {
|
|
771
1056
|
if (answer.reply) pushLine({ kind: "reply", text: answer.reply, outcome: answer.outcome })
|
|
1057
|
+
followUps = answer.next || []
|
|
772
1058
|
if (answer.error && !answer.reply) push("err", answer.error, "chat")
|
|
773
1059
|
finalStatus = answer.outcome === "done" ? "done" : answer.outcome === "needs_input" ? "idle" : "fail"
|
|
774
1060
|
}
|
|
@@ -781,7 +1067,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
781
1067
|
// The split view's session and tokens panels show this; only the one-pane view needs it in the chat.
|
|
782
1068
|
const spent = widthRef.current < SPLIT_MIN ? requestLine(meterStart) : ""
|
|
783
1069
|
if (spent) push("sys", spent, "chat")
|
|
784
|
-
finish(finalStatus)
|
|
1070
|
+
finish(finalStatus, followUps)
|
|
785
1071
|
}
|
|
786
1072
|
}
|
|
787
1073
|
|
|
@@ -831,7 +1117,22 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
831
1117
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
832
1118
|
}, [])
|
|
833
1119
|
|
|
1120
|
+
const acceptSuggestion = (i: number) => {
|
|
1121
|
+
const { list, trigger } = suggRef.current
|
|
1122
|
+
const pick = list[i]
|
|
1123
|
+
if (!pick || !trigger) return
|
|
1124
|
+
setSugg({ selected: 0 })
|
|
1125
|
+
fillPrompt(complete(promptTextRef.current, trigger, pick.insert))
|
|
1126
|
+
}
|
|
1127
|
+
|
|
834
1128
|
const onSubmit = (value: string) => {
|
|
1129
|
+
// Enter with suggestions open takes the selected one, as tab does; a second enter sends. A command typed in full
|
|
1130
|
+
// ("/help") is sent at once.
|
|
1131
|
+
const full = suggRef.current.list.some((x) => x.kind === "command" && x.insert === value.trim())
|
|
1132
|
+
if (suggRef.current.list.length && suggRef.current.trigger && !full) {
|
|
1133
|
+
acceptSuggestion(Math.min(sugg.selected, suggRef.current.list.length - 1))
|
|
1134
|
+
return
|
|
1135
|
+
}
|
|
835
1136
|
// What a command answers (/help, /skills, "busy") is for the chat, even while a request runs.
|
|
836
1137
|
answeringRef.current = true
|
|
837
1138
|
try {
|
|
@@ -842,6 +1143,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
842
1143
|
}
|
|
843
1144
|
const submit = (value: string) => {
|
|
844
1145
|
const text = value.replace(/\t/g, "").trim()
|
|
1146
|
+
promptTextRef.current = ""
|
|
1147
|
+
setPromptText("")
|
|
1148
|
+
setDraft("")
|
|
1149
|
+
setNext([])
|
|
845
1150
|
setInputKey((k) => k + 1)
|
|
846
1151
|
const pending = entryRef.current
|
|
847
1152
|
if (pending) {
|
|
@@ -966,41 +1271,77 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
966
1271
|
: ""
|
|
967
1272
|
const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
|
|
968
1273
|
const footerRight = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
|
|
1274
|
+
// Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
|
|
1275
|
+
const suggTrigger = triggerAt(promptText)
|
|
1276
|
+
const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
|
|
1277
|
+
const suggList: Suggestion[] =
|
|
1278
|
+
!suggTrigger || sugg.dismissed === suggKey || palette || refining || entry || dialog
|
|
1279
|
+
? []
|
|
1280
|
+
: suggTrigger.kind === "/"
|
|
1281
|
+
? commandSuggestions(paletteAll(), suggTrigger.token).slice(0, 8)
|
|
1282
|
+
: configRef.current
|
|
1283
|
+
? referenceSuggestions(suggTrigger.token, {
|
|
1284
|
+
runs: recentRuns(configRef.current.export.outDir),
|
|
1285
|
+
files: repoFiles(process.cwd()),
|
|
1286
|
+
item: workItem && String(workItem.id) === suggTrigger.token ? workItem : null,
|
|
1287
|
+
cwd: process.cwd(),
|
|
1288
|
+
})
|
|
1289
|
+
: []
|
|
1290
|
+
suggRef.current = { list: suggList, trigger: suggTrigger }
|
|
1291
|
+
const showNext = next.length > 0 && !running && !promptText.trim() && !palette && !refining && !dialog && !entry
|
|
969
1292
|
const home = lines.length === 0 && !running
|
|
1293
|
+
const paletteList = palette ? paletteMatches(palette.query) : []
|
|
1294
|
+
const paletteOverlay = palette ? (
|
|
1295
|
+
<Palette width={width} height={height} query={palette.query} items={paletteList} selected={Math.min(palette.selected, Math.max(0, paletteList.length - 1))} onPick={pickPalette} onHover={(i) => setPalette({ ...palette, selected: i })} />
|
|
1296
|
+
) : null
|
|
970
1297
|
|
|
971
1298
|
const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
|
|
972
1299
|
const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
|
|
973
|
-
// The split view's right pane shows tokens, time, and the last result, so the
|
|
1300
|
+
// The split view's right pane shows tokens, time, and the last result, so the row under the prompt leaves them out.
|
|
974
1301
|
const wide = width >= SPLIT_MIN
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
const
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
...(wide ? [] : [{ key: "ctrl+p", label: "commands" }]),
|
|
991
|
-
closeHint,
|
|
1302
|
+
const openPalette = () => {
|
|
1303
|
+
paletteRef.current = { query: "", selected: 0 }
|
|
1304
|
+
setPalette(paletteRef.current)
|
|
1305
|
+
}
|
|
1306
|
+
const focusPanels = () => setNav((n) => ({ ...n, focus: planLines(inspRef.current).length ? "plan" : "activity" }))
|
|
1307
|
+
// What can be done from here, as buttons: Refine fills with its colour once there is a request to refine.
|
|
1308
|
+
// `drop`: the order they give way on a narrow row; Refine, Search, and Stop always stay.
|
|
1309
|
+
const refineButton: Button = { icon: "✦", label: "Refine", keys: "ctrl+r", color: theme.memory, onPress: () => refineRef.current(), active: Boolean(promptText.trim()) }
|
|
1310
|
+
const searchButton: Button = { icon: "⌕", label: "Search", keys: "ctrl+p", color: theme.accent, onPress: openPalette }
|
|
1311
|
+
// The view: in the split view, Panels and Wide chat sit in the right pane's status line, where there is room;
|
|
1312
|
+
// Split view comes back here when the chat is wide, and Details on a narrow terminal.
|
|
1313
|
+
const toggleWide = () => setChatWide((w) => !w)
|
|
1314
|
+
const paneButtons: Button[] = [
|
|
1315
|
+
{ icon: "▦", label: "Panels", keys: "shift+tab", color: theme.thought, onPress: focusPanels, drop: 2 },
|
|
1316
|
+
{ icon: "⤢", label: "Wide chat", color: theme.muted, onPress: toggleWide, drop: 1 },
|
|
992
1317
|
]
|
|
1318
|
+
const viewButtons: Button[] = wide
|
|
1319
|
+
? chatWide
|
|
1320
|
+
? [{ icon: "◧", label: "Split view", color: theme.thought, onPress: toggleWide, drop: 2 }]
|
|
1321
|
+
: []
|
|
1322
|
+
: [{ icon: "◧", label: "Details", keys: "ctrl+o", color: theme.thought, onPress: () => setNarrowView("details"), drop: 1 }]
|
|
1323
|
+
const idleButtons: Button[] = [refineButton, searchButton, ...viewButtons, ...(wide ? [] : [{ icon: "⇄", label: "Profile", keys: "tab", color: theme.muted, onPress: cycleProfile, drop: 4 }])]
|
|
1324
|
+
const runningButtons: Button[] = [{ icon: "■", label: "Stop", keys: "esc", color: theme.warn, onPress: () => openDialog("stop") }, searchButton, ...viewButtons]
|
|
1325
|
+
const idleHints: Hint[] = [...copiedHint, ...(lastStats && !wide && !copied ? [{ key: `last run ${lastStats}`, label: "" }] : []), closeHint]
|
|
1326
|
+
// The one-pane view has no tokens panel, so the tokens and time show here.
|
|
1327
|
+
const runningHints: Hint[] = [...copiedHint, ...(wide || copied ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" }]), closeHint]
|
|
993
1328
|
const runningLeft = (
|
|
994
|
-
|
|
1329
|
+
<box style={{ flexDirection: "row", marginRight: 1 }}>
|
|
995
1330
|
<Spinner kind="bar" />
|
|
996
|
-
|
|
997
|
-
<span fg={theme.text}>{" esc"}</span>
|
|
998
|
-
<span fg={theme.muted}>{" stop"}</span>
|
|
999
|
-
</text>
|
|
1000
|
-
</>
|
|
1331
|
+
</box>
|
|
1001
1332
|
)
|
|
1002
1333
|
|
|
1003
1334
|
const renderControls = (promptWidth: number, placeholder: string, rows: [number, number]) => {
|
|
1335
|
+
if (refining && !dialog) {
|
|
1336
|
+
return (
|
|
1337
|
+
<RefinePanel
|
|
1338
|
+
state={refining}
|
|
1339
|
+
width={promptWidth}
|
|
1340
|
+
onChoose={chooseRefine}
|
|
1341
|
+
onHover={(i) => refining.phase === "done" && setRefine({ ...refining, choice: i })}
|
|
1342
|
+
/>
|
|
1343
|
+
)
|
|
1344
|
+
}
|
|
1004
1345
|
if (dialog) {
|
|
1005
1346
|
const spec = dialogSpec(dialog, running, outDirLabel(), thinking, waitRef.current?.text, askRef.current?.q)
|
|
1006
1347
|
const wide = spec.options.reduce((n, o) => n + o.label.length + 3, 0) > promptWidth - 36
|
|
@@ -1020,6 +1361,45 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1020
1361
|
}
|
|
1021
1362
|
return (
|
|
1022
1363
|
<>
|
|
1364
|
+
{showNext && !suggList.length ? (
|
|
1365
|
+
// One row when the chips fit beside "Next", else one per line: a ragged wrap reads as broken.
|
|
1366
|
+
next.reduce((n, st) => n + st.label.length + 9, 6) <= promptWidth ? (
|
|
1367
|
+
<box style={{ flexDirection: "row", width: promptWidth, flexShrink: 0, marginBottom: 1 }}>
|
|
1368
|
+
<text fg={theme.muted}>{"Next "}</text>
|
|
1369
|
+
{next.map((step, i) => (
|
|
1370
|
+
<box key={step.label} onMouseDown={() => takeNext(step)} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: theme.button }}>
|
|
1371
|
+
<text>
|
|
1372
|
+
<span fg={theme.text}>{step.label}</span>
|
|
1373
|
+
<span fg={theme.faint}>{` alt+${i + 1}`}</span>
|
|
1374
|
+
</text>
|
|
1375
|
+
</box>
|
|
1376
|
+
))}
|
|
1377
|
+
</box>
|
|
1378
|
+
) : (
|
|
1379
|
+
<box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0, marginBottom: 1 }}>
|
|
1380
|
+
{next.map((step, i) => (
|
|
1381
|
+
<box key={step.label} onMouseDown={() => takeNext(step)} style={{ flexDirection: "row", justifyContent: "space-between", flexShrink: 0 }}>
|
|
1382
|
+
<text>
|
|
1383
|
+
<span fg={theme.muted}>{i ? " " : "Next "}</span>
|
|
1384
|
+
<span fg={theme.accent}>{"▸ "}</span>
|
|
1385
|
+
<span fg={theme.text}>{step.label}</span>
|
|
1386
|
+
</text>
|
|
1387
|
+
<text fg={theme.faint}>{`alt+${i + 1}`}</text>
|
|
1388
|
+
</box>
|
|
1389
|
+
))}
|
|
1390
|
+
</box>
|
|
1391
|
+
)
|
|
1392
|
+
) : null}
|
|
1393
|
+
{suggList.length && suggTrigger ? (
|
|
1394
|
+
<SuggestList
|
|
1395
|
+
title={suggTrigger.kind === "/" ? "Commands" : "References"}
|
|
1396
|
+
width={promptWidth}
|
|
1397
|
+
items={suggList.map((x) => ({ label: x.label, detail: x.detail, color: x.kind === "command" ? theme.accent : x.kind === "run" ? theme.thought : x.kind === "item" ? theme.ok : theme.memory }))}
|
|
1398
|
+
selected={Math.min(sugg.selected, suggList.length - 1)}
|
|
1399
|
+
onPick={acceptSuggestion}
|
|
1400
|
+
onHover={(i) => setSugg((x) => ({ ...x, selected: i }))}
|
|
1401
|
+
/>
|
|
1402
|
+
) : null}
|
|
1023
1403
|
<Prompt
|
|
1024
1404
|
width={promptWidth}
|
|
1025
1405
|
inputKey={inputKey}
|
|
@@ -1034,21 +1414,50 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1034
1414
|
right={right}
|
|
1035
1415
|
rightColor={rightColor}
|
|
1036
1416
|
compact={width >= SPLIT_MIN && !home}
|
|
1417
|
+
focused={!palette && !refining && (nav.focus === "chat" || (width < SPLIT_MIN && narrowView === "chat"))}
|
|
1418
|
+
initialValue={draft}
|
|
1419
|
+
onText={(t) => {
|
|
1420
|
+
promptTextRef.current = t
|
|
1421
|
+
setPromptText(t)
|
|
1422
|
+
}}
|
|
1037
1423
|
/>
|
|
1038
|
-
<
|
|
1424
|
+
<ButtonBar width={promptWidth} buttons={running ? runningButtons : idleButtons} hints={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
|
|
1039
1425
|
</>
|
|
1040
1426
|
)
|
|
1041
1427
|
}
|
|
1042
1428
|
|
|
1043
1429
|
if (home) {
|
|
1044
1430
|
const promptWidth = Math.max(40, Math.min(width - 8, 120))
|
|
1431
|
+
// What is set up, while something is not: each open item says how, or runs the command that does it.
|
|
1432
|
+
const cfgHome = configRef.current
|
|
1433
|
+
const setupKey = cfgHome ? `${cfgHome.configPath}|${cfgHome.activeProfile}` : ""
|
|
1434
|
+
if (cfgHome && setupRef.current.key !== setupKey) setupRef.current = { key: setupKey, items: setupChecklist(cfgHome) }
|
|
1435
|
+
const setup = setupRef.current.items
|
|
1436
|
+
const open = setup.filter((i) => !i.done)
|
|
1437
|
+
const pickSetup = (i: SetupItem) => (i.command ? submitRef.current(i.command) : i.fix && push("sys", i.fix, "chat"))
|
|
1045
1438
|
return (
|
|
1046
1439
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1047
1440
|
<box style={{ flexDirection: "column", flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
|
|
1048
1441
|
<Logo />
|
|
1049
1442
|
<box style={{ height: 2 }} />
|
|
1050
1443
|
{renderControls(promptWidth, `Describe a goal… "${example}"`, [4, Math.max(4, Math.min(12, height - 22))])}
|
|
1051
|
-
<box style={{ height: 3 }} />
|
|
1444
|
+
<box style={{ height: open.length ? 2 : 3 }} />
|
|
1445
|
+
{open.length ? (
|
|
1446
|
+
<box style={{ flexDirection: "column", alignItems: "center", flexShrink: 0, marginBottom: 1 }}>
|
|
1447
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
1448
|
+
<text fg={theme.muted}>{"Setup "}</text>
|
|
1449
|
+
{(setup.reduce((n, i) => n + i.label.length + 5, 7) <= width - 4 ? setup : open).map((i) => (
|
|
1450
|
+
<box key={i.id} onMouseDown={i.done ? undefined : () => pickSetup(i)} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: i.done ? undefined : theme.button }}>
|
|
1451
|
+
<text>
|
|
1452
|
+
<span fg={i.done ? theme.ok : theme.warn}>{i.done ? "✓ " : "○ "}</span>
|
|
1453
|
+
<span fg={i.done ? theme.muted : theme.text}>{i.label}</span>
|
|
1454
|
+
</text>
|
|
1455
|
+
</box>
|
|
1456
|
+
))}
|
|
1457
|
+
</box>
|
|
1458
|
+
<text fg={theme.faint}>{`${setup.length - open.length} of ${setup.length} set up · click one to see how · /features shows what each feature needs`}</text>
|
|
1459
|
+
</box>
|
|
1460
|
+
) : null}
|
|
1052
1461
|
<text>
|
|
1053
1462
|
<span fg={theme.warn}>{"● "}</span>
|
|
1054
1463
|
<span fg={theme.text}>{"Tip "}</span>
|
|
@@ -1056,6 +1465,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1056
1465
|
</text>
|
|
1057
1466
|
</box>
|
|
1058
1467
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1468
|
+
{paletteOverlay}
|
|
1059
1469
|
</box>
|
|
1060
1470
|
)
|
|
1061
1471
|
}
|
|
@@ -1094,7 +1504,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1094
1504
|
? coding
|
|
1095
1505
|
? "The coding agent is writing a test · esc to stop"
|
|
1096
1506
|
: `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
|
|
1097
|
-
:
|
|
1507
|
+
: width >= SPLIT_MIN
|
|
1508
|
+
? "Ask ForgeQE · / commands · @ references"
|
|
1509
|
+
: "Ask ForgeQE: a goal, test cases, a test, a pipeline… (/ commands, @ references)"
|
|
1098
1510
|
const chatLines = lines.filter((l) => l.pane !== "activity")
|
|
1099
1511
|
const activityLines = lines.filter((l) => l.pane === "activity")
|
|
1100
1512
|
const stop = () => openDialog("stop")
|
|
@@ -1108,14 +1520,25 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1108
1520
|
const plan = planRows(insp, Math.max(3, Math.min(8, Math.floor(rowsAvailable / 5))))
|
|
1109
1521
|
const activityRows = Math.max(5, rowsAvailable - 2 - plan - bottom)
|
|
1110
1522
|
const third = Math.floor(paneWidth / 3)
|
|
1523
|
+
const focusOn = (f: Focus) => () => setNav((n) => ({ ...n, focus: f }))
|
|
1524
|
+
const sel = (f: "plan" | "files") => (nav.focus === f ? Math.min(nav[f], Math.max(0, (f === "plan" ? planLines(insp) : filesByUse(insp)).length - 1)) : undefined)
|
|
1111
1525
|
return (
|
|
1112
1526
|
<box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
|
|
1113
|
-
<StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} />
|
|
1527
|
+
<StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress || planProgress(insp)} actions={paneButtons} />
|
|
1114
1528
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1115
|
-
{plan ? <PlanPanel s={insp} width={paneWidth} height={plan} /> : null}
|
|
1116
|
-
<ActivityPanel
|
|
1529
|
+
{plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel("plan")} onFocus={focusOn("plan")} /> : null}
|
|
1530
|
+
<ActivityPanel
|
|
1531
|
+
lines={activityLines}
|
|
1532
|
+
scrollRef={activityScroll}
|
|
1533
|
+
width={paneWidth}
|
|
1534
|
+
height={activityRows}
|
|
1535
|
+
filter={nav.filter}
|
|
1536
|
+
focused={nav.focus === "activity"}
|
|
1537
|
+
onFocus={focusOn("activity")}
|
|
1538
|
+
onFilter={(filter) => setNav((n) => ({ ...n, filter, focus: "activity" }))}
|
|
1539
|
+
/>
|
|
1117
1540
|
<box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
|
|
1118
|
-
<FilesPanel s={insp} width={third} height={bottom} />
|
|
1541
|
+
<FilesPanel s={insp} width={third} height={bottom} selected={sel("files")} onFocus={focusOn("files")} onOpen={openFile} />
|
|
1119
1542
|
<ContextPanel c={contextPanel} width={third} height={bottom} />
|
|
1120
1543
|
<TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
|
|
1121
1544
|
</box>
|
|
@@ -1128,7 +1551,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1128
1551
|
{all.map((line) => (
|
|
1129
1552
|
<TranscriptLine key={line.id} line={line} />
|
|
1130
1553
|
))}
|
|
1131
|
-
{running && !split ? (
|
|
1554
|
+
{running && (!split || chatWide) ? (
|
|
1132
1555
|
<box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
|
|
1133
1556
|
<Spinner kind="dots" />
|
|
1134
1557
|
<text fg={theme.text}>{` ${activity || "Working…"}`}</text>
|
|
@@ -1140,20 +1563,25 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1140
1563
|
|
|
1141
1564
|
const split = width >= SPLIT_MIN
|
|
1142
1565
|
if (split) {
|
|
1143
|
-
|
|
1566
|
+
// Wide chat: the conversation across the screen for reading, the panels hidden until Split view.
|
|
1567
|
+
const leftWidth = chatWide ? width : Math.floor(width * 0.4)
|
|
1144
1568
|
return (
|
|
1145
1569
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1146
1570
|
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1147
|
-
<box style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1148
|
-
<SessionPanel info={sessionInfo} width={leftWidth - 3} />
|
|
1149
|
-
{
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1153
|
-
{details(width - leftWidth - 3, height - 2)}
|
|
1571
|
+
<box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: chatWide ? 2 : 1 }}>
|
|
1572
|
+
<SessionPanel info={sessionInfo} width={leftWidth - (chatWide ? 4 : 3)} />
|
|
1573
|
+
{/* Wide chat shows what the agents do in the conversation, as the one-pane view does. */}
|
|
1574
|
+
{transcript(chatWide ? lines : chatLines, {})}
|
|
1575
|
+
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - (chatWide ? 4 : 3))}</box>
|
|
1154
1576
|
</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
|
+
)}
|
|
1155
1582
|
</box>
|
|
1156
1583
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1584
|
+
{paletteOverlay}
|
|
1157
1585
|
</box>
|
|
1158
1586
|
)
|
|
1159
1587
|
}
|
|
@@ -1168,10 +1596,11 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1168
1596
|
// Stop, a question, or "your turn": the same panel the chat view shows in place of the prompt.
|
|
1169
1597
|
controls(promptWidth)
|
|
1170
1598
|
) : (
|
|
1171
|
-
<Hints width={promptWidth} items={[{ key: "ctrl+o", label: "back to the chat", onPress: () => setNarrowView("chat") }, closeHint]} />
|
|
1599
|
+
<Hints width={promptWidth} items={[{ key: "ctrl+o", label: "back to the chat", onPress: () => setNarrowView("chat") }, { key: "shift+tab", label: "panels", drop: 1 }, closeHint]} />
|
|
1172
1600
|
)}
|
|
1173
1601
|
</box>
|
|
1174
1602
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1603
|
+
{paletteOverlay}
|
|
1175
1604
|
</box>
|
|
1176
1605
|
)
|
|
1177
1606
|
}
|
|
@@ -1180,6 +1609,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1180
1609
|
{transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 })}
|
|
1181
1610
|
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>{controls(promptWidth)}</box>
|
|
1182
1611
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1612
|
+
{paletteOverlay}
|
|
1183
1613
|
</box>
|
|
1184
1614
|
)
|
|
1185
1615
|
}
|