@capagents/forgeqe 1.0.8 → 1.1.0

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