@capagents/forgeqe 1.0.3 → 1.0.5

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,5 +1,6 @@
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
 
@@ -23,21 +24,44 @@ import { buildSummary, shortPath } from "../summary.ts"
23
24
  import { meterSnapshot, requestLine } from "../usage.ts"
24
25
  import { cleanSelection, copyText } from "./clipboard.ts"
25
26
  import { ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Hint } from "./components.tsx"
27
+ import { setupChecklist, type SetupItem } from "./setup.ts"
26
28
  import { EXAMPLES, TIPS, formatDuration, formatTokens, packageVersion, pick, shortCwd, theme } from "./theme.ts"
27
29
  import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
28
30
  import { Dialog, dialogSpec } from "./dialogs.ts"
29
31
  import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
30
32
  import { featuresText } from "../capabilities/render.ts"
33
+ import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
34
+ import { commandSuggestions, complete, lookupWorkItem, recentRuns, referenceSuggestions, repoFiles, triggerAt, type Suggestion, type WorkItem } from "./suggest.ts"
35
+ import { REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
36
+ import { refine } from "../refine.ts"
37
+ import { nextSteps, type NextStep } from "./next.ts"
31
38
  import { CAPABILITIES } from "../capabilities/registry.ts"
32
39
  import { CONTEXT_LIMIT } from "../code/runtime.ts"
33
40
  import { readKnowledge } from "../knowledge.ts"
34
41
  import { externalMcp } from "../mcp/external.ts"
35
42
  import { meterSince } from "../usage.ts"
36
- import { ActivityPanel, ContextPanel, FilesPanel, PlanPanel, planRows, SessionPanel, StatusBar, TokensPanel, type ContextInfo, type SessionInfo } from "./inspector.tsx"
43
+ import {
44
+ ACTIVITY_FILTERS,
45
+ ActivityPanel,
46
+ ContextPanel,
47
+ FilesPanel,
48
+ nextFocus,
49
+ planLines,
50
+ PlanPanel,
51
+ planRows,
52
+ SessionPanel,
53
+ StatusBar,
54
+ TokensPanel,
55
+ type ActivityFilter,
56
+ type ContextInfo,
57
+ type Focus,
58
+ type SessionInfo,
59
+ } from "./inspector.tsx"
37
60
  import {
38
61
  addSkill,
39
62
  caseDone,
40
63
  caseStarted,
64
+ filesByUse,
41
65
  newInspector,
42
66
  newRequest,
43
67
  paneFor,
@@ -72,6 +96,8 @@ export type TuiOptions = {
72
96
  copy?: (text: string) => boolean
73
97
  /** Show everything the agents do (their reasoning, every file read, engine logs); /details toggles it. */
74
98
  verbose?: boolean
99
+ /** Replaces Refine's model session in tests. */
100
+ refine?: typeof refine
75
101
  /** Replaces opening a page in the browser (sign-in) in tests. */
76
102
  openUrl?: (url: string) => void
77
103
  }
@@ -83,9 +109,14 @@ export const SPLIT_MIN = 120
83
109
  export function App({ options }: { options: TuiOptions }) {
84
110
  const renderer = useRenderer()
85
111
  const { width, height } = useTerminalDimensions()
112
+ /** The terminal's width when a request ends (callbacks outlive the render they were made in). */
113
+ const widthRef = useRef(width)
114
+ widthRef.current = width
86
115
  const scrollRef = useRef<ScrollBoxRenderable>(null)
87
116
  const activityScroll = useRef<ScrollBoxRenderable>(null)
88
117
  const submitRef = useRef<(value: string) => void>(() => {})
118
+ /** Refines what is in the prompt (phase two of the palette work wires it). */
119
+ const refineRef = useRef<() => void>(() => {})
89
120
  const configRef = useRef<AppConfig | null>(null)
90
121
  const abortRef = useRef<AbortController | null>(null)
91
122
  const brainRef = useRef<Brain | null>(null)
@@ -107,6 +138,25 @@ export function App({ options }: { options: TuiOptions }) {
107
138
  const codeRunDir = useRef("")
108
139
  /** The right pane's live state: plan, steps or cases, files, context. */
109
140
  const inspRef = useRef(newInspector())
141
+ /** The command palette: open, what is typed in it, and which match is selected. */
142
+ const [palette, setPalette] = useState<{ query: string; selected: number } | null>(null)
143
+ const paletteRef = useRef(palette)
144
+ paletteRef.current = palette
145
+ /** Refine (ctrl+r): what the prompt holds, the panel's state, and how to cancel it. */
146
+ const promptTextRef = useRef("")
147
+ const [refining, setRefining] = useState<RefineState | null>(null)
148
+ const refiningRef = useRef(refining)
149
+ refiningRef.current = refining
150
+ const refineAbort = useRef<AbortController | null>(null)
151
+ /** One-click follow-ups after a request, from what it produced. */
152
+ const [next, setNext] = useState<NextStep[]>([])
153
+ /** What the prompt holds, for suggestions; and the suggestion state: which is selected, which word was dismissed. */
154
+ const [promptText, setPromptText] = useState("")
155
+ const [sugg, setSugg] = useState<{ selected: number; dismissed?: string }>({ selected: 0 })
156
+ const [workItem, setWorkItem] = useState<WorkItem | null | undefined>(undefined)
157
+ const suggRef = useRef<{ list: Suggestion[]; trigger: ReturnType<typeof triggerAt> }>({ list: [], trigger: null })
158
+ /** Text the prompt starts with on its next remount (an example from the palette, a refined request). */
159
+ const [draft, setDraft] = useState("")
110
160
  /** Set while a typed line is handled, so the shell's answers to it go to the chat. */
111
161
  const answeringRef = useRef(false)
112
162
  const [, setRev] = useState(0)
@@ -117,6 +167,15 @@ export function App({ options }: { options: TuiOptions }) {
117
167
  const requests = useRef(0)
118
168
  /** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
119
169
  const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
170
+ const setupRef = useRef<{ key: string; items: SetupItem[] }>({ key: "", items: [] })
171
+ // Which panel the keys go to (shift+tab moves on), the row selected in the plan and files, and the activity filter.
172
+ const [nav, setNavState] = useState<{ focus: Focus; plan: number; files: number; filter: ActivityFilter }>({ focus: "chat", plan: 0, files: 0, filter: "all" })
173
+ const navRef = useRef(nav)
174
+ // Keys can arrive faster than renders: each change builds on the ref, which the next key reads at once.
175
+ const setNav = (f: (n: typeof nav) => typeof nav) => {
176
+ navRef.current = f(navRef.current)
177
+ setNavState(navRef.current)
178
+ }
120
179
  const contextInfo = useRef<{ key: string; knowledge: number; mcp: string[] }>({ key: "", knowledge: 0, mcp: [] })
121
180
 
122
181
  const [lines, setLines] = useState<Line[]>([])
@@ -187,6 +246,19 @@ export function App({ options }: { options: TuiOptions }) {
187
246
  }
188
247
  /** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
189
248
  const pushTool = (text: string, ok: boolean) => push(!ok ? "err" : /^(Writing|Editing) /.test(text) ? "file" : "tool", text)
249
+ /** A short note in the hint row that clears itself: "opened report.html". */
250
+ const flashTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
251
+ const flash = (text: string) => {
252
+ setCopied(text)
253
+ clearTimeout(flashTimer.current)
254
+ flashTimer.current = setTimeout(() => setCopied(""), 2500)
255
+ }
256
+ const openFile = (file: string) => {
257
+ const abs = path.resolve(file)
258
+ if (!existsSync(abs)) return flash(`${path.basename(file)} is no longer there`)
259
+ ;(options.openUrl || openInBrowser)(`file://${abs}`)
260
+ flash(`opened ${path.basename(file)}`)
261
+ }
190
262
  const pushProduced = () => {
191
263
  const card = producedCard(producedRef.current, testsRef.current)
192
264
  if (card) pushLine({ kind: "card", text: card.title, card })
@@ -197,6 +269,16 @@ export function App({ options }: { options: TuiOptions }) {
197
269
  setTokens([...usedRef.current.values()].reduce((a, b) => a + b, 0))
198
270
  }
199
271
 
272
+ // "@1761": look the work item up (once per id, after a pause in typing) so its title can be suggested.
273
+ useEffect(() => {
274
+ const t = triggerAt(promptText)
275
+ const cfg = configRef.current
276
+ if (!t || t.kind !== "@" || !/^\d{2,}$/.test(t.token) || !cfg?.sources.ado) return
277
+ const id = Number(t.token)
278
+ const timer = setTimeout(() => void lookupWorkItem(cfg, id).then((item) => setWorkItem(item)), 300)
279
+ return () => clearTimeout(timer)
280
+ }, [promptText])
281
+
200
282
  useEffect(() => {
201
283
  if (status !== "running") return
202
284
  const timer = setInterval(() => setTick((n) => n + 1), 1000)
@@ -389,6 +471,93 @@ export function App({ options }: { options: TuiOptions }) {
389
471
  }
390
472
  }
391
473
 
474
+ /** Everything the palette offers now, in its order. */
475
+ const paletteAll = (): PaletteItem[] =>
476
+ paletteItems({
477
+ running: abortRef.current !== null,
478
+ headed: headedRef.current,
479
+ detailsFull: detailRef.current === "full",
480
+ profiles: Object.keys(configRef.current?.profiles || {}),
481
+ active: configRef.current?.activeProfile || "",
482
+ narrow: widthRef.current < SPLIT_MIN,
483
+ view: narrowView,
484
+ })
485
+ const paletteMatches = (query: string) => searchPalette(query, paletteAll())
486
+
487
+ /** Puts text in the prompt to edit and send. */
488
+ const fillPrompt = (text: string) => {
489
+ promptTextRef.current = text
490
+ setPromptText(text)
491
+ setDraft(text)
492
+ setInputKey((k) => k + 1)
493
+ }
494
+
495
+ const setRefine = (next: RefineState | null) => {
496
+ refiningRef.current = next
497
+ setRefining(next)
498
+ }
499
+
500
+ /** Refines what is in the prompt; the user then decides what is sent. */
501
+ const startRefine = () => {
502
+ const text = promptTextRef.current.trim()
503
+ const cfg = configRef.current
504
+ if (!text) {
505
+ push("sys", "Type a request first, then press ctrl+r to refine it.", "chat")
506
+ return
507
+ }
508
+ const why = cfg ? brainUnavailable(cfg) : "Config is not loaded."
509
+ if (!cfg || why) {
510
+ push("err", `Refine needs ForgeQE's agent: ${why}`, "chat")
511
+ return
512
+ }
513
+ const controller = new AbortController()
514
+ refineAbort.current = controller
515
+ setRefine({ phase: "working", original: text })
516
+ void (options.refine || refine)({ config: cfg, cwd: sessionSetup(cfg, options.repo).cwd, text, recent: convoRef.current, signal: controller.signal })
517
+ .then((result) => {
518
+ if (controller.signal.aborted || refiningRef.current?.phase !== "working") return
519
+ setRefine({ phase: "done", original: text, result, choice: 0 })
520
+ })
521
+ .catch((err) => {
522
+ if (controller.signal.aborted) return
523
+ setRefine({ phase: "error", original: text, message: (err as Error).message })
524
+ fillPrompt(text)
525
+ })
526
+ .finally(() => {
527
+ if (refineAbort.current === controller) refineAbort.current = null
528
+ })
529
+ }
530
+ refineRef.current = startRefine
531
+
532
+ const chooseRefine = (id: RefineChoice) => {
533
+ const r = refiningRef.current
534
+ if (!r) return
535
+ setRefine(null)
536
+ if (r.phase !== "done") {
537
+ fillPrompt(r.original)
538
+ return
539
+ }
540
+ if (id === "use") submitRef.current(r.result.refined)
541
+ else fillPrompt(id === "edit" ? r.result.refined : r.original)
542
+ }
543
+
544
+ const takeNext = (step: NextStep) => {
545
+ setNext([])
546
+ if (step.run.kind === "insert") fillPrompt(step.run.text)
547
+ else (options.openUrl || openInBrowser)(`file://${path.resolve(step.run.path)}`)
548
+ }
549
+
550
+ const pickPalette = (item: PaletteItem) => {
551
+ paletteRef.current = null
552
+ setPalette(null)
553
+ const run = item.run
554
+ if (run.kind === "insert") fillPrompt(run.text)
555
+ else if (run.kind === "command") submitRef.current(run.text)
556
+ else if (run.id === "stop") openDialog("stop")
557
+ else if (run.id === "details" || run.id === "chat") setNarrowView(run.id)
558
+ else if (run.id === "refine") refineRef.current()
559
+ }
560
+
392
561
  const cycleProfile = () => {
393
562
  const cfg = configRef.current
394
563
  if (!cfg || abortRef.current) return
@@ -399,6 +568,51 @@ export function App({ options }: { options: TuiOptions }) {
399
568
  }
400
569
 
401
570
  useKeyboard((key) => {
571
+ const pal = paletteRef.current
572
+ if (pal) {
573
+ // Keys can arrive faster than renders: every change builds on the latest state, never on this render's copy.
574
+ const edit = (f: (p: { query: string; selected: number }) => { query: string; selected: number } | null) => {
575
+ setPalette((p) => {
576
+ const next = p ? f(p) : p
577
+ paletteRef.current = next
578
+ return next
579
+ })
580
+ }
581
+ if (key.name === "escape" || (key.name === "p" && key.ctrl)) edit(() => null)
582
+ else if (key.name === "up") edit((p) => ({ ...p, selected: Math.max(0, p.selected - 1) }))
583
+ else if (key.name === "down") edit((p) => ({ ...p, selected: Math.min(Math.max(0, paletteMatches(p.query).length - 1), p.selected + 1) }))
584
+ else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") {
585
+ const current = paletteRef.current!
586
+ const item = paletteMatches(current.query)[current.selected]
587
+ if (item) pickPalette(item)
588
+ else {
589
+ edit(() => null)
590
+ if (current.query.trim()) fillPrompt(current.query.trim())
591
+ }
592
+ } else if (key.name === "backspace") edit((p) => ({ query: p.query.slice(0, -1), selected: 0 }))
593
+ else if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence >= " ") {
594
+ const ch = key.sequence
595
+ edit((p) => ({ query: p.query + ch, selected: 0 }))
596
+ }
597
+ return
598
+ }
599
+ const r = refiningRef.current
600
+ if (r && !dialogRef.current && !(key.name === "c" && key.ctrl)) {
601
+ if (r.phase === "working") {
602
+ if (key.name === "escape") {
603
+ refineAbort.current?.abort()
604
+ setRefine(null)
605
+ fillPrompt(r.original)
606
+ }
607
+ } else if (r.phase === "error") {
608
+ if (key.name === "escape" || key.name === "return" || key.name === "enter") setRefine(null)
609
+ } else if (key.name === "left" || (key.name === "tab" && key.shift)) setRefine({ ...r, choice: (r.choice + REFINE_CHOICES.length - 1) % REFINE_CHOICES.length })
610
+ else if (key.name === "right" || key.name === "tab") setRefine({ ...r, choice: (r.choice + 1) % REFINE_CHOICES.length })
611
+ else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") chooseRefine(REFINE_CHOICES[r.choice]!.id)
612
+ else if (key.name === "e") chooseRefine("edit")
613
+ else if (key.name === "escape") chooseRefine("keep")
614
+ return
615
+ }
402
616
  const open = dialogRef.current
403
617
  if (key.name === "c" && key.ctrl) {
404
618
  if (open === "quit") quit()
@@ -426,8 +640,68 @@ export function App({ options }: { options: TuiOptions }) {
426
640
  else if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence >= " ") setSecret(secretRef.current + key.sequence)
427
641
  return
428
642
  }
643
+ // The panels: shift+tab moves focus through those on screen; a focused panel takes the arrows, enter, and esc.
644
+ const panes = width >= SPLIT_MIN || narrowView === "details"
645
+ const insp = inspRef.current
646
+ const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && width >= SPLIT_MIN)
647
+ if (panes && key.name === "tab" && key.shift && !entryRef.current) {
648
+ setNav((n) => ({ ...n, focus: nextFocus(n.focus, shown) }))
649
+ return
650
+ }
651
+ const at = navRef.current.focus
652
+ if (panes && at !== "chat" && shown(at)) {
653
+ const list = at === "plan" ? planLines(insp).length : at === "files" ? filesByUse(insp).length : 0
654
+ const move = (d: number) => setNav((n) => ({ ...n, [at]: Math.max(0, Math.min(list - 1, (n[at as "plan" | "files"] ?? 0) + d)) }))
655
+ const page = Math.max(3, Math.floor(height / 3))
656
+ if (key.name === "escape") return setNav((n) => ({ ...n, focus: width >= SPLIT_MIN ? "chat" : "activity" }))
657
+ if (at === "activity") {
658
+ const box = activityScroll.current
659
+ if (key.name === "up") return box?.scrollBy(-1)
660
+ if (key.name === "down") return box?.scrollBy(1)
661
+ if (key.name === "pageup") return box?.scrollBy(-page)
662
+ if (key.name === "pagedown") return box?.scrollBy(page)
663
+ const f = ACTIVITY_FILTERS.find((x) => x.key === key.name && !key.ctrl && !key.meta)
664
+ if (f) return setNav((n) => ({ ...n, filter: f.id }))
665
+ } else {
666
+ if (key.name === "up") return move(-1)
667
+ if (key.name === "down") return move(1)
668
+ if (key.name === "pageup") return move(-page)
669
+ if (key.name === "pagedown") return move(page)
670
+ if (key.name === "home") return move(-list)
671
+ if (key.name === "end") return move(list)
672
+ if (key.name === "return" || key.name === "enter" || key.name === "kpenter") {
673
+ if (at === "files") {
674
+ const f = filesByUse(insp)[Math.min(navRef.current.files, list - 1)]
675
+ if (f) openFile(f.path)
676
+ } else {
677
+ const l = planLines(insp)[Math.min(navRef.current.plan, list - 1)]
678
+ if (l) push("sys", `Plan ${l.mark} ${l.text}`, "chat")
679
+ }
680
+ return
681
+ }
682
+ }
683
+ // Anything typed goes back to the chat, where the prompt is.
684
+ if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && width >= SPLIT_MIN) return setNav((n) => ({ ...n, focus: "chat" }))
685
+ if (!key.ctrl && !key.meta) return
686
+ }
687
+ if (suggRef.current.list.length && suggRef.current.trigger && !entryRef.current) {
688
+ const n = suggRef.current.list.length
689
+ if (key.name === "up") return setSugg((x) => ({ ...x, selected: (x.selected + n - 1) % n }))
690
+ if (key.name === "down") return setSugg((x) => ({ ...x, selected: (x.selected + 1) % n }))
691
+ if (key.name === "tab" && !key.shift) return acceptSuggestion(Math.min(sugg.selected, n - 1))
692
+ if (key.name === "escape") return setSugg({ selected: 0, dismissed: `${suggRef.current.trigger.kind}${suggRef.current.trigger.token}` })
693
+ }
429
694
  if (key.name === "tab" && !key.shift) cycleProfile()
430
- if (key.name === "p" && key.ctrl) push("sys", HELP, "chat")
695
+ if (key.name === "p" && key.ctrl) {
696
+ paletteRef.current = { query: "", selected: 0 }
697
+ setPalette(paletteRef.current)
698
+ }
699
+ if (key.meta && /^[1-3]$/.test(key.name || "") && showNext) {
700
+ const step = next[Number(key.name) - 1]
701
+ if (step) takeNext(step)
702
+ return
703
+ }
704
+ if (key.name === "r" && key.ctrl && !entryRef.current) startRefine()
431
705
  if (key.name === "o" && key.ctrl && width < SPLIT_MIN) setNarrowView((v) => (v === "chat" ? "details" : "chat"))
432
706
  if (key.name === "escape" && abortRef.current) openDialog("stop")
433
707
  })
@@ -490,7 +764,9 @@ export function App({ options }: { options: TuiOptions }) {
490
764
  push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
491
765
  // The action itself, without the result after a colon or the locator in brackets: "click "Search"".
492
766
  const action = event.text.split(/: | {2}\[/)[0]!.trim()
493
- stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(event.text, 80), clip(action, 60))
767
+ // The step is named by its action, so the failure detail is what follows it: "element not found".
768
+ const why = event.text.slice(action.length).replace(/^[\s:\[\]]+/, "").trim() || event.text
769
+ stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(why, 80), clip(action, 60))
494
770
  bump()
495
771
  }
496
772
  return
@@ -696,6 +972,7 @@ export function App({ options }: { options: TuiOptions }) {
696
972
  }
697
973
 
698
974
  const finish = (finalStatus: string) => {
975
+ setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on }))
699
976
  progressDone(inspRef.current, finalStatus === "stopped")
700
977
  bump()
701
978
  setStatus(finalStatus)
@@ -775,7 +1052,8 @@ export function App({ options }: { options: TuiOptions }) {
775
1052
  } finally {
776
1053
  pushProduced()
777
1054
  // What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
778
- const spent = requestLine(meterStart)
1055
+ // The split view's session and tokens panels show this; only the one-pane view needs it in the chat.
1056
+ const spent = widthRef.current < SPLIT_MIN ? requestLine(meterStart) : ""
779
1057
  if (spent) push("sys", spent, "chat")
780
1058
  finish(finalStatus)
781
1059
  }
@@ -827,7 +1105,22 @@ export function App({ options }: { options: TuiOptions }) {
827
1105
  // eslint-disable-next-line react-hooks/exhaustive-deps
828
1106
  }, [])
829
1107
 
1108
+ const acceptSuggestion = (i: number) => {
1109
+ const { list, trigger } = suggRef.current
1110
+ const pick = list[i]
1111
+ if (!pick || !trigger) return
1112
+ setSugg({ selected: 0 })
1113
+ fillPrompt(complete(promptTextRef.current, trigger, pick.insert))
1114
+ }
1115
+
830
1116
  const onSubmit = (value: string) => {
1117
+ // Enter with suggestions open takes the selected one, as tab does; a second enter sends. A command typed in full
1118
+ // ("/help") is sent at once.
1119
+ const full = suggRef.current.list.some((x) => x.kind === "command" && x.insert === value.trim())
1120
+ if (suggRef.current.list.length && suggRef.current.trigger && !full) {
1121
+ acceptSuggestion(Math.min(sugg.selected, suggRef.current.list.length - 1))
1122
+ return
1123
+ }
831
1124
  // What a command answers (/help, /skills, "busy") is for the chat, even while a request runs.
832
1125
  answeringRef.current = true
833
1126
  try {
@@ -838,6 +1131,10 @@ export function App({ options }: { options: TuiOptions }) {
838
1131
  }
839
1132
  const submit = (value: string) => {
840
1133
  const text = value.replace(/\t/g, "").trim()
1134
+ promptTextRef.current = ""
1135
+ setPromptText("")
1136
+ setDraft("")
1137
+ setNext([])
841
1138
  setInputKey((k) => k + 1)
842
1139
  const pending = entryRef.current
843
1140
  if (pending) {
@@ -962,28 +1259,51 @@ export function App({ options }: { options: TuiOptions }) {
962
1259
  : ""
963
1260
  const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
964
1261
  const footerRight = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
1262
+ // Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
1263
+ const suggTrigger = triggerAt(promptText)
1264
+ const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
1265
+ const suggList: Suggestion[] =
1266
+ !suggTrigger || sugg.dismissed === suggKey || palette || refining || entry || dialog
1267
+ ? []
1268
+ : suggTrigger.kind === "/"
1269
+ ? commandSuggestions(paletteAll(), suggTrigger.token).slice(0, 8)
1270
+ : configRef.current
1271
+ ? referenceSuggestions(suggTrigger.token, {
1272
+ runs: recentRuns(configRef.current.export.outDir),
1273
+ files: repoFiles(process.cwd()),
1274
+ item: workItem && String(workItem.id) === suggTrigger.token ? workItem : null,
1275
+ cwd: process.cwd(),
1276
+ })
1277
+ : []
1278
+ suggRef.current = { list: suggList, trigger: suggTrigger }
1279
+ const showNext = next.length > 0 && !running && !promptText.trim() && !palette && !refining && !dialog && !entry
965
1280
  const home = lines.length === 0 && !running
1281
+ const paletteList = palette ? paletteMatches(palette.query) : []
1282
+ const paletteOverlay = palette ? (
1283
+ <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 })} />
1284
+ ) : null
966
1285
 
967
1286
  const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
968
1287
  const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
969
1288
  // The split view's right pane shows tokens, time, and the last result, so the hints under the prompt leave them out.
970
1289
  const wide = width >= SPLIT_MIN
971
1290
  const viewHint: Hint[] = wide ? [] : [{ key: "ctrl+o", label: "details", onPress: () => setNarrowView("details") }]
972
- // While running, the row also holds the spinner and esc stop: below 95 columns the view hint does not fit beside them.
973
- const runningViewHint: Hint[] = width >= 95 ? viewHint : []
1291
+ // Each hint's `drop` is when it gives way on a narrow row: the last run and profiles (also in the palette) go first.
974
1292
  const idleHints: Hint[] = [
975
- ...copiedHint,
976
- ...(lastStats && !wide ? [{ key: `last run ${lastStats}`, label: "" }] : []),
977
- ...viewHint,
978
- { key: "tab", label: "profiles" },
1293
+ ...copiedHint.map((h) => ({ ...h, drop: 0 })),
1294
+ ...(lastStats && !wide ? [{ key: `last run ${lastStats}`, label: "", drop: 5 }] : []),
1295
+ ...viewHint.map((h) => ({ ...h, drop: 3 })),
1296
+ { key: "ctrl+r", label: "refine", onPress: () => refineRef.current(), drop: 1 },
1297
+ ...(wide ? [{ key: "shift+tab", label: "panels", onPress: () => setNav((n) => ({ ...n, focus: planLines(inspRef.current).length ? "plan" : "activity" })), drop: 4 }] : [{ key: "tab", label: "profiles", drop: 4 }]),
979
1298
  { key: "ctrl+p", label: "commands" },
980
1299
  closeHint,
981
1300
  ]
982
1301
  const runningHints: Hint[] = [
983
- ...copiedHint,
984
- ...(wide ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" }, ...runningViewHint]),
985
- // Beside the 40% pane's spinner and esc stop, only close fits from 120 columns.
986
- ...(wide ? [] : [{ key: "ctrl+p", label: "commands" }]),
1302
+ // A note ("opened report.html") is the last to give way: it answers what was just pressed.
1303
+ ...copiedHint.map((h) => ({ ...h, drop: 0 })),
1304
+ // The split view's panels show tokens and time; the one-pane view shows them here while there is room.
1305
+ ...(wide ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "", drop: 3 }, ...viewHint.map((h) => ({ ...h, drop: 2 }))]),
1306
+ { key: "ctrl+p", label: "commands", drop: 1 },
987
1307
  closeHint,
988
1308
  ]
989
1309
  const runningLeft = (
@@ -997,6 +1317,16 @@ export function App({ options }: { options: TuiOptions }) {
997
1317
  )
998
1318
 
999
1319
  const renderControls = (promptWidth: number, placeholder: string, rows: [number, number]) => {
1320
+ if (refining && !dialog) {
1321
+ return (
1322
+ <RefinePanel
1323
+ state={refining}
1324
+ width={promptWidth}
1325
+ onChoose={chooseRefine}
1326
+ onHover={(i) => refining.phase === "done" && setRefine({ ...refining, choice: i })}
1327
+ />
1328
+ )
1329
+ }
1000
1330
  if (dialog) {
1001
1331
  const spec = dialogSpec(dialog, running, outDirLabel(), thinking, waitRef.current?.text, askRef.current?.q)
1002
1332
  const wide = spec.options.reduce((n, o) => n + o.label.length + 3, 0) > promptWidth - 36
@@ -1016,6 +1346,45 @@ export function App({ options }: { options: TuiOptions }) {
1016
1346
  }
1017
1347
  return (
1018
1348
  <>
1349
+ {showNext && !suggList.length ? (
1350
+ // One row when the chips fit beside "Next", else one per line: a ragged wrap reads as broken.
1351
+ next.reduce((n, st) => n + st.label.length + 9, 6) <= promptWidth ? (
1352
+ <box style={{ flexDirection: "row", width: promptWidth, flexShrink: 0, marginBottom: 1 }}>
1353
+ <text fg={theme.muted}>{"Next "}</text>
1354
+ {next.map((step, i) => (
1355
+ <box key={step.label} onMouseDown={() => takeNext(step)} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: theme.button }}>
1356
+ <text>
1357
+ <span fg={theme.text}>{step.label}</span>
1358
+ <span fg={theme.faint}>{` alt+${i + 1}`}</span>
1359
+ </text>
1360
+ </box>
1361
+ ))}
1362
+ </box>
1363
+ ) : (
1364
+ <box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0, marginBottom: 1 }}>
1365
+ {next.map((step, i) => (
1366
+ <box key={step.label} onMouseDown={() => takeNext(step)} style={{ flexDirection: "row", justifyContent: "space-between", flexShrink: 0 }}>
1367
+ <text>
1368
+ <span fg={theme.muted}>{i ? " " : "Next "}</span>
1369
+ <span fg={theme.accent}>{"▸ "}</span>
1370
+ <span fg={theme.text}>{step.label}</span>
1371
+ </text>
1372
+ <text fg={theme.faint}>{`alt+${i + 1}`}</text>
1373
+ </box>
1374
+ ))}
1375
+ </box>
1376
+ )
1377
+ ) : null}
1378
+ {suggList.length && suggTrigger ? (
1379
+ <SuggestList
1380
+ title={suggTrigger.kind === "/" ? "Commands" : "References"}
1381
+ width={promptWidth}
1382
+ 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 }))}
1383
+ selected={Math.min(sugg.selected, suggList.length - 1)}
1384
+ onPick={acceptSuggestion}
1385
+ onHover={(i) => setSugg((x) => ({ ...x, selected: i }))}
1386
+ />
1387
+ ) : null}
1019
1388
  <Prompt
1020
1389
  width={promptWidth}
1021
1390
  inputKey={inputKey}
@@ -1030,21 +1399,50 @@ export function App({ options }: { options: TuiOptions }) {
1030
1399
  right={right}
1031
1400
  rightColor={rightColor}
1032
1401
  compact={width >= SPLIT_MIN && !home}
1402
+ focused={!palette && !refining && (nav.focus === "chat" || (width < SPLIT_MIN && narrowView === "chat"))}
1403
+ initialValue={draft}
1404
+ onText={(t) => {
1405
+ promptTextRef.current = t
1406
+ setPromptText(t)
1407
+ }}
1033
1408
  />
1034
- <Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
1409
+ <Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} reserve={running ? 20 : 0} />
1035
1410
  </>
1036
1411
  )
1037
1412
  }
1038
1413
 
1039
1414
  if (home) {
1040
1415
  const promptWidth = Math.max(40, Math.min(width - 8, 120))
1416
+ // What is set up, while something is not: each open item says how, or runs the command that does it.
1417
+ const cfgHome = configRef.current
1418
+ const setupKey = cfgHome ? `${cfgHome.configPath}|${cfgHome.activeProfile}` : ""
1419
+ if (cfgHome && setupRef.current.key !== setupKey) setupRef.current = { key: setupKey, items: setupChecklist(cfgHome) }
1420
+ const setup = setupRef.current.items
1421
+ const open = setup.filter((i) => !i.done)
1422
+ const pickSetup = (i: SetupItem) => (i.command ? submitRef.current(i.command) : i.fix && push("sys", i.fix, "chat"))
1041
1423
  return (
1042
1424
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1043
1425
  <box style={{ flexDirection: "column", flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
1044
1426
  <Logo />
1045
1427
  <box style={{ height: 2 }} />
1046
1428
  {renderControls(promptWidth, `Describe a goal… "${example}"`, [4, Math.max(4, Math.min(12, height - 22))])}
1047
- <box style={{ height: 3 }} />
1429
+ <box style={{ height: open.length ? 2 : 3 }} />
1430
+ {open.length ? (
1431
+ <box style={{ flexDirection: "column", alignItems: "center", flexShrink: 0, marginBottom: 1 }}>
1432
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
1433
+ <text fg={theme.muted}>{"Setup "}</text>
1434
+ {(setup.reduce((n, i) => n + i.label.length + 5, 7) <= width - 4 ? setup : open).map((i) => (
1435
+ <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 }}>
1436
+ <text>
1437
+ <span fg={i.done ? theme.ok : theme.warn}>{i.done ? "✓ " : "○ "}</span>
1438
+ <span fg={i.done ? theme.muted : theme.text}>{i.label}</span>
1439
+ </text>
1440
+ </box>
1441
+ ))}
1442
+ </box>
1443
+ <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>
1444
+ </box>
1445
+ ) : null}
1048
1446
  <text>
1049
1447
  <span fg={theme.warn}>{"● "}</span>
1050
1448
  <span fg={theme.text}>{"Tip "}</span>
@@ -1052,6 +1450,7 @@ export function App({ options }: { options: TuiOptions }) {
1052
1450
  </text>
1053
1451
  </box>
1054
1452
  <Footer left={shortCwd()} right={footerRight} width={width} />
1453
+ {paletteOverlay}
1055
1454
  </box>
1056
1455
  )
1057
1456
  }
@@ -1071,17 +1470,11 @@ export function App({ options }: { options: TuiOptions }) {
1071
1470
  }
1072
1471
  const llm = cfgNow?.llm
1073
1472
  const sessionInfo: SessionInfo = {
1074
- profile,
1075
- model: model || (cfgNow ? modelLabel(cfgNow) : ""),
1076
- auth: !llm ? "–" : llm.auth === "entra" ? "Entra ID, no key" : llm.apiKeyEnv ? `API key in ${llm.apiKeyEnv}` : llm.provider === "mock" ? "offline demo" : llm.provider,
1473
+ model: cfgNow ? `${llm?.provider === "mock" ? "offline demo" : llm?.deployment || llm?.model || "none"}${profile ? ` (${profile})` : ""}` : "…",
1077
1474
  cwd: shortCwd(),
1078
- tests: testsRef.current.on ? "written to this folder" : requests.current ? `not written (${testsRef.current.why || "off"})` : "set on the first request",
1079
- browser: `${headed ? "visible" : "headless"} · up to ${maxSteps} steps${options.url ? ` · ${hostOf(options.url)}` : ""}`,
1080
1475
  ...(cfgNow?.domain ? { domain: `${cfgNow.domain.industry}${cfgNow.domain.regulations.length ? ` (${cfgNow.domain.regulations.join(", ")})` : ""}` } : {}),
1081
- ...(llm?.priceWarning && llm.price ? { price: `${llm.price.input}/${llm.price.output} ${llm.price.currency} per M in/out from llms.json, not the list price` } : {}),
1082
1476
  started: sessionStarted.current,
1083
1477
  requests: requests.current,
1084
- total: spent.roles.length ? { usage: spent.total, cost: spent.cost } : undefined,
1085
1478
  }
1086
1479
  const contextPanel: ContextInfo = {
1087
1480
  tokens: insp.context,
@@ -1096,7 +1489,9 @@ export function App({ options }: { options: TuiOptions }) {
1096
1489
  ? coding
1097
1490
  ? "The coding agent is writing a test · esc to stop"
1098
1491
  : `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
1099
- : "Ask ForgeQE: a goal, test cases, a test, a pipeline…"
1492
+ : width >= SPLIT_MIN
1493
+ ? "Ask ForgeQE · / commands · @ references"
1494
+ : "Ask ForgeQE: a goal, test cases, a test, a pipeline… (/ commands, @ references)"
1100
1495
  const chatLines = lines.filter((l) => l.pane !== "activity")
1101
1496
  const activityLines = lines.filter((l) => l.pane === "activity")
1102
1497
  const stop = () => openDialog("stop")
@@ -1110,16 +1505,27 @@ export function App({ options }: { options: TuiOptions }) {
1110
1505
  const plan = planRows(insp, Math.max(3, Math.min(8, Math.floor(rowsAvailable / 5))))
1111
1506
  const activityRows = Math.max(5, rowsAvailable - 2 - plan - bottom)
1112
1507
  const third = Math.floor(paneWidth / 3)
1508
+ const focusOn = (f: Focus) => () => setNav((n) => ({ ...n, focus: f }))
1509
+ const sel = (f: "plan" | "files") => (nav.focus === f ? Math.min(nav[f], Math.max(0, (f === "plan" ? planLines(insp) : filesByUse(insp)).length - 1)) : undefined)
1113
1510
  return (
1114
1511
  <box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
1115
- <StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} />
1512
+ <StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress} />
1116
1513
  <box style={{ height: 1, flexShrink: 0 }} />
1117
- {plan ? <PlanPanel s={insp} width={paneWidth} height={plan} /> : null}
1118
- <ActivityPanel lines={activityLines} scrollRef={activityScroll} width={paneWidth} height={activityRows} />
1514
+ {plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel("plan")} onFocus={focusOn("plan")} /> : null}
1515
+ <ActivityPanel
1516
+ lines={activityLines}
1517
+ scrollRef={activityScroll}
1518
+ width={paneWidth}
1519
+ height={activityRows}
1520
+ filter={nav.filter}
1521
+ focused={nav.focus === "activity"}
1522
+ onFocus={focusOn("activity")}
1523
+ onFilter={(filter) => setNav((n) => ({ ...n, filter, focus: "activity" }))}
1524
+ />
1119
1525
  <box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
1120
- <FilesPanel s={insp} width={third} height={bottom} />
1526
+ <FilesPanel s={insp} width={third} height={bottom} selected={sel("files")} onFocus={focusOn("files")} onOpen={openFile} />
1121
1527
  <ContextPanel c={contextPanel} width={third} height={bottom} />
1122
- <TokensPanel roles={spent.roles} total={{ usage: spent.total, cost: spent.cost }} live={running ? tokens : 0} width={paneWidth - 2 * third} height={bottom} />
1528
+ <TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
1123
1529
  </box>
1124
1530
  </box>
1125
1531
  )
@@ -1146,7 +1552,7 @@ export function App({ options }: { options: TuiOptions }) {
1146
1552
  return (
1147
1553
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1148
1554
  <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1149
- <box style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1555
+ <box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1150
1556
  <SessionPanel info={sessionInfo} width={leftWidth - 3} />
1151
1557
  {transcript(chatLines, {})}
1152
1558
  <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
@@ -1156,6 +1562,7 @@ export function App({ options }: { options: TuiOptions }) {
1156
1562
  </box>
1157
1563
  </box>
1158
1564
  <Footer left={shortCwd()} right={footerRight} width={width} />
1565
+ {paletteOverlay}
1159
1566
  </box>
1160
1567
  )
1161
1568
  }
@@ -1170,10 +1577,11 @@ export function App({ options }: { options: TuiOptions }) {
1170
1577
  // Stop, a question, or "your turn": the same panel the chat view shows in place of the prompt.
1171
1578
  controls(promptWidth)
1172
1579
  ) : (
1173
- <Hints width={promptWidth} items={[{ key: "ctrl+o", label: "back to the chat", onPress: () => setNarrowView("chat") }, closeHint]} />
1580
+ <Hints width={promptWidth} items={[{ key: "ctrl+o", label: "back to the chat", onPress: () => setNarrowView("chat") }, { key: "shift+tab", label: "panels", drop: 1 }, closeHint]} />
1174
1581
  )}
1175
1582
  </box>
1176
1583
  <Footer left={shortCwd()} right={footerRight} width={width} />
1584
+ {paletteOverlay}
1177
1585
  </box>
1178
1586
  )
1179
1587
  }
@@ -1182,6 +1590,7 @@ export function App({ options }: { options: TuiOptions }) {
1182
1590
  {transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 })}
1183
1591
  <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>{controls(promptWidth)}</box>
1184
1592
  <Footer left={shortCwd()} right={footerRight} width={width} />
1593
+ {paletteOverlay}
1185
1594
  </box>
1186
1595
  )
1187
1596
  }