@capagents/forgeqe 1.1.5 → 1.1.6

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, 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) => {
@@ -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)
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
@@ -1003,17 +1049,16 @@ export function App({ options }: { options: TuiOptions }) {
1003
1049
  return
1004
1050
  }
1005
1051
  // 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)
1052
+ // one line each in brief in the conversation; its file reads and searches only in Activity, or with /details.
1053
+ const shown = shows(e, detailRef.current)
1054
+ if (e.kind === "start") startCall(e.id, e.text)
1055
+ else if (e.kind === "tool") pushTool(e.text, e.ok, e.id, showsAgentTool(e.text, e.ok, detailRef.current))
1008
1056
  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
- }
1057
+ else if (e.kind === "info") record("code", e.text, shown)
1058
+ else if (e.kind === "test") record(e.ok ? "ok" : "err", e.text, shown)
1014
1059
  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…")
1060
+ if (e.kind === "start") setActivity(`Agent: ${clip(e.text, 60)}…`)
1061
+ else if (e.kind === "tool" || e.kind === "say") setActivity(nowWorking())
1017
1062
  }
1018
1063
 
1019
1064
  const toolContext = (agent: boolean, signal?: AbortSignal) => ({
@@ -1050,6 +1095,8 @@ export function App({ options }: { options: TuiOptions }) {
1050
1095
  }
1051
1096
 
1052
1097
  const finish = (finalStatus: string, followUps: FollowUp[] = []) => {
1098
+ // A call the request ended inside of (stopped, or its result never came) is not left spinning in Activity.
1099
+ setLines((prev) => (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))
1053
1100
  setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on, followUps }))
1054
1101
  progressDone(inspRef.current, finalStatus === "stopped")
1055
1102
  bump()
@@ -1113,7 +1160,7 @@ export function App({ options }: { options: TuiOptions }) {
1113
1160
  const brain = brainRef.current
1114
1161
  const onAbort = () => void brain.abort()
1115
1162
  controller.signal.addEventListener("abort", onAbort, { once: true })
1116
- if (!controller.signal.aborted) setActivity("Thinking…")
1163
+ if (!controller.signal.aborted) setActivity("Reading your request and deciding the first step…")
1117
1164
  const answer = await brain.ask(fresh ? carryOver(convoRef.current, text) : text)
1118
1165
  controller.signal.removeEventListener("abort", onAbort)
1119
1166
  if (answer.outcome !== "stopped") convoRef.current.push({ user: text, reply: answer.reply })
@@ -1347,29 +1394,21 @@ export function App({ options }: { options: TuiOptions }) {
1347
1394
  const running = status === "running"
1348
1395
  const elapsed = started.current != null ? formatDuration(Date.now() - started.current) : ""
1349
1396
  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
1397
+ // What a running request has got to, for the line under the conversation: a suite's case or the browser's step.
1398
+ const progressText = suiteProgress
1399
+ ? `case ${Math.min(suiteProgress.done + 1, suiteProgress.total)}/${suiteProgress.total}`
1400
+ : browsing
1401
+ ? step > maxSteps
1402
+ ? `step ${step}, past ${maxSteps}`
1403
+ : `step ${step}/${maxSteps}`
1404
+ : ""
1368
1405
  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.
1406
+ // The footer is the status line, the one place for the model and its profile (tab switches it), what the session
1407
+ // used, and the version; the conversation's footer adds the folder on its left. Nothing above repeats them.
1370
1408
  const sessionTokens = meterSince(sessionMeter.current).total
1371
1409
  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
1410
+ const modelText = configRef.current ? `${modelLabel(configRef.current)}${profile ? ` (${profile})` : ""}` : model
1411
+ const footerRight = [modelText, usedTokens ? `${formatTokens(usedTokens)} tokens` : "", footerVersion].filter(Boolean).join(" · ")
1373
1412
  // Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
1374
1413
  const suggTrigger = triggerAt(promptText)
1375
1414
  const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
@@ -1435,12 +1474,15 @@ export function App({ options }: { options: TuiOptions }) {
1435
1474
  </box>
1436
1475
  )
1437
1476
 
1438
- const renderControls = (promptWidth: number, placeholder: string, rows: [number, number]) => {
1477
+ /** The prompt and its row of buttons, or a panel in their place; `maxHeight` is the rows a panel may take. */
1478
+ const renderControls = (promptWidth: number, placeholder: string, rows: [number, number], maxHeight: number) => {
1439
1479
  if (refining && !dialog) {
1440
1480
  return (
1441
1481
  <RefinePanel
1442
1482
  state={refining}
1443
1483
  width={promptWidth}
1484
+ maxHeight={maxHeight}
1485
+ scrollRef={refineScroll}
1444
1486
  onChoose={chooseRefine}
1445
1487
  onHover={(i) => refining.phase === "done" && setRefine({ ...refining, choice: i })}
1446
1488
  />
@@ -1460,6 +1502,7 @@ export function App({ options }: { options: TuiOptions }) {
1460
1502
  selected={Math.min(choice, spec.options.length - 1)}
1461
1503
  onChoose={choose}
1462
1504
  onHover={setChoice}
1505
+ maxHeight={maxHeight}
1463
1506
  />
1464
1507
  )
1465
1508
  }
@@ -1513,11 +1556,6 @@ export function App({ options }: { options: TuiOptions }) {
1513
1556
  placeholder={entry ? `${entry.label}${entry.secret ? " (hidden as you type)" : ""}${entry.initial ? ` · Enter keeps ${entry.initial}` : ""} · esc cancels` : placeholder}
1514
1557
  masked={entry?.secret ? secretLength : undefined}
1515
1558
  modeColor={modeColor}
1516
- profile={profile}
1517
- model={model}
1518
- right={right}
1519
- rightColor={rightColor}
1520
- compact={!home}
1521
1559
  focused={!palette && !refining && (panelsOpen ? nav.focus === "chat" : wide || narrowView === "chat")}
1522
1560
  initialValue={draft}
1523
1561
  onText={(t) => {
@@ -1555,7 +1593,7 @@ export function App({ options }: { options: TuiOptions }) {
1555
1593
  start: startActions(cfgHome, 4, undefined, recent.some((r) => r.kind === "run")),
1556
1594
  recent,
1557
1595
  first: getStarted(cfgHome, items),
1558
- facts: projectLine(cfgHome, process.cwd(), { model: modelLabel(cfgHome), signIns }),
1596
+ facts: projectLine(cfgHome, process.cwd(), { signIns }),
1559
1597
  }
1560
1598
  }
1561
1599
  const { items: setup, start, recent, first, facts } = setupRef.current
@@ -1575,10 +1613,12 @@ export function App({ options }: { options: TuiOptions }) {
1575
1613
  const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
1576
1614
  const recentRows = recent.length ? 1 + 1 + recent.length : 0
1577
1615
  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
1616
+ const fixed = 5 + 2 + 1 + 3 + 1 + 1 + 1 // logo, project line, gap, prompt border, buttons, footer, one row to spare
1579
1617
  const room = height - fixed
1580
1618
  const want = Math.max(5, Math.min(promptLines, room - 1))
1581
- let left = room - want
1619
+ // Refine or a question in the prompt's place takes the room the sections would: they are for starting, not deciding.
1620
+ const deciding = Boolean(refining || dialog)
1621
+ let left = deciding ? 0 : room - want
1582
1622
  const showStart = startRows > 0 && left >= startRows
1583
1623
  if (showStart) left -= startRows
1584
1624
  const showRecent = recentRows > 0 && left >= recentRows
@@ -1608,7 +1648,7 @@ export function App({ options }: { options: TuiOptions }) {
1608
1648
  </box>
1609
1649
  <box style={{ height: 1, flexShrink: 0 }} />
1610
1650
  <box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0 }}>
1611
- {renderControls(promptWidth, "Describe what you want to test…", [5, maxRows])}
1651
+ {renderControls(promptWidth, "Describe what you want to test…", [5, maxRows], height - 5 - 2 - 1 - 1 - 1)}
1612
1652
  {showStart ? (
1613
1653
  <box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
1614
1654
  {heading(offerTitle)}
@@ -1695,14 +1735,6 @@ export function App({ options }: { options: TuiOptions }) {
1695
1735
  } catch {}
1696
1736
  contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
1697
1737
  }
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
1738
  const contextPanel: ContextInfo = {
1707
1739
  tokens: insp.context,
1708
1740
  limit: CONTEXT_LIMIT,
@@ -1721,22 +1753,38 @@ export function App({ options }: { options: TuiOptions }) {
1721
1753
  const activityLines = lines.filter((l) => l.pane === "activity")
1722
1754
  const stop = () => openDialog("stop")
1723
1755
 
1756
+ // Files, context, and tokens as three panels where there is room for them, else as one line.
1757
+ const INFO_PANELS_MIN = 80
1758
+ const infoRows = (paneWidth: number) => (paneWidth >= INFO_PANELS_MIN ? 8 : 1)
1759
+ const infoPanels = (paneWidth: number) => {
1760
+ if (paneWidth < INFO_PANELS_MIN) return <InfoStrip s={insp} c={contextPanel} total={spent.total} width={paneWidth} />
1761
+ const bottom = 8 // the context panel's six rows, and the border
1762
+ const third = Math.floor(paneWidth / 3)
1763
+ const sel = nav.focus === "files" ? Math.min(nav.files, Math.max(0, filesByUse(insp).length - 1)) : undefined
1764
+ return (
1765
+ <box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
1766
+ <FilesPanel s={insp} width={third} height={bottom} selected={sel} onFocus={() => setNav((n) => ({ ...n, focus: "files" }))} onOpen={openFile} />
1767
+ <ContextPanel c={contextPanel} width={third} height={bottom} />
1768
+ <TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
1769
+ </box>
1770
+ )
1771
+ }
1772
+
1724
1773
  /**
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.
1774
+ * The Activities column: who is working and on what, the agent's plan with each task's state, and every call, step,
1775
+ * and note as it happens. Rows are counted, not left to flex: the activity gets what the plan leaves.
1727
1776
  */
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)
1777
+ const activityColumn = (paneWidth: number, rowsAvailable: number) => {
1778
+ const where = nowNext(insp)
1779
+ const plan = planRows(insp, Math.max(3, Math.min(10, Math.floor(rowsAvailable / 4))))
1780
+ const activityRows = Math.max(5, rowsAvailable - STATUS_ROWS - 1 - plan)
1733
1781
  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)
1782
+ const sel = nav.focus === "plan" ? Math.min(nav.plan, Math.max(0, planLines(insp).length - 1)) : undefined
1735
1783
  return (
1736
1784
  <box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
1737
1785
  <StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress || planProgress(insp)} actions={paneButtons} />
1738
1786
  <box style={{ height: 1, flexShrink: 0 }} />
1739
- {plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel("plan")} onFocus={focusOn("plan")} /> : null}
1787
+ {plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel} onFocus={focusOn("plan")} /> : null}
1740
1788
  <ActivityPanel
1741
1789
  lines={activityLines}
1742
1790
  scrollRef={activityScroll}
@@ -1746,17 +1794,16 @@ export function App({ options }: { options: TuiOptions }) {
1746
1794
  focused={nav.focus === "activity"}
1747
1795
  onFocus={focusOn("activity")}
1748
1796
  onFilter={(filter) => setNav((n) => ({ ...n, filter, focus: "activity" }))}
1797
+ now={running ? where : undefined}
1749
1798
  />
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
1799
  </box>
1756
1800
  )
1757
1801
  }
1758
1802
 
1759
- const transcript = (all: Line[], style: Record<string, unknown>) => (
1803
+ const transcript = (every: Line[], style: Record<string, unknown>) => {
1804
+ // Quiet lines (reads, searches, calls in progress) are the Activity panel's; the conversation stays readable.
1805
+ const all = every.filter((l) => !l.quiet)
1806
+ return (
1760
1807
  <scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
1761
1808
  {all.map((line, i) => (
1762
1809
  <TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
@@ -1767,28 +1814,31 @@ export function App({ options }: { options: TuiOptions }) {
1767
1814
  <Spinner kind="dots" color={theme.memory} />
1768
1815
  <text>
1769
1816
  <span fg={theme.memory}>{` ${activity || "Working…"}`}</span>
1770
- <span fg={theme.faint}>{` (${elapsed}${tokens ? ` · ${formatTokens(tokens)} tokens` : ""} · esc to interrupt)`}</span>
1817
+ <span fg={theme.faint}>{` (${[elapsed, progressText, tokens ? `${formatTokens(tokens)} tokens` : "", "esc to interrupt"].filter(Boolean).join(" · ")})`}</span>
1771
1818
  </text>
1772
1819
  </box>
1773
1820
  ) : null}
1774
1821
  </scrollbox>
1775
- )
1822
+ )
1823
+ }
1776
1824
  // 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))])
1825
+ // A panel in the prompt's place (Refine, a question) may take most of the screen, leaving the conversation a few rows.
1826
+ const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.floor(height * 0.45))], Math.max(8, height - 8))
1778
1827
 
1779
1828
  const split = panelsOpen
1780
1829
  if (split) {
1781
- const leftWidth = Math.floor(width * 0.4)
1830
+ // Half the width for the Activities column; the conversation, with files, context, and tokens above it, the other half.
1831
+ const leftWidth = Math.floor(width / 2)
1782
1832
  return (
1783
1833
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1784
1834
  <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1785
1835
  <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} />
1836
+ {infoPanels(leftWidth - 3)}
1787
1837
  {transcript(chatLines, {})}
1788
1838
  <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1789
1839
  </box>
1790
1840
  <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1791
- {details(width - leftWidth - 3, height - 2)}
1841
+ {activityColumn(width - leftWidth - 3, height - 2)}
1792
1842
  </box>
1793
1843
  </box>
1794
1844
  <Footer left={shortCwd()} right={footerRight} width={width} />
@@ -1801,7 +1851,10 @@ export function App({ options }: { options: TuiOptions }) {
1801
1851
  if (!wide && narrowView === "details") {
1802
1852
  return (
1803
1853
  <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>
1854
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>
1855
+ {activityColumn(promptWidth, height - 3 - (dialog ? 8 : 0) - infoRows(promptWidth))}
1856
+ {infoPanels(promptWidth)}
1857
+ </box>
1805
1858
  <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
1806
1859
  {dialog ? (
1807
1860
  // Stop, a question, or "your turn": the same panel the chat view shows in place of the prompt.
@@ -42,14 +42,8 @@ export type PromptProps = {
42
42
  maxRows: number
43
43
  placeholder: string
44
44
  modeColor: string
45
- profile: string
46
- model: string
47
- right?: string
48
- rightColor?: string
49
45
  /** A secret being entered: the box shows one dot per character instead of a text field, so the value is never drawn. */
50
46
  masked?: number
51
- /** Beside a session panel that names the model and profile, and a status bar: no bottom row. */
52
- compact?: boolean
53
47
  /** Text the box starts with when `inputKey` changes (a refined request, an example from the palette). */
54
48
  initialValue?: string
55
49
  /** False while an overlay (the palette, a suggestion list) takes the keyboard. Default true. */
@@ -94,7 +88,6 @@ const PROMPT_KEYS: KeyBinding[] = [
94
88
  ]
95
89
 
96
90
  export function Prompt(props: PromptProps) {
97
- const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
98
91
  const ref = useRef<TextareaRenderable>(null)
99
92
  // The rows the text needs; the box shows that many between minRows and maxRows, so it also grows when the screen
100
93
  // around it makes more room (the start screen folds a section for a long request).
@@ -158,20 +151,6 @@ export function Prompt(props: PromptProps) {
158
151
  )}
159
152
  </box>
160
153
  </box>
161
- {props.compact ? null : (
162
- <box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
163
- <text>
164
- <span fg={theme.muted}>{"Model "}</span>
165
- <span fg={theme.text}>{props.model || "…"}</span>
166
- </text>
167
- <text>
168
- {props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
169
- {props.right ? <span fg={theme.faint}>{" · "}</span> : null}
170
- <span fg={theme.muted}>{"profile "}</span>
171
- <span fg={theme.text}>{props.profile || "none"}</span>
172
- </text>
173
- </box>
174
- )}
175
154
  </box>
176
155
  )
177
156
  }
@@ -301,11 +280,33 @@ export type ChoicePanelProps = {
301
280
  onHover: (index: number) => void
302
281
  /** One option per line, for many or long options. */
303
282
  vertical?: boolean
283
+ /** Rows the panel may take; a long detail is cut and a long list shows a window around the selected option. */
284
+ maxHeight?: number
285
+ }
286
+
287
+ /** The options a vertical list shows in `room` rows, around the selected one: [first, last) and what is hidden. */
288
+ export function optionWindow(count: number, selected: number, room: number): { from: number; to: number } {
289
+ if (count <= room) return { from: 0, to: count }
290
+ // Two rows go to "↑ n more" and "↓ n more"; the selected option sits in the middle of the rest when it can.
291
+ const shown = Math.max(1, room - 2)
292
+ const from = Math.max(0, Math.min(count - shown, selected - Math.floor(shown / 2)))
293
+ return { from, to: from + shown }
304
294
  }
305
295
 
306
296
  /** Question with buttons, as the agent runtime shows for permissions: arrows or tab move, enter confirms, click works too. */
307
297
  export function ChoicePanel(props: ChoicePanelProps) {
308
298
  const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
299
+ // The detail's columns: the bar and padding take five, its own indent two, and one more for a word that wraps early.
300
+ const inner = Math.max(10, props.width - 8)
301
+ // Padding, title, the gap, the keys row under a vertical list, and the edge below: the rest is detail and options.
302
+ const budget = Math.max(6, (props.maxHeight ?? 999) - 6 - (props.vertical ? 1 : 0))
303
+ const optionRows = props.vertical ? Math.min(props.options.length, Math.max(3, budget - 2)) : 1
304
+ const detailRoom = Math.max(1, budget - optionRows)
305
+ const detailRows = props.detail ? wrappedLines(props.detail, inner) : 0
306
+ // A detail longer than its room is cut at a word, so the options stay on screen.
307
+ const detail = detailRows > detailRoom ? `${props.detail.slice(0, detailRoom * inner - 2).replace(/\s+\S*$/, "")}…` : props.detail
308
+ const win = props.vertical ? optionWindow(props.options.length, props.selected, optionRows) : { from: 0, to: props.options.length }
309
+ const more = (n: number, arrow: string) => (n > 0 ? <text fg={theme.faint}>{` ${arrow} ${n} more`}</text> : null)
309
310
  return (
310
311
  <box style={{ flexDirection: "column", width: props.width, flexShrink: 0 }}>
311
312
  <box
@@ -326,10 +327,12 @@ export function ChoicePanel(props: ChoicePanelProps) {
326
327
  <span fg={props.color}>{"△ "}</span>
327
328
  <span fg={theme.text}>{props.title}</span>
328
329
  </text>
329
- {props.detail ? <text fg={theme.muted}>{` ${props.detail}`}</text> : null}
330
+ {detail ? <text fg={theme.muted}>{` ${detail}`}</text> : null}
330
331
  <box style={{ flexDirection: props.vertical ? "column" : "row", justifyContent: "space-between", marginTop: 1 }}>
331
332
  <box style={{ flexDirection: props.vertical ? "column" : "row", alignItems: "flex-start" }}>
332
- {props.options.map((option, i) => {
333
+ {props.vertical ? more(win.from, "↑") : null}
334
+ {props.options.slice(win.from, win.to).map((option, j) => {
335
+ const i = win.from + j
333
336
  const active = i === props.selected
334
337
  return (
335
338
  <box
@@ -342,6 +345,7 @@ export function ChoicePanel(props: ChoicePanelProps) {
342
345
  </box>
343
346
  )
344
347
  })}
348
+ {props.vertical ? more(props.options.length - win.to, "↓") : null}
345
349
  </box>
346
350
  <text style={{ marginTop: props.vertical ? 1 : 0 }}>
347
351
  <span fg={theme.text}>{props.vertical ? "↑↓" : "⇆"}</span>