@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/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 { ActivityPanel, ContextPanel, FilesPanel, PlanPanel, planRows, SessionPanel, StatusBar, TokensPanel, type ContextInfo, type SessionInfo } from "./inspector.tsx"
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) push("sys", HELP, "chat")
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
- stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(event.text, 80), clip(action, 60))
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 hints under the prompt leave them out.
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 viewHint: Hint[] = wide ? [] : [{ key: "ctrl+o", label: "details", onPress: () => setNarrowView("details") }]
976
- // While running, the row also holds the spinner and esc stop: below 95 columns the view hint does not fit beside them.
977
- const runningViewHint: Hint[] = width >= 95 ? viewHint : []
978
- const idleHints: Hint[] = [
979
- ...copiedHint,
980
- ...(lastStats && !wide ? [{ key: `last run ${lastStats}`, label: "" }] : []),
981
- ...viewHint,
982
- { key: "tab", label: "profiles" },
983
- { key: "ctrl+p", label: "commands" },
984
- closeHint,
985
- ]
986
- const runningHints: Hint[] = [
987
- ...copiedHint,
988
- ...(wide ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" }, ...runningViewHint]),
989
- // Beside the 40% pane's spinner and esc stop, only close fits from 120 columns.
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
- <text onMouseDown={() => openDialog("stop")}>
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
- <Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
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
- : "Ask ForgeQE: a goal, test cases, a test, a pipeline…"
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 lines={activityLines} scrollRef={activityScroll} width={paneWidth} height={activityRows} />
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
- const leftWidth = Math.floor(width * 0.4)
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
- {transcript(chatLines, {})}
1150
- <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1151
- </box>
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
  }