@capagents/forgeqe 1.1.5 → 1.1.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
@@ -30,9 +30,9 @@ import { initWorkspace } from "../init.ts"
30
30
  import { ShortcutsPanel } from "./shortcuts.tsx"
31
31
  import { ago, getStarted, projectLine, setupSentence, type Fact, type GetStarted, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
32
32
  import { TIPS, formatDuration, formatTokens, packageVersion, shortCwd, theme } from "./theme.ts"
33
- import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
33
+ import { emptyProduced, Line, Produced, producedCard, settleCall, sinceRequest, TestsSetting, TranscriptLine, trimLines } from "./transcript.tsx"
34
34
  import { Dialog, dialogSpec } from "./dialogs.ts"
35
- import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
35
+ import { activityFromLog, clip, modelLabel } from "./text.ts"
36
36
  import { featuresText } from "../capabilities/render.ts"
37
37
  import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
38
38
  import { commandSuggestions, complete, lookupWorkItem, recentRuns, referenceSuggestions, repoFiles, triggerAt, type Suggestion, type WorkItem } from "./suggest.ts"
@@ -49,18 +49,19 @@ import {
49
49
  ActivityPanel,
50
50
  ContextPanel,
51
51
  FilesPanel,
52
+ InfoStrip,
53
+ nowNext,
52
54
  nextFocus,
53
55
  planLines,
54
56
  PlanPanel,
55
57
  planProgress,
56
58
  planRows,
57
- SessionPanel,
59
+ STATUS_ROWS,
58
60
  StatusBar,
59
61
  TokensPanel,
60
62
  type ActivityFilter,
61
63
  type ContextInfo,
62
64
  type Focus,
63
- type SessionInfo,
64
65
  } from "./inspector.tsx"
65
66
  import {
66
67
  addSkill,
@@ -123,6 +124,8 @@ export function App({ options }: { options: TuiOptions }) {
123
124
  widthRef.current = width
124
125
  const scrollRef = useRef<ScrollBoxRenderable>(null)
125
126
  const activityScroll = useRef<ScrollBoxRenderable>(null)
127
+ /** Refine's body, which scrolls when the refined request is longer than the screen has room for. */
128
+ const refineScroll = useRef<ScrollBoxRenderable>(null)
126
129
  const submitRef = useRef<(value: string) => void>(() => {})
127
130
  /** Refines what is in the prompt (phase two of the palette work wires it). */
128
131
  const refineRef = useRef<() => void>(() => {})
@@ -239,17 +242,33 @@ export function App({ options }: { options: TuiOptions }) {
239
242
  thinkingRef.current = thinking
240
243
 
241
244
  /** Lines go to the chat, or while a request runs and they are not results, to the activity pane; `pane` overrides that. */
242
- const push = (kind: Line["kind"], text: string, pane?: Pane) => {
245
+ const push = (kind: Line["kind"], text: string, pane?: Pane, quiet?: boolean) => {
243
246
  const parts = kind === "user" || kind === "reply" ? [text] : text.split("\n")
244
247
  const where = pane || (answeringRef.current ? "chat" : paneFor(kind, abortRef.current !== null))
245
248
  setLines((prev) => {
246
249
  const next = [...prev]
247
- for (const part of parts) next.push({ id: idRef.current++, kind, text: part, pane: where })
248
- return next.slice(-600)
250
+ for (const part of parts) next.push({ id: idRef.current++, kind, text: part, pane: where, ...(quiet ? { quiet } : {}) })
251
+ return trimLines(next)
249
252
  })
250
253
  }
251
- const pushLine = (line: Omit<Line, "id">) =>
252
- setLines((prev) => [...prev, { ...line, pane: line.pane || paneFor(line.kind, abortRef.current !== null), id: idRef.current++ }].slice(-600))
254
+ // The pane is decided when the event arrives: the updater runs later, when the request may already have finished.
255
+ const pushLine = (line: Omit<Line, "id">) => {
256
+ const next = { ...line, pane: line.pane || paneFor(line.kind, abortRef.current !== null), id: idRef.current++ }
257
+ setLines((prev) => trimLines([...prev, next]))
258
+ }
259
+ /**
260
+ * What the agents do always reaches the Activity panel; `shown` says whether the conversation shows it too at this
261
+ * detail level (/details). Hidden lines were dropped before, so the panel missed most of the agents' work.
262
+ */
263
+ const record = (kind: Line["kind"], text: string, shown: boolean) => push(kind, text, shown ? undefined : "activity", !shown)
264
+ /** A tool call started: one running line in Activity, which its result replaces. */
265
+ const startCall = (id: string, text: string) => pushLine({ kind: "running", text, pane: "activity", quiet: true, call: id })
266
+ /** A tool call finished: its running line becomes the result, shown in the conversation too when `shown`. */
267
+ const endCall = (id: string | undefined, kind: Line["kind"], text: string, shown: boolean) => {
268
+ const line = { kind, text, pane: shown ? paneFor(kind, abortRef.current !== null) : ("activity" as const), quiet: !shown }
269
+ const n = idRef.current++
270
+ setLines((prev) => trimLines(settleCall(prev, id, line, n)))
271
+ }
253
272
  /** What an agent's event tells the right pane: a file it used, a skill it loaded, its plan, how full its context is. */
254
273
  const inspect = (e: CodeEvent) => {
255
274
  const s = inspRef.current
@@ -261,8 +280,14 @@ export function App({ options }: { options: TuiOptions }) {
261
280
  else return
262
281
  bump()
263
282
  }
283
+ /** What the agent is on between calls: the plan's task in progress, rather than a bare "working". */
284
+ const nowWorking = () => {
285
+ const task = inspRef.current.plan.find((p) => p.status === "in_progress")
286
+ return task ? `Working on: ${clip(task.text, 70)}` : "The agent is deciding what to do next…"
287
+ }
264
288
  /** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
265
- const pushTool = (text: string, ok: boolean) => push(!ok ? "err" : /^(Writing|Editing) /.test(text) ? "file" : "tool", text)
289
+ const toolKind = (text: string, ok: boolean): Line["kind"] => (!ok ? "err" : /^(Writing|Editing) /.test(text) ? "file" : "tool")
290
+ const pushTool = (text: string, ok: boolean, id?: string, shown = true) => endCall(id, toolKind(text, ok), text, shown)
266
291
  /** A short note in the hint row that clears itself: "opened report.html". */
267
292
  const flashTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
268
293
  const flash = (text: string) => {
@@ -419,7 +444,7 @@ export function App({ options }: { options: TuiOptions }) {
419
444
  reloadAfterAuth()
420
445
  return
421
446
  }
422
- const controller = begin("Signing in…")
447
+ const controller = begin("Signing in…", system ? `Sign in to ${system}` : "Sign in")
423
448
  controller.signal.addEventListener("abort", () => settleEntry(null), { once: true })
424
449
  let finalStatus = "fail"
425
450
  try {
@@ -646,6 +671,9 @@ export function App({ options }: { options: TuiOptions }) {
646
671
  }
647
672
  } else if (r.phase === "error") {
648
673
  if (key.name === "escape" || key.name === "return" || key.name === "enter") setRefine(null)
674
+ } else if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") {
675
+ const page = Math.max(3, Math.floor(height / 3))
676
+ refineScroll.current?.scrollBy(key.name === "up" ? -1 : key.name === "down" ? 1 : key.name === "pageup" ? -page : page)
649
677
  } else if (key.name === "left" || (key.name === "tab" && key.shift)) setRefine({ ...r, choice: (r.choice + REFINE_CHOICES.length - 1) % REFINE_CHOICES.length })
650
678
  else if (key.name === "right" || key.name === "tab") setRefine({ ...r, choice: (r.choice + 1) % REFINE_CHOICES.length })
651
679
  else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") chooseRefine(REFINE_CHOICES[r.choice]!.id)
@@ -684,7 +712,9 @@ export function App({ options }: { options: TuiOptions }) {
684
712
  const panes = (width >= SPLIT_MIN && !chatWide) || (width < SPLIT_MIN && narrowView === "details")
685
713
  const insp = inspRef.current
686
714
  const panelsOpen = width >= SPLIT_MIN && !chatWide
687
- const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && panelsOpen)
715
+ // Files takes the keys only where it is a panel: a narrow column shows it as a line of the info strip.
716
+ const filesPanel = (panelsOpen ? Math.floor(width / 2) - 3 : width - 4) >= 80
717
+ const shown = (f: Focus) => f === "activity" || (f === "files" && filesPanel) || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && panelsOpen)
688
718
  if (panes && key.name === "tab" && key.shift && !entryRef.current) {
689
719
  // Consumed here: the prompt can take focus within this key press and must not also act on it.
690
720
  key.preventDefault()
@@ -729,6 +759,13 @@ export function App({ options }: { options: TuiOptions }) {
729
759
  if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && panelsOpen) return setNav((n) => ({ ...n, focus: "chat" }))
730
760
  if (!key.ctrl && !key.meta) return
731
761
  }
762
+ // pgup and pgdn scroll the conversation from the prompt, as the wheel does; pgdn at the end follows new lines again.
763
+ if ((key.name === "pageup" || key.name === "pagedown") && !shortcutsRef.current && !entryRef.current?.secret) {
764
+ key.preventDefault()
765
+ const page = Math.max(3, height - 10)
766
+ scrollRef.current?.scrollBy(key.name === "pageup" ? -page : page)
767
+ return
768
+ }
732
769
  // ? on an empty prompt: every key in one panel; esc or ? closes it.
733
770
  if (shortcutsRef.current) {
734
771
  if (key.name === "escape" || key.sequence === "?") setShortcuts(false)
@@ -809,11 +846,12 @@ export function App({ options }: { options: TuiOptions }) {
809
846
  else if (!aborted()) setActivity(`Replaying step ${event.step} from memory…`)
810
847
  }
811
848
  if (event.type === "memory") {
812
- if (shows(event, detailRef.current)) push("memory", event.text)
849
+ record("memory", event.text, shows(event, detailRef.current))
813
850
  markRef.current = Date.now()
814
851
  }
815
852
  if (event.type === "usage") useTokens(`run-${runIndex.current}`, event.usage.input + event.usage.output)
816
853
  if (event.type === "log") {
854
+ // Engine info logs stay in the session log: they would bury the steps in the panel.
817
855
  if (shows(event, detailRef.current)) push(event.level === "error" ? "err" : "sys", event.text)
818
856
  if (!aborted()) setActivity(activityFromLog(event.text))
819
857
  }
@@ -909,6 +947,7 @@ export function App({ options }: { options: TuiOptions }) {
909
947
  if (ev.type === "usage") useTokens(`case-${s.index}`, ev.usage.input + ev.usage.output)
910
948
  else if (!shows(ev, detailRef.current)) {
911
949
  if (ev.type === "log" && !aborted()) setActivity(`${p}${activityFromLog(ev.text)}`)
950
+ else if (ev.type === "memory") record("memory", p + ev.text, false)
912
951
  } else if (ev.type === "log") push(ev.level === "error" ? "err" : "sys", p + ev.text)
913
952
  else if (ev.type === "memory") push("memory", p + ev.text)
914
953
  else if (ev.type === "thought") push("thought", p + stepIntent(ev.step, ev.text, detailRef.current))
@@ -928,25 +967,32 @@ export function App({ options }: { options: TuiOptions }) {
928
967
  case "info":
929
968
  push(e.ok === undefined ? "code" : e.ok ? "ok" : "err", e.text)
930
969
  return
970
+ case "call-start":
971
+ startCall(e.id, e.text)
972
+ if (!aborted()) setActivity(`Agent: ${clip(e.text, 60)}…`)
973
+ return
931
974
  case "call":
932
- pushTool(e.text, e.ok)
933
- if (!aborted()) setActivity(`Agent: ${clip(e.text, 60)}`)
975
+ pushTool(e.text, e.ok, e.id, !e.quiet)
976
+ if (!aborted()) setActivity(nowWorking())
934
977
  return
935
978
  case "agent": {
936
979
  const ev = e.event
937
980
  inspect(ev)
938
981
  if (ev.kind === "plan") return
939
982
  if (ev.kind === "usage") useTokens(`agent-tool-${runIndex.current}`, ev.usage.input + ev.usage.output)
940
- // Hidden lines (reads, searches, the agent's narration) still show on the activity line below.
941
- else if (shows(ev, detailRef.current)) {
942
- if (ev.kind === "info") push("code", ev.text)
943
- else if (ev.kind === "say") push("thought", ev.text)
944
- else if (ev.kind === "tool") pushTool(ev.text, ev.ok)
945
- else if (ev.kind === "test") push(ev.ok ? "ok" : "err", ev.text)
983
+ else {
984
+ // Every line reaches Activity; the conversation keeps the ones this detail level shows.
985
+ const shown = shows(ev, detailRef.current)
986
+ if (ev.kind === "start") startCall(ev.id, ev.text)
987
+ else if (ev.kind === "info") record("code", ev.text, shown)
988
+ else if (ev.kind === "say") record("thought", ev.text, shown)
989
+ else if (ev.kind === "tool") pushTool(ev.text, ev.ok, ev.id, shown)
990
+ else if (ev.kind === "test") record(ev.ok ? "ok" : "err", ev.text, shown)
946
991
  }
947
992
  if (aborted()) return
948
993
  const who = codingRef.current ? "Coding agent" : "Agent"
949
- if (ev.kind === "tool") setActivity(`${who}: ${clip(ev.text, 60)}`)
994
+ if (ev.kind === "start") setActivity(`${who}: ${clip(ev.text, 60)}…`)
995
+ else if (ev.kind === "tool") setActivity(`${who}: ${clip(ev.text, 60)}`)
950
996
  else if (ev.kind === "info" && ev.text.startsWith("ForgeQE runs the test")) setActivity("ForgeQE is running the new test…")
951
997
  else if (ev.kind === "say") setActivity(`${who} is working…`)
952
998
  return
@@ -991,6 +1037,9 @@ export function App({ options }: { options: TuiOptions }) {
991
1037
  return
992
1038
  case "report":
993
1039
  producedRef.current.reports.push({ title: e.title, html: e.html })
1040
+ // What the report wrote is a file the request made: it is in Files with the rest.
1041
+ for (const f of [e.markdown, e.html]) useFile(inspRef.current, { path: path.relative(process.cwd(), f) || f, action: "write" })
1042
+ bump()
994
1043
  return
995
1044
  }
996
1045
  }
@@ -1003,17 +1052,16 @@ export function App({ options }: { options: TuiOptions }) {
1003
1052
  return
1004
1053
  }
1005
1054
  // ForgeQE's own agent: its tool calls (Azure DevOps and other MCP servers, commands) and what it says between them,
1006
- // one line each in brief; only its file reads and searches are left to /details.
1007
- if (e.kind === "tool" && showsAgentTool(e.text, e.ok, detailRef.current)) pushTool(e.text, e.ok)
1055
+ // one line each in brief in the conversation; its file reads and searches only in Activity, or with /details.
1056
+ const shown = shows(e, detailRef.current)
1057
+ if (e.kind === "start") startCall(e.id, e.text)
1058
+ else if (e.kind === "tool") pushTool(e.text, e.ok, e.id, showsAgentTool(e.text, e.ok, detailRef.current))
1008
1059
  else if (e.kind === "say") push("thought", briefText(e.text, detailRef.current, 200))
1009
- else if (shows(e, detailRef.current)) {
1010
- if (e.kind === "info") push("code", e.text)
1011
- if (e.kind === "tool") pushTool(e.text, e.ok)
1012
- if (e.kind === "test") push(e.ok ? "ok" : "err", e.text)
1013
- }
1060
+ else if (e.kind === "info") record("code", e.text, shown)
1061
+ else if (e.kind === "test") record(e.ok ? "ok" : "err", e.text, shown)
1014
1062
  if (abortRef.current?.signal.aborted) return
1015
- if (e.kind === "tool") setActivity(`Agent: ${clip(e.text, 60)}`)
1016
- else if (e.kind === "say") setActivity("The agent is working…")
1063
+ if (e.kind === "start") setActivity(`Agent: ${clip(e.text, 60)}…`)
1064
+ else if (e.kind === "tool" || e.kind === "say") setActivity(nowWorking())
1017
1065
  }
1018
1066
 
1019
1067
  const toolContext = (agent: boolean, signal?: AbortSignal) => ({
@@ -1033,10 +1081,12 @@ export function App({ options }: { options: TuiOptions }) {
1033
1081
  ask,
1034
1082
  })
1035
1083
 
1036
- const begin = (activityText: string) => {
1084
+ /** `request`: what was asked, which heads this request's lines in Activity so they are not read as the last one's. */
1085
+ const begin = (activityText: string, request: string) => {
1037
1086
  const controller = new AbortController()
1038
1087
  abortRef.current = controller
1039
1088
  started.current = Date.now()
1089
+ pushLine({ kind: "request", text: request.replace(/\s+/g, " ").trim(), pane: "activity", quiet: true, at: started.current })
1040
1090
  usedRef.current = new Map()
1041
1091
  producedRef.current = emptyProduced()
1042
1092
  newRequest(inspRef.current)
@@ -1050,6 +1100,16 @@ export function App({ options }: { options: TuiOptions }) {
1050
1100
  }
1051
1101
 
1052
1102
  const finish = (finalStatus: string, followUps: FollowUp[] = []) => {
1103
+ // A call the request ended inside of (stopped, or its result never came) is not left spinning in Activity; then the
1104
+ // request's closing line says how it ended, how long it took, and how many activities it had.
1105
+ const took = started.current != null ? formatDuration(Date.now() - started.current) : ""
1106
+ const ended = (["done", "fail", "stopped", "idle"] as const).find((x) => x === finalStatus) || "fail"
1107
+ const endId = idRef.current++
1108
+ setLines((prev) => {
1109
+ const settled = prev.some((l) => l.kind === "running") ? prev.map((l) => (l.kind === "running" ? { ...l, kind: "tool" as const, text: `${l.text} · ${finalStatus === "stopped" ? "stopped" : "no result"}` } : l)) : prev
1110
+ const n = sinceRequest(settled)
1111
+ return trimLines([...settled, { id: endId, kind: "requestEnd", text: `${took ? `in ${took} · ` : ""}${n} ${n === 1 ? "activity" : "activities"}`, pane: "activity", quiet: true, ended }])
1112
+ })
1053
1113
  setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on, followUps }))
1054
1114
  progressDone(inspRef.current, finalStatus === "stopped")
1055
1115
  bump()
@@ -1073,7 +1133,7 @@ export function App({ options }: { options: TuiOptions }) {
1073
1133
  directNoted.current = true
1074
1134
  push("sys", `ForgeQE's agent is off (${why}), so the text runs as a browser goal.${why.includes("agent runtime") ? ` ${INSTALL_HINT}` : " Press Tab for a live profile."} /features lists what ForgeQE can do.`)
1075
1135
  }
1076
- const controller = begin(cfg.llm.provider === "mock" ? "Running the offline demo…" : "Starting…")
1136
+ const controller = begin(cfg.llm.provider === "mock" ? "Running the offline demo…" : "Starting…", goal)
1077
1137
  testsRef.current = { on: false, why: why.includes("agent runtime") ? "the agent runtime Scribe needs is not installed" : "ForgeQE's agent is off in the offline profile" }
1078
1138
  let finalStatus = "fail"
1079
1139
  try {
@@ -1088,7 +1148,7 @@ export function App({ options }: { options: TuiOptions }) {
1088
1148
  }
1089
1149
 
1090
1150
  const askAgent = async (text: string, cfg: AppConfig) => {
1091
- const controller = begin("Thinking…")
1151
+ const controller = begin("Thinking…", text)
1092
1152
  const meterStart = meterSnapshot()
1093
1153
  setThinking(true)
1094
1154
  const setup = sessionSetup(cfg, options.repo)
@@ -1113,7 +1173,7 @@ export function App({ options }: { options: TuiOptions }) {
1113
1173
  const brain = brainRef.current
1114
1174
  const onAbort = () => void brain.abort()
1115
1175
  controller.signal.addEventListener("abort", onAbort, { once: true })
1116
- if (!controller.signal.aborted) setActivity("Thinking…")
1176
+ if (!controller.signal.aborted) setActivity("Reading your request and deciding the first step…")
1117
1177
  const answer = await brain.ask(fresh ? carryOver(convoRef.current, text) : text)
1118
1178
  controller.signal.removeEventListener("abort", onAbort)
1119
1179
  if (answer.outcome !== "stopped") convoRef.current.push({ user: text, reply: answer.reply })
@@ -1347,29 +1407,21 @@ export function App({ options }: { options: TuiOptions }) {
1347
1407
  const running = status === "running"
1348
1408
  const elapsed = started.current != null ? formatDuration(Date.now() - started.current) : ""
1349
1409
  const modeColor = running ? theme.warn : status === "fail" ? theme.err : theme.accent
1350
- const right = running
1351
- ? suiteProgress
1352
- ? `case ${Math.min(suiteProgress.done + 1, suiteProgress.total)}/${suiteProgress.total}`
1353
- : browsing
1354
- ? step > maxSteps
1355
- ? `step ${step} (past ${maxSteps}, still progressing)`
1356
- : `step ${step}/${maxSteps}`
1357
- : coding
1358
- ? "coding agent"
1359
- : "agent"
1360
- : status === "done" || status === "fail" || status === "stopped"
1361
- ? status
1362
- : options.url
1363
- ? hostOf(options.url)
1364
- : headed
1365
- ? "headed"
1366
- : ""
1367
- const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
1410
+ // What a running request has got to, for the line under the conversation: a suite's case or the browser's step.
1411
+ const progressText = suiteProgress
1412
+ ? `case ${Math.min(suiteProgress.done + 1, suiteProgress.total)}/${suiteProgress.total}`
1413
+ : browsing
1414
+ ? step > maxSteps
1415
+ ? `step ${step}, past ${maxSteps}`
1416
+ : `step ${step}/${maxSteps}`
1417
+ : ""
1368
1418
  const footerVersion = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
1369
- // In the conversation, the footer is the status line: the model, what the session used, and the version.
1419
+ // The footer is the status line, the one place for the model and its profile (tab switches it), what the session
1420
+ // used, and the version; the conversation's footer adds the folder on its left. Nothing above repeats them.
1370
1421
  const sessionTokens = meterSince(sessionMeter.current).total
1371
1422
  const usedTokens = sessionTokens.input + sessionTokens.output
1372
- const footerRight = lines.length ? [configRef.current ? modelLabel(configRef.current) : model, usedTokens ? `${formatTokens(usedTokens)} tokens` : "", footerVersion].filter(Boolean).join(" · ") : footerVersion
1423
+ const modelText = configRef.current ? `${modelLabel(configRef.current)}${profile ? ` (${profile})` : ""}` : model
1424
+ const footerRight = [modelText, usedTokens ? `${formatTokens(usedTokens)} tokens` : "", footerVersion].filter(Boolean).join(" · ")
1373
1425
  // Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
1374
1426
  const suggTrigger = triggerAt(promptText)
1375
1427
  const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
@@ -1435,12 +1487,15 @@ export function App({ options }: { options: TuiOptions }) {
1435
1487
  </box>
1436
1488
  )
1437
1489
 
1438
- const renderControls = (promptWidth: number, placeholder: string, rows: [number, number]) => {
1490
+ /** The prompt and its row of buttons, or a panel in their place; `maxHeight` is the rows a panel may take. */
1491
+ const renderControls = (promptWidth: number, placeholder: string, rows: [number, number], maxHeight: number) => {
1439
1492
  if (refining && !dialog) {
1440
1493
  return (
1441
1494
  <RefinePanel
1442
1495
  state={refining}
1443
1496
  width={promptWidth}
1497
+ maxHeight={maxHeight}
1498
+ scrollRef={refineScroll}
1444
1499
  onChoose={chooseRefine}
1445
1500
  onHover={(i) => refining.phase === "done" && setRefine({ ...refining, choice: i })}
1446
1501
  />
@@ -1460,6 +1515,7 @@ export function App({ options }: { options: TuiOptions }) {
1460
1515
  selected={Math.min(choice, spec.options.length - 1)}
1461
1516
  onChoose={choose}
1462
1517
  onHover={setChoice}
1518
+ maxHeight={maxHeight}
1463
1519
  />
1464
1520
  )
1465
1521
  }
@@ -1513,11 +1569,6 @@ export function App({ options }: { options: TuiOptions }) {
1513
1569
  placeholder={entry ? `${entry.label}${entry.secret ? " (hidden as you type)" : ""}${entry.initial ? ` · Enter keeps ${entry.initial}` : ""} · esc cancels` : placeholder}
1514
1570
  masked={entry?.secret ? secretLength : undefined}
1515
1571
  modeColor={modeColor}
1516
- profile={profile}
1517
- model={model}
1518
- right={right}
1519
- rightColor={rightColor}
1520
- compact={!home}
1521
1572
  focused={!palette && !refining && (panelsOpen ? nav.focus === "chat" : wide || narrowView === "chat")}
1522
1573
  initialValue={draft}
1523
1574
  onText={(t) => {
@@ -1555,7 +1606,7 @@ export function App({ options }: { options: TuiOptions }) {
1555
1606
  start: startActions(cfgHome, 4, undefined, recent.some((r) => r.kind === "run")),
1556
1607
  recent,
1557
1608
  first: getStarted(cfgHome, items),
1558
- facts: projectLine(cfgHome, process.cwd(), { model: modelLabel(cfgHome), signIns }),
1609
+ facts: projectLine(cfgHome, process.cwd(), { signIns }),
1559
1610
  }
1560
1611
  }
1561
1612
  const { items: setup, start, recent, first, facts } = setupRef.current
@@ -1575,10 +1626,12 @@ export function App({ options }: { options: TuiOptions }) {
1575
1626
  const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
1576
1627
  const recentRows = recent.length ? 1 + 1 + recent.length : 0
1577
1628
  const setupRows = loading || open.length ? 2 : 0
1578
- const fixed = 5 + 2 + 1 + 5 + 1 + 1 + 1 // logo, project line, gap, prompt chrome, buttons, footer, one row to spare
1629
+ const fixed = 5 + 2 + 1 + 3 + 1 + 1 + 1 // logo, project line, gap, prompt border, buttons, footer, one row to spare
1579
1630
  const room = height - fixed
1580
1631
  const want = Math.max(5, Math.min(promptLines, room - 1))
1581
- let left = room - want
1632
+ // Refine or a question in the prompt's place takes the room the sections would: they are for starting, not deciding.
1633
+ const deciding = Boolean(refining || dialog)
1634
+ let left = deciding ? 0 : room - want
1582
1635
  const showStart = startRows > 0 && left >= startRows
1583
1636
  if (showStart) left -= startRows
1584
1637
  const showRecent = recentRows > 0 && left >= recentRows
@@ -1608,7 +1661,7 @@ export function App({ options }: { options: TuiOptions }) {
1608
1661
  </box>
1609
1662
  <box style={{ height: 1, flexShrink: 0 }} />
1610
1663
  <box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0 }}>
1611
- {renderControls(promptWidth, "Describe what you want to test…", [5, maxRows])}
1664
+ {renderControls(promptWidth, "Describe what you want to test…", [5, maxRows], height - 5 - 2 - 1 - 1 - 1)}
1612
1665
  {showStart ? (
1613
1666
  <box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
1614
1667
  {heading(offerTitle)}
@@ -1695,14 +1748,6 @@ export function App({ options }: { options: TuiOptions }) {
1695
1748
  } catch {}
1696
1749
  contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
1697
1750
  }
1698
- const llm = cfgNow?.llm
1699
- const sessionInfo: SessionInfo = {
1700
- model: cfgNow ? `${llm?.provider === "mock" ? "offline demo" : llm?.deployment || llm?.model || "none"}${profile ? ` (${profile})` : ""}` : "…",
1701
- cwd: shortCwd(),
1702
- ...(cfgNow?.domain ? { domain: `${cfgNow.domain.industry}${cfgNow.domain.regulations.length ? ` (${cfgNow.domain.regulations.join(", ")})` : ""}` } : {}),
1703
- started: sessionStarted.current,
1704
- requests: requests.current,
1705
- }
1706
1751
  const contextPanel: ContextInfo = {
1707
1752
  tokens: insp.context,
1708
1753
  limit: CONTEXT_LIMIT,
@@ -1721,22 +1766,38 @@ export function App({ options }: { options: TuiOptions }) {
1721
1766
  const activityLines = lines.filter((l) => l.pane === "activity")
1722
1767
  const stop = () => openDialog("stop")
1723
1768
 
1769
+ // Files, context, and tokens as three panels where there is room for them, else as one line.
1770
+ const INFO_PANELS_MIN = 80
1771
+ const infoRows = (paneWidth: number) => (paneWidth >= INFO_PANELS_MIN ? 8 : 1)
1772
+ const infoPanels = (paneWidth: number) => {
1773
+ if (paneWidth < INFO_PANELS_MIN) return <InfoStrip s={insp} c={contextPanel} total={spent.total} width={paneWidth} />
1774
+ const bottom = 8 // the context panel's six rows, and the border
1775
+ const third = Math.floor(paneWidth / 3)
1776
+ const sel = nav.focus === "files" ? Math.min(nav.files, Math.max(0, filesByUse(insp).length - 1)) : undefined
1777
+ return (
1778
+ <box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
1779
+ <FilesPanel s={insp} width={third} height={bottom} selected={sel} onFocus={() => setNav((n) => ({ ...n, focus: "files" }))} onOpen={openFile} />
1780
+ <ContextPanel c={contextPanel} width={third} height={bottom} />
1781
+ <TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
1782
+ </box>
1783
+ )
1784
+ }
1785
+
1724
1786
  /**
1725
- * The right pane: the status bar, then bordered panels for the plan, the activity, and files, context, and tokens.
1726
- * Rows are counted, not left to flex: the activity gets what the others leave, so nothing is pushed off screen.
1787
+ * The Activities column: who is working and on what, the agent's plan with each task's state, and every call, step,
1788
+ * and note as it happens. Rows are counted, not left to flex: the activity gets what the plan leaves.
1727
1789
  */
1728
- const details = (paneWidth: number, rowsAvailable: number) => {
1729
- const bottom = 6 + 2 // the context panel's six rows, and the border
1730
- const plan = planRows(insp, Math.max(3, Math.min(8, Math.floor(rowsAvailable / 5))))
1731
- const activityRows = Math.max(5, rowsAvailable - 2 - plan - bottom)
1732
- const third = Math.floor(paneWidth / 3)
1790
+ const activityColumn = (paneWidth: number, rowsAvailable: number) => {
1791
+ const where = nowNext(insp)
1792
+ const plan = planRows(insp, Math.max(3, Math.min(10, Math.floor(rowsAvailable / 4))))
1793
+ const activityRows = Math.max(5, rowsAvailable - STATUS_ROWS - 1 - plan)
1733
1794
  const focusOn = (f: Focus) => () => setNav((n) => ({ ...n, focus: f }))
1734
- const sel = (f: "plan" | "files") => (nav.focus === f ? Math.min(nav[f], Math.max(0, (f === "plan" ? planLines(insp) : filesByUse(insp)).length - 1)) : undefined)
1795
+ const sel = nav.focus === "plan" ? Math.min(nav.plan, Math.max(0, planLines(insp).length - 1)) : undefined
1735
1796
  return (
1736
1797
  <box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
1737
1798
  <StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress || planProgress(insp)} actions={paneButtons} />
1738
1799
  <box style={{ height: 1, flexShrink: 0 }} />
1739
- {plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel("plan")} onFocus={focusOn("plan")} /> : null}
1800
+ {plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel} onFocus={focusOn("plan")} animate={running} /> : null}
1740
1801
  <ActivityPanel
1741
1802
  lines={activityLines}
1742
1803
  scrollRef={activityScroll}
@@ -1746,17 +1807,16 @@ export function App({ options }: { options: TuiOptions }) {
1746
1807
  focused={nav.focus === "activity"}
1747
1808
  onFocus={focusOn("activity")}
1748
1809
  onFilter={(filter) => setNav((n) => ({ ...n, filter, focus: "activity" }))}
1810
+ now={running ? where : undefined}
1749
1811
  />
1750
- <box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
1751
- <FilesPanel s={insp} width={third} height={bottom} selected={sel("files")} onFocus={focusOn("files")} onOpen={openFile} />
1752
- <ContextPanel c={contextPanel} width={third} height={bottom} />
1753
- <TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
1754
- </box>
1755
1812
  </box>
1756
1813
  )
1757
1814
  }
1758
1815
 
1759
- const transcript = (all: Line[], style: Record<string, unknown>) => (
1816
+ const transcript = (every: Line[], style: Record<string, unknown>) => {
1817
+ // Quiet lines (reads, searches, calls in progress) are the Activity panel's; the conversation stays readable.
1818
+ const all = every.filter((l) => !l.quiet)
1819
+ return (
1760
1820
  <scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
1761
1821
  {all.map((line, i) => (
1762
1822
  <TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
@@ -1767,28 +1827,31 @@ export function App({ options }: { options: TuiOptions }) {
1767
1827
  <Spinner kind="dots" color={theme.memory} />
1768
1828
  <text>
1769
1829
  <span fg={theme.memory}>{` ${activity || "Working…"}`}</span>
1770
- <span fg={theme.faint}>{` (${elapsed}${tokens ? ` · ${formatTokens(tokens)} tokens` : ""} · esc to interrupt)`}</span>
1830
+ <span fg={theme.faint}>{` (${[elapsed, progressText, tokens ? `${formatTokens(tokens)} tokens` : "", "esc to interrupt"].filter(Boolean).join(" · ")})`}</span>
1771
1831
  </text>
1772
1832
  </box>
1773
1833
  ) : null}
1774
1834
  </scrollbox>
1775
- )
1835
+ )
1836
+ }
1776
1837
  // A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
1777
- const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.floor(height * 0.45))])
1838
+ // A panel in the prompt's place (Refine, a question) may take most of the screen, leaving the conversation a few rows.
1839
+ const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.floor(height * 0.45))], Math.max(8, height - 8))
1778
1840
 
1779
1841
  const split = panelsOpen
1780
1842
  if (split) {
1781
- const leftWidth = Math.floor(width * 0.4)
1843
+ // Half the width for the Activities column; the conversation, with files, context, and tokens above it, the other half.
1844
+ const leftWidth = Math.floor(width / 2)
1782
1845
  return (
1783
1846
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1784
1847
  <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1785
1848
  <box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1786
- <SessionPanel info={sessionInfo} width={leftWidth - 3} />
1849
+ {infoPanels(leftWidth - 3)}
1787
1850
  {transcript(chatLines, {})}
1788
1851
  <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1789
1852
  </box>
1790
1853
  <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1791
- {details(width - leftWidth - 3, height - 2)}
1854
+ {activityColumn(width - leftWidth - 3, height - 2)}
1792
1855
  </box>
1793
1856
  </box>
1794
1857
  <Footer left={shortCwd()} right={footerRight} width={width} />
@@ -1801,7 +1864,10 @@ export function App({ options }: { options: TuiOptions }) {
1801
1864
  if (!wide && narrowView === "details") {
1802
1865
  return (
1803
1866
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1804
- <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>{details(promptWidth, height - 3 - (dialog ? 8 : 0))}</box>
1867
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>
1868
+ {activityColumn(promptWidth, height - 3 - (dialog ? 8 : 0) - infoRows(promptWidth))}
1869
+ {infoPanels(promptWidth)}
1870
+ </box>
1805
1871
  <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
1806
1872
  {dialog ? (
1807
1873
  // Stop, a question, or "your turn": the same panel the chat view shows in place of the prompt.