@capagents/forgeqe 1.0.0 → 1.0.2

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
@@ -28,6 +28,29 @@ import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLi
28
28
  import { Dialog, dialogSpec } from "./dialogs.ts"
29
29
  import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
30
30
  import { featuresText } from "../capabilities/render.ts"
31
+ import { CAPABILITIES } from "../capabilities/registry.ts"
32
+ import { CONTEXT_LIMIT } from "../code/runtime.ts"
33
+ import { readKnowledge } from "../knowledge.ts"
34
+ import { externalMcp } from "../mcp/external.ts"
35
+ import { meterSince } from "../usage.ts"
36
+ import { ActivityLog, ContextPanel, FilesPanel, PlanPanel, SessionBlock, TokensPanel, WorkHeader, type ContextInfo, type SessionInfo } from "./inspector.tsx"
37
+ import {
38
+ addSkill,
39
+ caseDone,
40
+ caseStarted,
41
+ newInspector,
42
+ newRequest,
43
+ paneFor,
44
+ progressDone,
45
+ setContext,
46
+ setPlan,
47
+ setWho,
48
+ startProgress,
49
+ stepOutcome,
50
+ stepStarted,
51
+ useFile,
52
+ type Pane,
53
+ } from "./session.ts"
31
54
 
32
55
  export type TuiOptions = {
33
56
  configPath?: string
@@ -54,11 +77,14 @@ export type TuiOptions = {
54
77
  }
55
78
 
56
79
  const VERSION = packageVersion()
80
+ /** Narrower than this, the split view's right pane would not fit: one pane, and ctrl+o switches. */
81
+ export const SPLIT_MIN = 120
57
82
 
58
83
  export function App({ options }: { options: TuiOptions }) {
59
84
  const renderer = useRenderer()
60
85
  const { width, height } = useTerminalDimensions()
61
86
  const scrollRef = useRef<ScrollBoxRenderable>(null)
87
+ const activityScroll = useRef<ScrollBoxRenderable>(null)
62
88
  const submitRef = useRef<(value: string) => void>(() => {})
63
89
  const configRef = useRef<AppConfig | null>(null)
64
90
  const abortRef = useRef<AbortController | null>(null)
@@ -79,6 +105,19 @@ export function App({ options }: { options: TuiOptions }) {
79
105
  const testsRef = useRef<TestsSetting>({ on: false, why: "" })
80
106
  const codingRef = useRef(false)
81
107
  const codeRunDir = useRef("")
108
+ /** The right pane's live state: plan, steps or cases, files, context. */
109
+ const inspRef = useRef(newInspector())
110
+ /** Set while a typed line is handled, so the shell's answers to it go to the chat. */
111
+ const answeringRef = useRef(false)
112
+ const [, setRev] = useState(0)
113
+ const bump = () => setRev((n) => n + 1)
114
+ /** Usage since the shell opened, for the session's token and cost totals. */
115
+ const sessionMeter = useRef(meterSnapshot())
116
+ const sessionStarted = useRef<number | undefined>(undefined)
117
+ const requests = useRef(0)
118
+ /** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
119
+ const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
120
+ const contextInfo = useRef<{ key: string; knowledge: number; mcp: string[] }>({ key: "", knowledge: 0, mcp: [] })
82
121
 
83
122
  const [lines, setLines] = useState<Line[]>([])
84
123
  const [headed, setHeaded] = useState(Boolean(options.headed))
@@ -123,15 +162,29 @@ export function App({ options }: { options: TuiOptions }) {
123
162
  choiceRef.current = choice
124
163
  thinkingRef.current = thinking
125
164
 
126
- const push = (kind: Line["kind"], text: string) => {
165
+ /** Lines go to the chat, or while a request runs and they are not results, to the activity pane; `pane` overrides that. */
166
+ const push = (kind: Line["kind"], text: string, pane?: Pane) => {
127
167
  const parts = kind === "user" || kind === "reply" ? [text] : text.split("\n")
168
+ const where = pane || (answeringRef.current ? "chat" : paneFor(kind, abortRef.current !== null))
128
169
  setLines((prev) => {
129
170
  const next = [...prev]
130
- for (const part of parts) next.push({ id: idRef.current++, kind, text: part })
131
- return next.slice(-300)
171
+ for (const part of parts) next.push({ id: idRef.current++, kind, text: part, pane: where })
172
+ return next.slice(-600)
132
173
  })
133
174
  }
134
- const pushLine = (line: Omit<Line, "id">) => setLines((prev) => [...prev, { ...line, id: idRef.current++ }].slice(-300))
175
+ const pushLine = (line: Omit<Line, "id">) =>
176
+ setLines((prev) => [...prev, { ...line, pane: line.pane || paneFor(line.kind, abortRef.current !== null), id: idRef.current++ }].slice(-600))
177
+ /** What an agent's event tells the right pane: a file it used, a skill it loaded, its plan, how full its context is. */
178
+ const inspect = (e: CodeEvent) => {
179
+ const s = inspRef.current
180
+ if (e.kind === "tool") {
181
+ if (e.file) useFile(s, e.file)
182
+ if (e.skill) addSkill(s, e.skill)
183
+ } else if (e.kind === "plan") setPlan(s, e.items)
184
+ else if (e.kind === "usage") setContext(s, e.context || 0)
185
+ else return
186
+ bump()
187
+ }
135
188
  /** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
136
189
  const pushTool = (text: string, ok: boolean) => push(!ok ? "err" : /^(Writing|Editing) /.test(text) ? "file" : "tool", text)
137
190
  const pushProduced = () => {
@@ -374,7 +427,8 @@ export function App({ options }: { options: TuiOptions }) {
374
427
  return
375
428
  }
376
429
  if (key.name === "tab" && !key.shift) cycleProfile()
377
- if (key.name === "p" && key.ctrl) push("sys", HELP)
430
+ if (key.name === "p" && key.ctrl) push("sys", HELP, "chat")
431
+ if (key.name === "o" && key.ctrl && width < SPLIT_MIN) setNarrowView((v) => (v === "chat" ? "details" : "chat"))
378
432
  if (key.name === "escape" && abortRef.current) openDialog("stop")
379
433
  })
380
434
 
@@ -396,6 +450,9 @@ export function App({ options }: { options: TuiOptions }) {
396
450
  replayingRef.current = false
397
451
  setStep(0)
398
452
  setBrowsing(true)
453
+ startProgress(inspRef.current, `Browser · ${clip(e.goal, 40)}`)
454
+ setWho(inspRef.current, "Browser agent")
455
+ bump()
399
456
  if (agent) push("code", `Browser: ${clip(e.goal, 110)}`)
400
457
  if (!aborted()) setActivity(cfg?.llm.provider === "mock" ? "Running the offline demo…" : "Starting the browser…")
401
458
  return
@@ -417,6 +474,10 @@ export function App({ options }: { options: TuiOptions }) {
417
474
  if (shows(event, detailRef.current)) push(event.level === "error" ? "err" : "sys", event.text)
418
475
  if (!aborted()) setActivity(activityFromLog(event.text))
419
476
  }
477
+ if (event.type === "thought") {
478
+ stepStarted(inspRef.current, `Step ${event.step} · ${clip(event.text.replace(/\s+/g, " "), 90)}${replayingRef.current ? " (memory)" : ""}`)
479
+ bump()
480
+ }
420
481
  if (event.type === "thought" && !replayingRef.current) {
421
482
  if (shows(event, detailRef.current)) push("thought", stepIntent(event.step, event.text, detailRef.current))
422
483
  if (!aborted()) setActivity(`Acting in the browser (step ${stepRef.current})…`)
@@ -425,7 +486,11 @@ export function App({ options }: { options: TuiOptions }) {
425
486
  markRef.current = Date.now()
426
487
  if (!aborted()) setActivity(replayingRef.current ? "Replaying from memory…" : `Thinking about step ${stepRef.current + 1}…`)
427
488
  }
428
- if (event.type === "outcome") push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
489
+ if (event.type === "outcome") {
490
+ push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
491
+ stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(event.text, 80))
492
+ bump()
493
+ }
429
494
  return
430
495
  }
431
496
  case "run-done": {
@@ -450,17 +515,25 @@ export function App({ options }: { options: TuiOptions }) {
450
515
  producedRef.current.runs.push({ runDir: e.runDir, status: r?.status || "fail", files: e.files })
451
516
  setLastStats(summary.stats)
452
517
  setBrowsing(false)
518
+ progressDone(inspRef.current, aborted())
519
+ setWho(inspRef.current, codingRef.current ? "Scribe" : "ForgeQE's agent")
520
+ bump()
453
521
  if (agent && !aborted()) setActivity("The agent is reading the result…")
454
522
  return
455
523
  }
456
524
  case "suite-start":
457
525
  push("sys", `${e.cases.length} test case${e.cases.length === 1 ? "" : "s"}${e.parallel > 1 ? `, ${e.parallel} at a time` : ""}`)
458
526
  setSuiteProgress({ done: 0, total: e.cases.length })
527
+ startProgress(inspRef.current, `${e.cases.length} test case${e.cases.length === 1 ? "" : "s"}`)
528
+ bump()
459
529
  return
460
530
  case "suite-event": {
461
531
  const s = e.event
462
532
  const tag = (id: string) => ((configRef.current?.suite.parallel || 1) > 1 ? `[${id}] ` : "")
463
533
  if (s.type === "case-start") {
534
+ caseStarted(inspRef.current, s.case.id, s.case.title)
535
+ setWho(inspRef.current, "Browser agent")
536
+ bump()
464
537
  push("code", `${s.index + 1}/${s.total} ${s.case.id} ${s.case.title}`)
465
538
  // What was read from the test management system: where the case lives and the steps the browser will follow.
466
539
  const steps = s.case.steps
@@ -476,6 +549,8 @@ export function App({ options }: { options: TuiOptions }) {
476
549
  } else if (s.type === "case-done") {
477
550
  setSuiteProgress((p) => (p ? { ...p, done: p.done + 1 } : p))
478
551
  const r = s.result
552
+ caseDone(inspRef.current, r.case.id, r.status, r.failure ? FAILURE_LABEL[r.failure.type] : undefined)
553
+ bump()
479
554
  push(r.status === "passed" ? "ok" : "err", `${r.case.id} ${r.status} in ${formatDuration(r.ms)}${r.failure ? ` · ${FAILURE_LABEL[r.failure.type]}` : ""}`)
480
555
  if (r.failure) push("sys", ` ${tag(r.case.id)}${clip(r.failure.reason, 200)} Next: ${FAILURE_ACTION[r.failure.type]}.`)
481
556
  // Each step of the case with its verdict, and what the page showed for a step that did not pass.
@@ -508,6 +583,8 @@ export function App({ options }: { options: TuiOptions }) {
508
583
  return
509
584
  case "agent": {
510
585
  const ev = e.event
586
+ inspect(ev)
587
+ if (ev.kind === "plan") return
511
588
  if (ev.kind === "usage") useTokens(`agent-tool-${runIndex.current}`, ev.usage.input + ev.usage.output)
512
589
  // Hidden lines (reads, searches, the agent's narration) still show on the activity line below.
513
590
  else if (shows(ev, detailRef.current)) {
@@ -527,6 +604,8 @@ export function App({ options }: { options: TuiOptions }) {
527
604
  codingRef.current = true
528
605
  codeRunDir.current = e.runDir
529
606
  setCoding(true)
607
+ setWho(inspRef.current, "Scribe")
608
+ bump()
530
609
  const show = shortPath
531
610
  pushLine({
532
611
  kind: "card",
@@ -550,6 +629,8 @@ export function App({ options }: { options: TuiOptions }) {
550
629
  case "code-done":
551
630
  codingRef.current = false
552
631
  setCoding(false)
632
+ setWho(inspRef.current, "ForgeQE's agent")
633
+ bump()
553
634
  producedRef.current.tests.push({ runDir: codeRunDir.current, result: e.result, error: e.error })
554
635
  if (e.error) push("err", `Coding agent: ${e.error}`)
555
636
  if (agent && !aborted()) setActivity("The agent is reading the result…")
@@ -561,6 +642,8 @@ export function App({ options }: { options: TuiOptions }) {
561
642
  }
562
643
 
563
644
  const onBrainEvent = (e: CodeEvent) => {
645
+ inspect(e)
646
+ if (e.kind === "plan") return
564
647
  if (e.kind === "usage") {
565
648
  useTokens("agent", e.usage.input + e.usage.output)
566
649
  return
@@ -600,6 +683,10 @@ export function App({ options }: { options: TuiOptions }) {
600
683
  started.current = Date.now()
601
684
  usedRef.current = new Map()
602
685
  producedRef.current = emptyProduced()
686
+ newRequest(inspRef.current)
687
+ requests.current++
688
+ sessionStarted.current ??= Date.now()
689
+ bump()
603
690
  setTokens(0)
604
691
  setStatus("running")
605
692
  setActivity(activityText)
@@ -607,6 +694,8 @@ export function App({ options }: { options: TuiOptions }) {
607
694
  }
608
695
 
609
696
  const finish = (finalStatus: string) => {
697
+ progressDone(inspRef.current, finalStatus === "stopped")
698
+ bump()
610
699
  setStatus(finalStatus)
611
700
  setActivity("")
612
701
  setThinking(false)
@@ -671,21 +760,21 @@ export function App({ options }: { options: TuiOptions }) {
671
760
  controller.signal.removeEventListener("abort", onAbort)
672
761
  if (answer.outcome !== "stopped") convoRef.current.push({ user: text, reply: answer.reply })
673
762
  if (answer.outcome === "stopped") {
674
- push("sys", "Stopped.")
763
+ push("sys", "Stopped.", "chat")
675
764
  finalStatus = "stopped"
676
765
  } else {
677
766
  if (answer.reply) pushLine({ kind: "reply", text: answer.reply, outcome: answer.outcome })
678
- if (answer.error && !answer.reply) push("err", answer.error)
767
+ if (answer.error && !answer.reply) push("err", answer.error, "chat")
679
768
  finalStatus = answer.outcome === "done" ? "done" : answer.outcome === "needs_input" ? "idle" : "fail"
680
769
  }
681
770
  } catch (err) {
682
- push("err", (err as Error).message)
771
+ push("err", (err as Error).message, "chat")
683
772
  closeBrain()
684
773
  } finally {
685
774
  pushProduced()
686
775
  // What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
687
776
  const spent = requestLine(meterStart)
688
- if (spent) push("sys", spent)
777
+ if (spent) push("sys", spent, "chat")
689
778
  finish(finalStatus)
690
779
  }
691
780
  }
@@ -737,6 +826,15 @@ export function App({ options }: { options: TuiOptions }) {
737
826
  }, [])
738
827
 
739
828
  const onSubmit = (value: string) => {
829
+ // What a command answers (/help, /skills, "busy") is for the chat, even while a request runs.
830
+ answeringRef.current = true
831
+ try {
832
+ submit(value)
833
+ } finally {
834
+ answeringRef.current = false
835
+ }
836
+ }
837
+ const submit = (value: string) => {
740
838
  const text = value.replace(/\t/g, "").trim()
741
839
  setInputKey((k) => k + 1)
742
840
  const pending = entryRef.current
@@ -864,17 +962,24 @@ export function App({ options }: { options: TuiOptions }) {
864
962
 
865
963
  const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
866
964
  const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
965
+ // The split view's right pane shows tokens, time, and the last result, so the hints under the prompt leave them out.
966
+ const wide = width >= SPLIT_MIN
967
+ const viewHint: Hint[] = wide ? [] : [{ key: "ctrl+o", label: "details", onPress: () => setNarrowView("details") }]
968
+ // While running, the row also holds the spinner and esc stop: below 95 columns the view hint does not fit beside them.
969
+ const runningViewHint: Hint[] = width >= 95 ? viewHint : []
867
970
  const idleHints: Hint[] = [
868
971
  ...copiedHint,
869
- ...(lastStats ? [{ key: `last run ${lastStats}`, label: "" }] : []),
972
+ ...(lastStats && !wide ? [{ key: `last run ${lastStats}`, label: "" }] : []),
973
+ ...viewHint,
870
974
  { key: "tab", label: "profiles" },
871
975
  { key: "ctrl+p", label: "commands" },
872
976
  closeHint,
873
977
  ]
874
978
  const runningHints: Hint[] = [
875
979
  ...copiedHint,
876
- { key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" },
877
- { key: "ctrl+p", label: "commands" },
980
+ ...(wide ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" }, ...runningViewHint]),
981
+ // Beside the 40% pane's spinner and esc stop, only close fits from 120 columns.
982
+ ...(wide ? [] : [{ key: "ctrl+p", label: "commands" }]),
878
983
  closeHint,
879
984
  ]
880
985
  const runningLeft = (
@@ -920,6 +1025,7 @@ export function App({ options }: { options: TuiOptions }) {
920
1025
  model={model}
921
1026
  right={right}
922
1027
  rightColor={rightColor}
1028
+ compact={width >= SPLIT_MIN && !home}
923
1029
  />
924
1030
  <Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
925
1031
  </>
@@ -946,37 +1052,134 @@ export function App({ options }: { options: TuiOptions }) {
946
1052
  )
947
1053
  }
948
1054
 
1055
+ // What the split view shows about the session and the request in progress.
1056
+ const cfgNow = configRef.current
1057
+ const insp = inspRef.current
1058
+ const spent = meterSince(sessionMeter.current)
1059
+ const contextKey = cfgNow ? `${cfgNow.configPath}|${cfgNow.activeProfile}|${cfgNow.knowledge?.dir}` : ""
1060
+ if (cfgNow && contextInfo.current.key !== contextKey) {
1061
+ let mcp: string[] = []
1062
+ try {
1063
+ mcp = externalMcp(cfgNow).servers.map((x) => x.label)
1064
+ } catch {}
1065
+ contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
1066
+ }
1067
+ const llm = cfgNow?.llm
1068
+ const sessionInfo: SessionInfo = {
1069
+ profile,
1070
+ model: model || (cfgNow ? modelLabel(cfgNow) : ""),
1071
+ auth: !llm ? "–" : llm.auth === "entra" ? "Entra ID, no key" : llm.apiKeyEnv ? `API key in ${llm.apiKeyEnv}` : llm.provider === "mock" ? "offline demo" : llm.provider,
1072
+ cwd: shortCwd(),
1073
+ tests: testsRef.current.on ? "written to this folder" : requests.current ? `not written (${testsRef.current.why || "off"})` : "set on the first request",
1074
+ browser: `${headed ? "visible" : "headless"} · up to ${maxSteps} steps${options.url ? ` · ${hostOf(options.url)}` : ""}`,
1075
+ ...(cfgNow?.domain ? { domain: `${cfgNow.domain.industry}${cfgNow.domain.regulations.length ? ` (${cfgNow.domain.regulations.join(", ")})` : ""}` } : {}),
1076
+ started: sessionStarted.current,
1077
+ requests: requests.current,
1078
+ total: spent.roles.length ? { usage: spent.total, cost: spent.cost } : undefined,
1079
+ }
1080
+ const contextPanel: ContextInfo = {
1081
+ tokens: insp.context,
1082
+ limit: CONTEXT_LIMIT,
1083
+ skills: insp.skills,
1084
+ knowledge: contextInfo.current.knowledge,
1085
+ ...(cfgNow?.domain ? { domain: cfgNow.domain.industry } : {}),
1086
+ mcp: contextInfo.current.mcp,
1087
+ capabilities: CAPABILITIES.length,
1088
+ }
1089
+ const placeholder = running
1090
+ ? coding
1091
+ ? "The coding agent is writing a test · esc to stop"
1092
+ : `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
1093
+ : "Ask ForgeQE: a goal, test cases, a test, a pipeline…"
1094
+ const chatLines = lines.filter((l) => l.pane !== "activity")
1095
+ const activityLines = lines.filter((l) => l.pane === "activity")
1096
+ const stop = () => openDialog("stop")
1097
+
1098
+ /**
1099
+ * The right pane: who is working, the plan, the activity, and files, context, and tokens. Its rows are counted, not
1100
+ * left to flex: the activity log gets what the fixed parts leave, so the bottom panels are never pushed off screen.
1101
+ */
1102
+ const details = (paneWidth: number, rowsAvailable: number) => {
1103
+ const bottomRows = Math.max(6, Math.min(9, Math.floor(rowsAvailable / 4)))
1104
+ const planCap = Math.max(3, Math.min(10, Math.floor(rowsAvailable / 4)))
1105
+ const planLines = insp.plan.length + insp.progress.items.length + (insp.plan.length && insp.progress.items.length ? 1 : 0)
1106
+ const planHeight = 1 + Math.max(1, Math.min(planCap, planLines))
1107
+ // Header, a blank row, the plan, a blank row, the activity, a blank row, and the bottom panels with their title and legend.
1108
+ const activityRows = Math.max(4, rowsAvailable - 1 - 1 - planHeight - 1 - 1 - (bottomRows + 2))
1109
+ const third = Math.floor((paneWidth - 2) / 3)
1110
+ return (
1111
+ <box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
1112
+ <WorkHeader running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} />
1113
+ <box style={{ height: 1, flexShrink: 0 }} />
1114
+ <PlanPanel s={insp} width={paneWidth} rows={planCap} />
1115
+ <box style={{ height: 1, flexShrink: 0 }} />
1116
+ <ActivityLog lines={activityLines} scrollRef={activityScroll} height={activityRows} width={paneWidth} />
1117
+ <box style={{ flexDirection: "row", height: bottomRows + 2, flexShrink: 0, marginTop: 1 }}>
1118
+ <FilesPanel s={insp} width={third} rows={bottomRows - 1} />
1119
+ <ContextPanel c={contextPanel} width={third} />
1120
+ <TokensPanel roles={spent.roles} total={{ usage: spent.total, cost: spent.cost }} width={paneWidth - 2 * third} />
1121
+ </box>
1122
+ </box>
1123
+ )
1124
+ }
1125
+
1126
+ const transcript = (all: Line[], style: Record<string, unknown>) => (
1127
+ <scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
1128
+ {all.map((line) => (
1129
+ <TranscriptLine key={line.id} line={line} />
1130
+ ))}
1131
+ {running && !split ? (
1132
+ <box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
1133
+ <Spinner kind="dots" />
1134
+ <text fg={theme.text}>{` ${activity || "Working…"}`}</text>
1135
+ </box>
1136
+ ) : null}
1137
+ </scrollbox>
1138
+ )
1139
+ const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.min(10, Math.floor(height / 3)))])
1140
+
1141
+ const split = width >= SPLIT_MIN
1142
+ if (split) {
1143
+ const leftWidth = Math.floor(width * 0.4)
1144
+ const rightWidth = width - leftWidth - 3
1145
+ return (
1146
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
1147
+ <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1148
+ <box style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1149
+ <SessionBlock info={sessionInfo} width={leftWidth - 3} />
1150
+ {transcript(chatLines, {})}
1151
+ <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1152
+ </box>
1153
+ <box border={["left"]} borderColor={theme.button} style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1154
+ {details(rightWidth - 2, height - 2)}
1155
+ </box>
1156
+ </box>
1157
+ <Footer left={shortCwd()} right={footerRight} width={width} />
1158
+ </box>
1159
+ )
1160
+ }
1161
+
949
1162
  const promptWidth = Math.max(40, width - 4)
1163
+ if (narrowView === "details") {
1164
+ return (
1165
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
1166
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>{details(promptWidth, height - 3 - (dialog ? 8 : 0))}</box>
1167
+ <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
1168
+ {dialog ? (
1169
+ // Stop, a question, or "your turn": the same panel the chat view shows in place of the prompt.
1170
+ controls(promptWidth)
1171
+ ) : (
1172
+ <Hints width={promptWidth} items={[{ key: "ctrl+o", label: "back to the chat", onPress: () => setNarrowView("chat") }, closeHint]} />
1173
+ )}
1174
+ </box>
1175
+ <Footer left={shortCwd()} right={footerRight} width={width} />
1176
+ </box>
1177
+ )
1178
+ }
950
1179
  return (
951
1180
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
952
- <scrollbox
953
- ref={scrollRef}
954
- stickyScroll
955
- stickyStart="bottom"
956
- style={{ flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}
957
- focused={false}
958
- >
959
- {lines.map((line) => (
960
- <TranscriptLine key={line.id} line={line} />
961
- ))}
962
- {running ? (
963
- <box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
964
- <Spinner kind="dots" />
965
- <text fg={theme.text}>{` ${activity || "Working…"}`}</text>
966
- </box>
967
- ) : null}
968
- </scrollbox>
969
- <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
970
- {renderControls(
971
- promptWidth,
972
- running
973
- ? coding
974
- ? "The coding agent is writing a test · esc to stop"
975
- : `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
976
- : "Ask ForgeQE: a goal, test cases, a test, a pipeline…",
977
- [3, Math.max(3, Math.min(10, Math.floor(height / 3)))],
978
- )}
979
- </box>
1181
+ {transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 })}
1182
+ <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>{controls(promptWidth)}</box>
980
1183
  <Footer left={shortCwd()} right={footerRight} width={width} />
981
1184
  </box>
982
1185
  )
@@ -48,6 +48,8 @@ export type PromptProps = {
48
48
  rightColor?: string
49
49
  /** A secret being entered: the box shows one dot per character instead of a text field, so the value is never drawn. */
50
50
  masked?: number
51
+ /** Narrow pane beside a session panel that names the model and profile: the bottom row shows only `right`. */
52
+ compact?: boolean
51
53
  }
52
54
 
53
55
  /** Enter sends; Shift+Enter, Alt+Enter or Ctrl+J start a new line. */
@@ -106,16 +108,18 @@ export function Prompt(props: PromptProps) {
106
108
  placeholderColor={theme.faint}
107
109
  />
108
110
  )}
109
- <box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
110
- <text>
111
- <span fg={theme.muted}>{"Model "}</span>
112
- <span fg={theme.text}>{props.model || "…"}</span>
113
- </text>
111
+ <box style={{ flexDirection: "row", justifyContent: props.compact ? "flex-end" : "space-between", marginTop: 1 }}>
112
+ {props.compact ? null : (
113
+ <text>
114
+ <span fg={theme.muted}>{"Model "}</span>
115
+ <span fg={theme.text}>{props.model || "…"}</span>
116
+ </text>
117
+ )}
114
118
  <text>
115
119
  {props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
116
- {props.right ? <span fg={theme.faint}>{" · "}</span> : null}
117
- <span fg={theme.muted}>{"profile "}</span>
118
- <span fg={theme.text}>{props.profile || "none"}</span>
120
+ {props.right && !props.compact ? <span fg={theme.faint}>{" · "}</span> : null}
121
+ {props.compact ? null : <span fg={theme.muted}>{"profile "}</span>}
122
+ {props.compact ? null : <span fg={theme.text}>{props.profile || "none"}</span>}
119
123
  </text>
120
124
  </box>
121
125
  </box>