@capagents/forgeqe 1.1.4 → 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.
Files changed (65) hide show
  1. package/HOW_TO_USE.md +64 -17
  2. package/README.md +2 -1
  3. package/browserstack.example.yaml +14 -2
  4. package/docs/user-guide.html +78 -23
  5. package/forgeqe.example.yaml +18 -5
  6. package/package.json +2 -1
  7. package/prompts/README.md +1 -0
  8. package/prompts/brain.md +8 -1
  9. package/prompts/defect-rca.md +43 -0
  10. package/prompts/test-agent.md +13 -2
  11. package/prompts/test-reviewer.md +8 -3
  12. package/prompts/test-rules.md +10 -4
  13. package/py/forgeqe_engine/browser.py +1 -1
  14. package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
  15. package/skills/playwright-typescript/SKILL.md +2 -1
  16. package/src/brain/index.ts +1 -1
  17. package/src/capabilities/registry.ts +34 -6
  18. package/src/cases/bugs.ts +209 -22
  19. package/src/cases/defectHistory.ts +193 -0
  20. package/src/cases/defects.ts +255 -0
  21. package/src/cases/github.ts +162 -0
  22. package/src/cases/goal.ts +1 -1
  23. package/src/cases/index.ts +23 -4
  24. package/src/cases/predict.ts +372 -0
  25. package/src/cases/rca.ts +258 -0
  26. package/src/cases/readiness.ts +38 -1
  27. package/src/cases/repro.ts +202 -0
  28. package/src/cases/types.ts +8 -1
  29. package/src/cli/help.ts +8 -2
  30. package/src/cli/index.ts +36 -7
  31. package/src/code/graph/graph.ts +518 -0
  32. package/src/code/graph/locators.ts +398 -0
  33. package/src/code/graph/parse.ts +439 -0
  34. package/src/code/graph/tools.ts +113 -0
  35. package/src/code/handoff.ts +93 -18
  36. package/src/code/loop.ts +30 -5
  37. package/src/code/profile.ts +14 -3
  38. package/src/code/replay.ts +1 -1
  39. package/src/code/review.ts +22 -0
  40. package/src/code/runtime.ts +8 -1
  41. package/src/config.ts +142 -18
  42. package/src/mcp/attachtools.ts +223 -0
  43. package/src/mcp/commands.ts +1 -1
  44. package/src/mcp/defecttools.ts +106 -0
  45. package/src/mcp/devicetools.ts +125 -0
  46. package/src/mcp/external.ts +42 -1
  47. package/src/mcp/server.ts +3 -1
  48. package/src/mcp/tools.ts +95 -10
  49. package/src/monitor/run.ts +1 -1
  50. package/src/report/data.ts +14 -0
  51. package/src/report/html.ts +15 -3
  52. package/src/report/template.html +44 -43
  53. package/src/run.ts +4 -2
  54. package/src/suite/report.ts +44 -11
  55. package/src/suite/run.ts +163 -16
  56. package/src/tui/App.tsx +153 -94
  57. package/src/tui/components.tsx +27 -23
  58. package/src/tui/inspector.tsx +140 -83
  59. package/src/tui/landing.ts +3 -4
  60. package/src/tui/refine-panel.tsx +102 -52
  61. package/src/tui/shortcuts.tsx +2 -0
  62. package/src/tui/transcript.tsx +32 -2
  63. package/src/types.ts +3 -1
  64. package/src/usage.ts +1 -1
  65. package/testmu.example.yaml +11 -0
package/src/tui/App.tsx CHANGED
@@ -14,7 +14,7 @@ import { stepVerdictLines } from "../cases/types.ts"
14
14
  import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
15
15
  import { authStatus, openInBrowser, signIn, signOut, type AuthIO } from "../auth/signin.ts"
16
16
  import { HELP, parseCommand } from "../commands.ts"
17
- import { loadConfig, selectProfile, type AppConfig } from "../config.ts"
17
+ import { loadConfig, selectProfile, type AppConfig, type BrowserProvider } from "../config.ts"
18
18
  import { TOOLS, type AskQuestion, type ToolEvent } from "../mcp/tools.ts"
19
19
  import type { executeRun } from "../run.ts"
20
20
  import { bundleLogs } from "../sessionLog.ts"
@@ -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,
@@ -89,6 +90,10 @@ export type TuiOptions = {
89
90
  /** First message, sent as if typed. */
90
91
  goal?: string
91
92
  headed?: boolean
93
+ /** Browser provider pinned for the session's runs (--browser-provider). */
94
+ browserProvider?: BrowserProvider
95
+ /** Named devices pinned for the session's runs (--devices). */
96
+ devices?: string[]
92
97
  replay?: ReplayMode
93
98
  memory?: boolean
94
99
  /** Repo the agent works in and writes tests for; null: no tests unless asked; undefined: code.repo. */
@@ -119,6 +124,8 @@ export function App({ options }: { options: TuiOptions }) {
119
124
  widthRef.current = width
120
125
  const scrollRef = useRef<ScrollBoxRenderable>(null)
121
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)
122
129
  const submitRef = useRef<(value: string) => void>(() => {})
123
130
  /** Refines what is in the prompt (phase two of the palette work wires it). */
124
131
  const refineRef = useRef<() => void>(() => {})
@@ -235,17 +242,33 @@ export function App({ options }: { options: TuiOptions }) {
235
242
  thinkingRef.current = thinking
236
243
 
237
244
  /** Lines go to the chat, or while a request runs and they are not results, to the activity pane; `pane` overrides that. */
238
- const push = (kind: Line["kind"], text: string, pane?: Pane) => {
245
+ const push = (kind: Line["kind"], text: string, pane?: Pane, quiet?: boolean) => {
239
246
  const parts = kind === "user" || kind === "reply" ? [text] : text.split("\n")
240
247
  const where = pane || (answeringRef.current ? "chat" : paneFor(kind, abortRef.current !== null))
241
248
  setLines((prev) => {
242
249
  const next = [...prev]
243
- for (const part of parts) next.push({ id: idRef.current++, kind, text: part, pane: where })
244
- 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)
245
252
  })
246
253
  }
247
- const pushLine = (line: Omit<Line, "id">) =>
248
- 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
+ }
249
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. */
250
273
  const inspect = (e: CodeEvent) => {
251
274
  const s = inspRef.current
@@ -257,8 +280,14 @@ export function App({ options }: { options: TuiOptions }) {
257
280
  else return
258
281
  bump()
259
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
+ }
260
288
  /** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
261
- 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)
262
291
  /** A short note in the hint row that clears itself: "opened report.html". */
263
292
  const flashTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
264
293
  const flash = (text: string) => {
@@ -642,6 +671,9 @@ export function App({ options }: { options: TuiOptions }) {
642
671
  }
643
672
  } else if (r.phase === "error") {
644
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)
645
677
  } else if (key.name === "left" || (key.name === "tab" && key.shift)) setRefine({ ...r, choice: (r.choice + REFINE_CHOICES.length - 1) % REFINE_CHOICES.length })
646
678
  else if (key.name === "right" || key.name === "tab") setRefine({ ...r, choice: (r.choice + 1) % REFINE_CHOICES.length })
647
679
  else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") chooseRefine(REFINE_CHOICES[r.choice]!.id)
@@ -680,7 +712,9 @@ export function App({ options }: { options: TuiOptions }) {
680
712
  const panes = (width >= SPLIT_MIN && !chatWide) || (width < SPLIT_MIN && narrowView === "details")
681
713
  const insp = inspRef.current
682
714
  const panelsOpen = width >= SPLIT_MIN && !chatWide
683
- 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)
684
718
  if (panes && key.name === "tab" && key.shift && !entryRef.current) {
685
719
  // Consumed here: the prompt can take focus within this key press and must not also act on it.
686
720
  key.preventDefault()
@@ -725,6 +759,13 @@ export function App({ options }: { options: TuiOptions }) {
725
759
  if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && panelsOpen) return setNav((n) => ({ ...n, focus: "chat" }))
726
760
  if (!key.ctrl && !key.meta) return
727
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
+ }
728
769
  // ? on an empty prompt: every key in one panel; esc or ? closes it.
729
770
  if (shortcutsRef.current) {
730
771
  if (key.name === "escape" || key.sequence === "?") setShortcuts(false)
@@ -805,11 +846,12 @@ export function App({ options }: { options: TuiOptions }) {
805
846
  else if (!aborted()) setActivity(`Replaying step ${event.step} from memory…`)
806
847
  }
807
848
  if (event.type === "memory") {
808
- if (shows(event, detailRef.current)) push("memory", event.text)
849
+ record("memory", event.text, shows(event, detailRef.current))
809
850
  markRef.current = Date.now()
810
851
  }
811
852
  if (event.type === "usage") useTokens(`run-${runIndex.current}`, event.usage.input + event.usage.output)
812
853
  if (event.type === "log") {
854
+ // Engine info logs stay in the session log: they would bury the steps in the panel.
813
855
  if (shows(event, detailRef.current)) push(event.level === "error" ? "err" : "sys", event.text)
814
856
  if (!aborted()) setActivity(activityFromLog(event.text))
815
857
  }
@@ -905,6 +947,7 @@ export function App({ options }: { options: TuiOptions }) {
905
947
  if (ev.type === "usage") useTokens(`case-${s.index}`, ev.usage.input + ev.usage.output)
906
948
  else if (!shows(ev, detailRef.current)) {
907
949
  if (ev.type === "log" && !aborted()) setActivity(`${p}${activityFromLog(ev.text)}`)
950
+ else if (ev.type === "memory") record("memory", p + ev.text, false)
908
951
  } else if (ev.type === "log") push(ev.level === "error" ? "err" : "sys", p + ev.text)
909
952
  else if (ev.type === "memory") push("memory", p + ev.text)
910
953
  else if (ev.type === "thought") push("thought", p + stepIntent(ev.step, ev.text, detailRef.current))
@@ -924,25 +967,32 @@ export function App({ options }: { options: TuiOptions }) {
924
967
  case "info":
925
968
  push(e.ok === undefined ? "code" : e.ok ? "ok" : "err", e.text)
926
969
  return
970
+ case "call-start":
971
+ startCall(e.id, e.text)
972
+ if (!aborted()) setActivity(`Agent: ${clip(e.text, 60)}…`)
973
+ return
927
974
  case "call":
928
- pushTool(e.text, e.ok)
929
- if (!aborted()) setActivity(`Agent: ${clip(e.text, 60)}`)
975
+ pushTool(e.text, e.ok, e.id)
976
+ if (!aborted()) setActivity(nowWorking())
930
977
  return
931
978
  case "agent": {
932
979
  const ev = e.event
933
980
  inspect(ev)
934
981
  if (ev.kind === "plan") return
935
982
  if (ev.kind === "usage") useTokens(`agent-tool-${runIndex.current}`, ev.usage.input + ev.usage.output)
936
- // Hidden lines (reads, searches, the agent's narration) still show on the activity line below.
937
- else if (shows(ev, detailRef.current)) {
938
- if (ev.kind === "info") push("code", ev.text)
939
- else if (ev.kind === "say") push("thought", ev.text)
940
- else if (ev.kind === "tool") pushTool(ev.text, ev.ok)
941
- 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)
942
991
  }
943
992
  if (aborted()) return
944
993
  const who = codingRef.current ? "Coding agent" : "Agent"
945
- 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)}`)
946
996
  else if (ev.kind === "info" && ev.text.startsWith("ForgeQE runs the test")) setActivity("ForgeQE is running the new test…")
947
997
  else if (ev.kind === "say") setActivity(`${who} is working…`)
948
998
  return
@@ -999,21 +1049,22 @@ export function App({ options }: { options: TuiOptions }) {
999
1049
  return
1000
1050
  }
1001
1051
  // ForgeQE's own agent: its tool calls (Azure DevOps and other MCP servers, commands) and what it says between them,
1002
- // one line each in brief; only its file reads and searches are left to /details.
1003
- 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))
1004
1056
  else if (e.kind === "say") push("thought", briefText(e.text, detailRef.current, 200))
1005
- else if (shows(e, detailRef.current)) {
1006
- if (e.kind === "info") push("code", e.text)
1007
- if (e.kind === "tool") pushTool(e.text, e.ok)
1008
- if (e.kind === "test") push(e.ok ? "ok" : "err", e.text)
1009
- }
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)
1010
1059
  if (abortRef.current?.signal.aborted) return
1011
- if (e.kind === "tool") setActivity(`Agent: ${clip(e.text, 60)}`)
1012
- 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())
1013
1062
  }
1014
1063
 
1015
1064
  const toolContext = (agent: boolean, signal?: AbortSignal) => ({
1016
1065
  headed: headedRef.current,
1066
+ browserProvider: options.browserProvider,
1067
+ devices: options.devices,
1017
1068
  maxSteps: stepsRef.current,
1018
1069
  replay: options.replay,
1019
1070
  memory: options.memory,
@@ -1044,6 +1095,8 @@ export function App({ options }: { options: TuiOptions }) {
1044
1095
  }
1045
1096
 
1046
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))
1047
1100
  setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on, followUps }))
1048
1101
  progressDone(inspRef.current, finalStatus === "stopped")
1049
1102
  bump()
@@ -1107,7 +1160,7 @@ export function App({ options }: { options: TuiOptions }) {
1107
1160
  const brain = brainRef.current
1108
1161
  const onAbort = () => void brain.abort()
1109
1162
  controller.signal.addEventListener("abort", onAbort, { once: true })
1110
- if (!controller.signal.aborted) setActivity("Thinking…")
1163
+ if (!controller.signal.aborted) setActivity("Reading your request and deciding the first step…")
1111
1164
  const answer = await brain.ask(fresh ? carryOver(convoRef.current, text) : text)
1112
1165
  controller.signal.removeEventListener("abort", onAbort)
1113
1166
  if (answer.outcome !== "stopped") convoRef.current.push({ user: text, reply: answer.reply })
@@ -1341,29 +1394,21 @@ export function App({ options }: { options: TuiOptions }) {
1341
1394
  const running = status === "running"
1342
1395
  const elapsed = started.current != null ? formatDuration(Date.now() - started.current) : ""
1343
1396
  const modeColor = running ? theme.warn : status === "fail" ? theme.err : theme.accent
1344
- const right = running
1345
- ? suiteProgress
1346
- ? `case ${Math.min(suiteProgress.done + 1, suiteProgress.total)}/${suiteProgress.total}`
1347
- : browsing
1348
- ? step > maxSteps
1349
- ? `step ${step} (past ${maxSteps}, still progressing)`
1350
- : `step ${step}/${maxSteps}`
1351
- : coding
1352
- ? "coding agent"
1353
- : "agent"
1354
- : status === "done" || status === "fail" || status === "stopped"
1355
- ? status
1356
- : options.url
1357
- ? hostOf(options.url)
1358
- : headed
1359
- ? "headed"
1360
- : ""
1361
- 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
+ : ""
1362
1405
  const footerVersion = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
1363
- // 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.
1364
1408
  const sessionTokens = meterSince(sessionMeter.current).total
1365
1409
  const usedTokens = sessionTokens.input + sessionTokens.output
1366
- 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(" · ")
1367
1412
  // Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
1368
1413
  const suggTrigger = triggerAt(promptText)
1369
1414
  const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
@@ -1429,12 +1474,15 @@ export function App({ options }: { options: TuiOptions }) {
1429
1474
  </box>
1430
1475
  )
1431
1476
 
1432
- 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) => {
1433
1479
  if (refining && !dialog) {
1434
1480
  return (
1435
1481
  <RefinePanel
1436
1482
  state={refining}
1437
1483
  width={promptWidth}
1484
+ maxHeight={maxHeight}
1485
+ scrollRef={refineScroll}
1438
1486
  onChoose={chooseRefine}
1439
1487
  onHover={(i) => refining.phase === "done" && setRefine({ ...refining, choice: i })}
1440
1488
  />
@@ -1454,6 +1502,7 @@ export function App({ options }: { options: TuiOptions }) {
1454
1502
  selected={Math.min(choice, spec.options.length - 1)}
1455
1503
  onChoose={choose}
1456
1504
  onHover={setChoice}
1505
+ maxHeight={maxHeight}
1457
1506
  />
1458
1507
  )
1459
1508
  }
@@ -1507,11 +1556,6 @@ export function App({ options }: { options: TuiOptions }) {
1507
1556
  placeholder={entry ? `${entry.label}${entry.secret ? " (hidden as you type)" : ""}${entry.initial ? ` · Enter keeps ${entry.initial}` : ""} · esc cancels` : placeholder}
1508
1557
  masked={entry?.secret ? secretLength : undefined}
1509
1558
  modeColor={modeColor}
1510
- profile={profile}
1511
- model={model}
1512
- right={right}
1513
- rightColor={rightColor}
1514
- compact={!home}
1515
1559
  focused={!palette && !refining && (panelsOpen ? nav.focus === "chat" : wide || narrowView === "chat")}
1516
1560
  initialValue={draft}
1517
1561
  onText={(t) => {
@@ -1541,7 +1585,7 @@ export function App({ options }: { options: TuiOptions }) {
1541
1585
  let signIns: Record<string, string> = {}
1542
1586
  try {
1543
1587
  // "Azure DevOps (PAT)": how each system's server signs in, as the session panel says it.
1544
- signIns = Object.fromEntries(externalMcp(cfgHome).servers.filter((x) => x.signIn).map((x) => [x.label.replace(/ MCP$/, ""), x.signIn!.startsWith("PAT") ? "PAT" : "az login"]))
1588
+ signIns = Object.fromEntries(externalMcp(cfgHome, Bun.which, process.env, { cwd: process.cwd() }).servers.filter((x) => x.signIn).map((x) => [x.label.replace(/ MCP$/, ""), x.signIn!.startsWith("PAT") ? "PAT" : x.signIn!.startsWith("Azure") ? "az login" : x.signIn!]))
1545
1589
  } catch {}
1546
1590
  setupRef.current = {
1547
1591
  key: setupKey,
@@ -1549,7 +1593,7 @@ export function App({ options }: { options: TuiOptions }) {
1549
1593
  start: startActions(cfgHome, 4, undefined, recent.some((r) => r.kind === "run")),
1550
1594
  recent,
1551
1595
  first: getStarted(cfgHome, items),
1552
- facts: projectLine(cfgHome, process.cwd(), { model: modelLabel(cfgHome), signIns }),
1596
+ facts: projectLine(cfgHome, process.cwd(), { signIns }),
1553
1597
  }
1554
1598
  }
1555
1599
  const { items: setup, start, recent, first, facts } = setupRef.current
@@ -1569,10 +1613,12 @@ export function App({ options }: { options: TuiOptions }) {
1569
1613
  const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
1570
1614
  const recentRows = recent.length ? 1 + 1 + recent.length : 0
1571
1615
  const setupRows = loading || open.length ? 2 : 0
1572
- 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
1573
1617
  const room = height - fixed
1574
1618
  const want = Math.max(5, Math.min(promptLines, room - 1))
1575
- 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
1576
1622
  const showStart = startRows > 0 && left >= startRows
1577
1623
  if (showStart) left -= startRows
1578
1624
  const showRecent = recentRows > 0 && left >= recentRows
@@ -1602,7 +1648,7 @@ export function App({ options }: { options: TuiOptions }) {
1602
1648
  </box>
1603
1649
  <box style={{ height: 1, flexShrink: 0 }} />
1604
1650
  <box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0 }}>
1605
- {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)}
1606
1652
  {showStart ? (
1607
1653
  <box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
1608
1654
  {heading(offerTitle)}
@@ -1685,18 +1731,10 @@ export function App({ options }: { options: TuiOptions }) {
1685
1731
  let mcp: string[] = []
1686
1732
  try {
1687
1733
  // "Azure DevOps (az login)": which account a server works as decides what it may see, so the panel says it.
1688
- mcp = externalMcp(cfgNow).servers.map((x) => (x.signIn ? `${x.label.replace(/ MCP$/, "")} (${x.signIn.startsWith("PAT") ? "PAT" : "az login"})` : x.label))
1734
+ mcp = externalMcp(cfgNow, Bun.which, process.env, { cwd: process.cwd() }).servers.map((x) => (x.signIn ? `${x.label.replace(/ MCP$/, "")} (${x.signIn.startsWith("PAT") ? "PAT" : x.signIn.startsWith("Azure") ? "az login" : x.signIn})` : x.label))
1689
1735
  } catch {}
1690
1736
  contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
1691
1737
  }
1692
- const llm = cfgNow?.llm
1693
- const sessionInfo: SessionInfo = {
1694
- model: cfgNow ? `${llm?.provider === "mock" ? "offline demo" : llm?.deployment || llm?.model || "none"}${profile ? ` (${profile})` : ""}` : "…",
1695
- cwd: shortCwd(),
1696
- ...(cfgNow?.domain ? { domain: `${cfgNow.domain.industry}${cfgNow.domain.regulations.length ? ` (${cfgNow.domain.regulations.join(", ")})` : ""}` } : {}),
1697
- started: sessionStarted.current,
1698
- requests: requests.current,
1699
- }
1700
1738
  const contextPanel: ContextInfo = {
1701
1739
  tokens: insp.context,
1702
1740
  limit: CONTEXT_LIMIT,
@@ -1715,22 +1753,38 @@ export function App({ options }: { options: TuiOptions }) {
1715
1753
  const activityLines = lines.filter((l) => l.pane === "activity")
1716
1754
  const stop = () => openDialog("stop")
1717
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
+
1718
1773
  /**
1719
- * The right pane: the status bar, then bordered panels for the plan, the activity, and files, context, and tokens.
1720
- * 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.
1721
1776
  */
1722
- const details = (paneWidth: number, rowsAvailable: number) => {
1723
- const bottom = 6 + 2 // the context panel's six rows, and the border
1724
- const plan = planRows(insp, Math.max(3, Math.min(8, Math.floor(rowsAvailable / 5))))
1725
- const activityRows = Math.max(5, rowsAvailable - 2 - plan - bottom)
1726
- 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)
1727
1781
  const focusOn = (f: Focus) => () => setNav((n) => ({ ...n, focus: f }))
1728
- 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
1729
1783
  return (
1730
1784
  <box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
1731
1785
  <StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress || planProgress(insp)} actions={paneButtons} />
1732
1786
  <box style={{ height: 1, flexShrink: 0 }} />
1733
- {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}
1734
1788
  <ActivityPanel
1735
1789
  lines={activityLines}
1736
1790
  scrollRef={activityScroll}
@@ -1740,17 +1794,16 @@ export function App({ options }: { options: TuiOptions }) {
1740
1794
  focused={nav.focus === "activity"}
1741
1795
  onFocus={focusOn("activity")}
1742
1796
  onFilter={(filter) => setNav((n) => ({ ...n, filter, focus: "activity" }))}
1797
+ now={running ? where : undefined}
1743
1798
  />
1744
- <box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
1745
- <FilesPanel s={insp} width={third} height={bottom} selected={sel("files")} onFocus={focusOn("files")} onOpen={openFile} />
1746
- <ContextPanel c={contextPanel} width={third} height={bottom} />
1747
- <TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
1748
- </box>
1749
1799
  </box>
1750
1800
  )
1751
1801
  }
1752
1802
 
1753
- 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 (
1754
1807
  <scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
1755
1808
  {all.map((line, i) => (
1756
1809
  <TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
@@ -1761,28 +1814,31 @@ export function App({ options }: { options: TuiOptions }) {
1761
1814
  <Spinner kind="dots" color={theme.memory} />
1762
1815
  <text>
1763
1816
  <span fg={theme.memory}>{` ${activity || "Working…"}`}</span>
1764
- <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>
1765
1818
  </text>
1766
1819
  </box>
1767
1820
  ) : null}
1768
1821
  </scrollbox>
1769
- )
1822
+ )
1823
+ }
1770
1824
  // A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
1771
- 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))
1772
1827
 
1773
1828
  const split = panelsOpen
1774
1829
  if (split) {
1775
- 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)
1776
1832
  return (
1777
1833
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1778
1834
  <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1779
1835
  <box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1780
- <SessionPanel info={sessionInfo} width={leftWidth - 3} />
1836
+ {infoPanels(leftWidth - 3)}
1781
1837
  {transcript(chatLines, {})}
1782
1838
  <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1783
1839
  </box>
1784
1840
  <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1785
- {details(width - leftWidth - 3, height - 2)}
1841
+ {activityColumn(width - leftWidth - 3, height - 2)}
1786
1842
  </box>
1787
1843
  </box>
1788
1844
  <Footer left={shortCwd()} right={footerRight} width={width} />
@@ -1795,7 +1851,10 @@ export function App({ options }: { options: TuiOptions }) {
1795
1851
  if (!wide && narrowView === "details") {
1796
1852
  return (
1797
1853
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1798
- <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>
1799
1858
  <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
1800
1859
  {dialog ? (
1801
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>