@capagents/forgeqe 1.0.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui/App.tsx CHANGED
@@ -26,8 +26,9 @@ import { meterSnapshot, requestLine } from "../usage.ts"
26
26
  import { cleanSelection, copyText } from "./clipboard.ts"
27
27
  import { ButtonBar, ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Button, type Hint } from "./components.tsx"
28
28
  import { setupChecklist, type SetupItem } from "./setup.ts"
29
+ import { initWorkspace } from "../init.ts"
29
30
  import { ShortcutsPanel } from "./shortcuts.tsx"
30
- import { ago, projectLine, setupSentence, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
31
+ import { ago, getStarted, projectLine, setupSentence, type Fact, type GetStarted, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
31
32
  import { TIPS, formatDuration, formatTokens, packageVersion, shortCwd, theme } from "./theme.ts"
32
33
  import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
33
34
  import { Dialog, dialogSpec } from "./dialogs.ts"
@@ -35,7 +36,7 @@ import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
35
36
  import { featuresText } from "../capabilities/render.ts"
36
37
  import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
37
38
  import { commandSuggestions, complete, lookupWorkItem, recentRuns, referenceSuggestions, repoFiles, triggerAt, type Suggestion, type WorkItem } from "./suggest.ts"
38
- import { REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
39
+ import { initialChoice, REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
39
40
  import { refine } from "../refine.ts"
40
41
  import { nextSteps, type NextStep } from "./next.ts"
41
42
  import { CAPABILITIES } from "../capabilities/registry.ts"
@@ -178,8 +179,8 @@ export function App({ options }: { options: TuiOptions }) {
178
179
  /** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
179
180
  const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
180
181
  // The split view's chat at the full width, for reading a long answer; the panels come back with Split view.
181
- const [chatWide, setChatWide] = useState(false)
182
- const setupRef = useRef<{ key: string; items: SetupItem[]; start: StartAction[]; recent: RecentItem[] }>({ key: "", items: [], start: [], recent: [] })
182
+ const [chatWide, setChatWide] = useState(true)
183
+ const setupRef = useRef<{ key: string; items: SetupItem[]; start: StartAction[]; recent: RecentItem[]; first: GetStarted[]; facts: Fact[] }>({ key: "", items: [], start: [], recent: [], first: [], facts: [] })
183
184
  // Which panel the keys go to (shift+tab moves on), the row selected in the plan and files, and the activity filter.
184
185
  const [nav, setNavState] = useState<{ focus: Focus; plan: number; files: number; filter: ActivityFilter }>({ focus: "chat", plan: 0, files: 0, filter: "all" })
185
186
  const navRef = useRef(nav)
@@ -536,7 +537,7 @@ export function App({ options }: { options: TuiOptions }) {
536
537
  void (options.refine || refine)({ config: cfg, cwd: sessionSetup(cfg, options.repo).cwd, text, recent: convoRef.current, signal: controller.signal })
537
538
  .then((result) => {
538
539
  if (controller.signal.aborted || refiningRef.current?.phase !== "working") return
539
- setRefine({ phase: "done", original: text, result, choice: 0 })
540
+ setRefine({ phase: "done", original: text, result, choice: initialChoice(result.missing) })
540
541
  })
541
542
  .catch((err) => {
542
543
  if (controller.signal.aborted) return
@@ -668,7 +669,8 @@ export function App({ options }: { options: TuiOptions }) {
668
669
  // The panels: shift+tab moves focus through those on screen; a focused panel takes the arrows, enter, and esc.
669
670
  const panes = (width >= SPLIT_MIN && !chatWide) || (width < SPLIT_MIN && narrowView === "details")
670
671
  const insp = inspRef.current
671
- const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && width >= SPLIT_MIN)
672
+ const panelsOpen = width >= SPLIT_MIN && !chatWide
673
+ const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && panelsOpen)
672
674
  if (panes && key.name === "tab" && key.shift && !entryRef.current) {
673
675
  // Consumed here: the prompt can take focus within this key press and must not also act on it.
674
676
  key.preventDefault()
@@ -682,7 +684,7 @@ export function App({ options }: { options: TuiOptions }) {
682
684
  const list = at === "plan" ? planLines(insp).length : at === "files" ? filesByUse(insp).length : 0
683
685
  const move = (d: number) => setNav((n) => ({ ...n, [at]: Math.max(0, Math.min(list - 1, (n[at as "plan" | "files"] ?? 0) + d)) }))
684
686
  const page = Math.max(3, Math.floor(height / 3))
685
- if (key.name === "escape") return setNav((n) => ({ ...n, focus: width >= SPLIT_MIN ? "chat" : "activity" }))
687
+ if (key.name === "escape") return setNav((n) => ({ ...n, focus: panelsOpen ? "chat" : "activity" }))
686
688
  if (at === "activity") {
687
689
  const box = activityScroll.current
688
690
  if (key.name === "up") return box?.scrollBy(-1)
@@ -710,7 +712,7 @@ export function App({ options }: { options: TuiOptions }) {
710
712
  }
711
713
  }
712
714
  // Anything typed goes back to the chat, where the prompt is.
713
- if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && width >= SPLIT_MIN) return setNav((n) => ({ ...n, focus: "chat" }))
715
+ if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && panelsOpen) return setNav((n) => ({ ...n, focus: "chat" }))
714
716
  if (!key.ctrl && !key.meta) return
715
717
  }
716
718
  // ? on an empty prompt: every key in one panel; esc or ? closes it.
@@ -726,8 +728,11 @@ export function App({ options }: { options: TuiOptions }) {
726
728
  }
727
729
  // The start screen's actions: alt+1 to alt+4 put one in the prompt.
728
730
  if (key.meta && /^[1-4]$/.test(key.name || "") && linesRef.current === 0 && !abortRef.current) {
729
- const a = setupRef.current.start[Number(key.name) - 1]
731
+ const n = Number(key.name) - 1
732
+ const a = setupRef.current.start[n]
733
+ const g = setupRef.current.first[n]
730
734
  if (a) fillPrompt(a.request)
735
+ else if (!setupRef.current.start.length && g) g.command ? submitRef.current(g.command) : g.fix && push("sys", g.fix, "chat")
731
736
  return
732
737
  }
733
738
  if (suggRef.current.list.length && suggRef.current.trigger && !entryRef.current) {
@@ -748,7 +753,11 @@ export function App({ options }: { options: TuiOptions }) {
748
753
  return
749
754
  }
750
755
  if (key.name === "r" && key.ctrl && !entryRef.current) startRefine()
751
- if (key.name === "o" && key.ctrl && width < SPLIT_MIN) setNarrowView((v) => (v === "chat" ? "details" : "chat"))
756
+ // ctrl+o: the panels (plan, activity, files, context, tokens) beside the chat on a wide terminal, in its place on a narrow one.
757
+ if (key.name === "o" && key.ctrl) {
758
+ if (width >= SPLIT_MIN) setChatWide((w) => !w)
759
+ else setNarrowView((v) => (v === "chat" ? "details" : "chat"))
760
+ }
752
761
  if (key.name === "escape" && abortRef.current) openDialog("stop")
753
762
  })
754
763
 
@@ -1104,7 +1113,9 @@ export function App({ options }: { options: TuiOptions }) {
1104
1113
  pushProduced()
1105
1114
  // What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
1106
1115
  // The split view's session and tokens panels show this; only the one-pane view needs it in the chat.
1107
- const spent = widthRef.current < SPLIT_MIN ? requestLine(meterStart) : ""
1116
+ // The footer and the tokens panel show what a request used; the conversation stays about the work.
1117
+ const spent = ""
1118
+ void meterStart
1108
1119
  if (spent) push("sys", spent, "chat")
1109
1120
  finish(finalStatus, followUps)
1110
1121
  }
@@ -1129,6 +1140,19 @@ export function App({ options }: { options: TuiOptions }) {
1129
1140
  else void askAgent(text, cfg)
1130
1141
  }
1131
1142
 
1143
+ /** Reads forgeqe.yaml and llms.json again (after /init wrote them), keeping the session going. */
1144
+ const reloadConfig = () => {
1145
+ let cfg = loadConfig({ configPath: options.configPath, profile: options.profile })
1146
+ if (options.profile) cfg = selectProfile(cfg, options.profile)
1147
+ configRef.current = cfg
1148
+ setProfile(cfg.activeProfile)
1149
+ setModel(modelLabel(cfg))
1150
+ setupRef.current.key = ""
1151
+ contextInfo.current.key = ""
1152
+ closeBrain()
1153
+ bump()
1154
+ }
1155
+
1132
1156
  useEffect(() => {
1133
1157
  if (booted.current) return
1134
1158
  booted.current = true
@@ -1198,7 +1222,8 @@ export function App({ options }: { options: TuiOptions }) {
1198
1222
  if (!text) return
1199
1223
  // Setup notes come before the request, so they do not read as its answer; the split view shows them in its session panel.
1200
1224
  const notes = priceNotesRef.current.splice(0)
1201
- if (width < SPLIT_MIN) for (const w of notes) push("sys", `Price check: ${w}`, "chat")
1225
+ // Price notes stay out of the conversation: forgeqe models and the reports say what a run cost.
1226
+ void notes
1202
1227
  push("user", text)
1203
1228
  const cmd = parseCommand(text)
1204
1229
  if (cmd.type === "help") {
@@ -1240,6 +1265,17 @@ export function App({ options }: { options: TuiOptions }) {
1240
1265
  }
1241
1266
  return
1242
1267
  }
1268
+ if (cmd.type === "init") {
1269
+ // forgeqe init in this folder, then the session reads what it wrote: the start screen updates at once.
1270
+ try {
1271
+ const r = initWorkspace(process.cwd(), {})
1272
+ push("sys", [r.created.length ? `Created in ${shortCwd()}:` : "Nothing to create: this folder is set up.", ...r.created.map((f) => ` ✚ ${path.relative(process.cwd(), f) || f}`), ...r.notes].join("\n"), "chat")
1273
+ reloadConfig()
1274
+ } catch (err) {
1275
+ push("err", (err as Error).message, "chat")
1276
+ }
1277
+ return
1278
+ }
1243
1279
  if (cmd.type === "features") {
1244
1280
  if (configRef.current) push("sys", featuresText(configRef.current))
1245
1281
  else push("err", "Config is not loaded.")
@@ -1309,7 +1345,11 @@ export function App({ options }: { options: TuiOptions }) {
1309
1345
  ? "headed"
1310
1346
  : ""
1311
1347
  const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
1312
- const footerRight = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
1348
+ const footerVersion = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
1349
+ // In the conversation, the footer is the status line: the model, what the session used, and the version.
1350
+ const sessionTokens = meterSince(sessionMeter.current).total
1351
+ const usedTokens = sessionTokens.input + sessionTokens.output
1352
+ const footerRight = lines.length ? [configRef.current ? modelLabel(configRef.current) : model, usedTokens ? `${formatTokens(usedTokens)} tokens` : "", footerVersion].filter(Boolean).join(" · ") : footerVersion
1313
1353
  // Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
1314
1354
  const suggTrigger = triggerAt(promptText)
1315
1355
  const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
@@ -1336,8 +1376,10 @@ export function App({ options }: { options: TuiOptions }) {
1336
1376
 
1337
1377
  const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
1338
1378
  const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
1339
- // The split view's right pane shows tokens, time, and the last result, so the row under the prompt leaves them out.
1379
+ // One column by default, as in Claude Code; ctrl+o (or Panels) opens the plan, activity, files, context, and
1380
+ // tokens beside it on a wide terminal, or in its place on a narrow one.
1340
1381
  const wide = width >= SPLIT_MIN
1382
+ const panelsOpen = wide && !chatWide
1341
1383
  const openPalette = () => {
1342
1384
  paletteRef.current = { query: "", selected: 0 }
1343
1385
  setPalette(paletteRef.current)
@@ -1347,25 +1389,26 @@ export function App({ options }: { options: TuiOptions }) {
1347
1389
  // `drop`: the order they give way on a narrow row; Refine, Search, and Stop always stay.
1348
1390
  const refineButton: Button = { icon: "✦", label: "Refine", keys: "ctrl+r", color: theme.memory, onPress: () => refineRef.current(), active: Boolean(promptText.trim()) }
1349
1391
  const searchButton: Button = { icon: "⌕", label: "Search", keys: "ctrl+p", color: theme.accent, onPress: openPalette }
1350
- // The view: in the split view, Panels and Wide chat sit in the right pane's status line, where there is room;
1351
- // Split view comes back here when the chat is wide, and Details on a narrow terminal.
1392
+ // The view: with the panels open, moving into them and closing them sit in their status line; otherwise Panels
1393
+ // (Details on a narrow terminal) opens them.
1352
1394
  const toggleWide = () => setChatWide((w) => !w)
1353
1395
  const paneButtons: Button[] = [
1354
- { icon: "▦", label: "Panels", keys: "shift+tab", color: theme.thought, onPress: focusPanels, drop: 2 },
1355
- { icon: "⤢", label: "Wide chat", color: theme.muted, onPress: toggleWide, drop: 1 },
1396
+ { icon: "▦", label: "Focus panels", keys: "shift+tab", color: theme.thought, onPress: focusPanels, drop: 2 },
1397
+ { icon: "✕", label: "Close", keys: "ctrl+o", color: theme.muted, onPress: toggleWide, drop: 1 },
1356
1398
  ]
1357
1399
  const viewButtons: Button[] = wide
1358
1400
  ? chatWide
1359
- ? [{ icon: "◧", label: "Split view", color: theme.thought, onPress: toggleWide, drop: 2 }]
1401
+ ? [{ icon: "▦", label: "Panels", keys: "ctrl+o", color: theme.thought, onPress: toggleWide, drop: 2 }]
1360
1402
  : []
1361
1403
  : [{ icon: "◧", label: "Details", keys: "ctrl+o", color: theme.thought, onPress: () => setNarrowView("details"), drop: 1 }]
1362
1404
  const idleButtons: Button[] = [refineButton, searchButton, ...viewButtons, ...(wide ? [] : [{ icon: "⇄", label: "Profile", keys: "tab", color: theme.muted, onPress: cycleProfile, drop: 4 }])]
1363
1405
  const runningButtons: Button[] = [{ icon: "■", label: "Stop", keys: "esc", color: theme.warn, onPress: () => openDialog("stop") }, searchButton, ...viewButtons]
1364
1406
  const shortcutsHint: Hint = { key: "?", label: "shortcuts", onPress: () => setShortcuts(true) }
1365
1407
  // ? is offered where there is room for it: the start screen of a terminal 110 columns or wider; it works everywhere.
1366
- const idleHints: Hint[] = [...copiedHint, ...(lastStats && !wide && !copied ? [{ key: `last run ${lastStats}`, label: "" }] : []), ...(home && width >= 110 ? [shortcutsHint] : []), closeHint]
1408
+ const idleHints: Hint[] = [...copiedHint, ...(home && width >= 110 ? [shortcutsHint] : []), closeHint]
1367
1409
  // The one-pane view has no tokens panel, so the tokens and time show here.
1368
- const runningHints: Hint[] = [...copiedHint, ...(wide || copied ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" }]), closeHint]
1410
+ // While a request runs, the line above the prompt says what is happening, for how long, and with how many tokens.
1411
+ const runningHints: Hint[] = [...copiedHint, closeHint]
1369
1412
  const runningLeft = (
1370
1413
  <box style={{ flexDirection: "row", marginRight: 1 }}>
1371
1414
  <Spinner kind="bar" />
@@ -1454,8 +1497,8 @@ export function App({ options }: { options: TuiOptions }) {
1454
1497
  model={model}
1455
1498
  right={right}
1456
1499
  rightColor={rightColor}
1457
- compact={width >= SPLIT_MIN && !home}
1458
- focused={!palette && !refining && (nav.focus === "chat" || (width < SPLIT_MIN && narrowView === "chat"))}
1500
+ compact={!home}
1501
+ focused={!palette && !refining && (panelsOpen ? nav.focus === "chat" : wide || narrowView === "chat")}
1459
1502
  initialValue={draft}
1460
1503
  onText={(t) => {
1461
1504
  promptTextRef.current = t
@@ -1479,24 +1522,37 @@ export function App({ options }: { options: TuiOptions }) {
1479
1522
  const cfgHome = configRef.current
1480
1523
  const setupKey = cfgHome ? `${cfgHome.configPath}|${cfgHome.activeProfile}` : ""
1481
1524
  if (cfgHome && setupRef.current.key !== setupKey) {
1525
+ const items = setupChecklist(cfgHome)
1526
+ const recent = recentWork(path.resolve(cfgHome.export.outDir))
1527
+ let signIns: Record<string, string> = {}
1528
+ try {
1529
+ // "Azure DevOps (PAT)": how each system's server signs in, as the session panel says it.
1530
+ signIns = Object.fromEntries(externalMcp(cfgHome).servers.filter((x) => x.signIn).map((x) => [x.label.replace(/ MCP$/, ""), x.signIn!.startsWith("PAT") ? "PAT" : "az login"]))
1531
+ } catch {}
1482
1532
  setupRef.current = {
1483
1533
  key: setupKey,
1484
- items: setupChecklist(cfgHome),
1485
- start: startActions(cfgHome),
1486
- recent: recentWork(path.resolve(cfgHome.export.outDir)),
1534
+ items,
1535
+ start: startActions(cfgHome, 4, undefined, recent.some((r) => r.kind === "run")),
1536
+ recent,
1537
+ first: getStarted(cfgHome, items),
1538
+ facts: projectLine(cfgHome, process.cwd(), { model: modelLabel(cfgHome), signIns }),
1487
1539
  }
1488
1540
  }
1489
- const { items: setup, start, recent } = setupRef.current
1541
+ const { items: setup, start, recent, first, facts } = setupRef.current
1490
1542
  const open = setup.filter((i) => !i.done)
1491
1543
  // Each missing item with how to set it up, in the chat; the first that a command sets up (/auth) is offered last.
1492
1544
  const explainSetup = (items: SetupItem[]) => push("sys", items.map((i) => `○ ${i.fix}${i.command ? ` Type ${i.command} to start.` : ""}`).join("\n"), "chat")
1493
- const facts = projectLine(cfgHome, process.cwd(), cfgHome ? modelLabel(cfgHome) : "")
1494
1545
 
1495
1546
  // Rows: the logo, the project line, and the prompt always; then Start with, Continue, and Setup while they fit.
1496
1547
  // A long request takes rows from them, last section first, so the box never scrolls while there is room.
1497
1548
  const loading = !cfgHome
1498
1549
  // Each section is a blank row and then its own rows: a heading and its items, or the setup line.
1499
- const startRows = loading ? 1 + 1 + 4 : start.length ? 1 + 1 + start.length : 0
1550
+ // Get started takes Start with's place while there is nothing to start with: no model, or no forgeqe.yaml.
1551
+ const offer: { id: string; label: string; detail: string; run: () => void }[] = start.length
1552
+ ? start.map((a) => ({ id: a.id, label: a.label, detail: a.detail, run: () => fillPrompt(a.request) }))
1553
+ : first.map((g) => ({ id: g.id, label: g.label, detail: g.detail, run: () => (g.command ? submitRef.current(g.command) : g.fix && push("sys", g.fix, "chat")) }))
1554
+ const offerTitle = start.length ? "Start with" : "Get started"
1555
+ const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
1500
1556
  const recentRows = recent.length ? 1 + 1 + recent.length : 0
1501
1557
  const setupRows = loading || open.length ? 2 : 0
1502
1558
  const fixed = 5 + 2 + 1 + 5 + 1 + 1 + 1 // logo, project line, gap, prompt chrome, buttons, footer, one row to spare
@@ -1521,8 +1577,8 @@ export function App({ options }: { options: TuiOptions }) {
1521
1577
  <text>
1522
1578
  {facts.length ? (
1523
1579
  facts.map((f, i) => (
1524
- <span key={i} fg={i === 0 ? theme.text : theme.muted}>
1525
- {`${i ? " · " : ""}${f}`}
1580
+ <span key={i} fg={f.tone === "name" ? theme.text : f.tone === "warn" ? theme.warn : theme.muted}>
1581
+ {`${i ? " · " : ""}${f.text}`}
1526
1582
  </span>
1527
1583
  ))
1528
1584
  ) : (
@@ -1535,11 +1591,11 @@ export function App({ options }: { options: TuiOptions }) {
1535
1591
  {renderControls(promptWidth, "Describe what you want to test…", [5, maxRows])}
1536
1592
  {showStart ? (
1537
1593
  <box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
1538
- {heading("Start with")}
1594
+ {heading(offerTitle)}
1539
1595
  {loading
1540
1596
  ? [0, 1, 2, 3].map((i) => <text key={i} fg={theme.faint}>{i ? " " : " Loading what works in this setup…"}</text>)
1541
- : start.map((a, i) => (
1542
- <box key={a.id} onMouseDown={() => fillPrompt(a.request)} style={{ flexDirection: "row", justifyContent: "space-between", flexShrink: 0 }}>
1597
+ : offer.map((a, i) => (
1598
+ <box key={a.id} onMouseDown={a.run} style={{ flexDirection: "row", justifyContent: "space-between", flexShrink: 0 }}>
1543
1599
  <text>
1544
1600
  <span fg={theme.accent}>{" ▸ "}</span>
1545
1601
  <span fg={theme.text}>{a.label.padEnd(32)}</span>
@@ -1640,9 +1696,7 @@ export function App({ options }: { options: TuiOptions }) {
1640
1696
  ? coding
1641
1697
  ? "The coding agent is writing a test · esc to stop"
1642
1698
  : `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
1643
- : width >= SPLIT_MIN
1644
- ? "Ask ForgeQE · / commands · @ references"
1645
- : "Ask ForgeQE: a goal, test cases, a test, a pipeline… (/ commands, @ references)"
1699
+ : "Ask ForgeQE… (/ for commands, @ to reference a run, file, or work item)"
1646
1700
  const chatLines = lines.filter((l) => l.pane !== "activity")
1647
1701
  const activityLines = lines.filter((l) => l.pane === "activity")
1648
1702
  const stop = () => openDialog("stop")
@@ -1684,13 +1738,17 @@ export function App({ options }: { options: TuiOptions }) {
1684
1738
 
1685
1739
  const transcript = (all: Line[], style: Record<string, unknown>) => (
1686
1740
  <scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
1687
- {all.map((line) => (
1688
- <TranscriptLine key={line.id} line={line} />
1741
+ {all.map((line, i) => (
1742
+ <TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
1689
1743
  ))}
1690
- {running && (!split || chatWide) ? (
1691
- <box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
1692
- <Spinner kind="dots" />
1693
- <text fg={theme.text}>{` ${activity || "Working…"}`}</text>
1744
+ {running && !panelsOpen ? (
1745
+ // What is happening now, for how long, and with how many tokens, as one line under the conversation.
1746
+ <box style={{ flexDirection: "row", marginTop: 1, flexShrink: 0 }}>
1747
+ <Spinner kind="dots" color={theme.memory} />
1748
+ <text>
1749
+ <span fg={theme.memory}>{` ${activity || "Working…"}`}</span>
1750
+ <span fg={theme.faint}>{` (${elapsed}${tokens ? ` · ${formatTokens(tokens)} tokens` : ""} · esc to interrupt)`}</span>
1751
+ </text>
1694
1752
  </box>
1695
1753
  ) : null}
1696
1754
  </scrollbox>
@@ -1698,24 +1756,20 @@ export function App({ options }: { options: TuiOptions }) {
1698
1756
  // A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
1699
1757
  const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.floor(height * 0.45))])
1700
1758
 
1701
- const split = width >= SPLIT_MIN
1759
+ const split = panelsOpen
1702
1760
  if (split) {
1703
- // Wide chat: the conversation across the screen for reading, the panels hidden until Split view.
1704
- const leftWidth = chatWide ? width : Math.floor(width * 0.4)
1761
+ const leftWidth = Math.floor(width * 0.4)
1705
1762
  return (
1706
1763
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1707
1764
  <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1708
- <box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: chatWide ? 2 : 1 }}>
1709
- <SessionPanel info={sessionInfo} width={leftWidth - (chatWide ? 4 : 3)} />
1710
- {/* Wide chat shows what the agents do in the conversation, as the one-pane view does. */}
1711
- {transcript(chatWide ? lines : chatLines, {})}
1712
- <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - (chatWide ? 4 : 3))}</box>
1765
+ <box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1766
+ <SessionPanel info={sessionInfo} width={leftWidth - 3} />
1767
+ {transcript(chatLines, {})}
1768
+ <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1769
+ </box>
1770
+ <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1771
+ {details(width - leftWidth - 3, height - 2)}
1713
1772
  </box>
1714
- {chatWide ? null : (
1715
- <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
1716
- {details(width - leftWidth - 3, height - 2)}
1717
- </box>
1718
- )}
1719
1773
  </box>
1720
1774
  <Footer left={shortCwd()} right={footerRight} width={width} />
1721
1775
  {paletteOverlay}
@@ -1724,7 +1778,7 @@ export function App({ options }: { options: TuiOptions }) {
1724
1778
  }
1725
1779
 
1726
1780
  const promptWidth = Math.max(40, width - 4)
1727
- if (narrowView === "details") {
1781
+ if (!wide && narrowView === "details") {
1728
1782
  return (
1729
1783
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1730
1784
  <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>{details(promptWidth, height - 3 - (dialog ? 8 : 0))}</box>
@@ -122,64 +122,56 @@ export function Prompt(props: PromptProps) {
122
122
  props.onLines?.(needed)
123
123
  setLines(needed)
124
124
  }
125
+ // A rounded box with ">" before the text, as in Claude Code; the border lights up while it has the keys.
125
126
  return (
126
- <box style={{ flexDirection: "column", width: props.width, flexShrink: 0 }}>
127
- <box
128
- border={["left"]}
129
- borderColor={props.modeColor}
130
- customBorderChars={BAR}
131
- style={{
132
- flexDirection: "column",
133
- backgroundColor: theme.panel,
134
- paddingLeft: 2,
135
- paddingRight: 2,
136
- paddingTop: 1,
137
- paddingBottom: 1,
138
- width: props.width,
139
- }}
140
- >
141
- {props.masked !== undefined ? (
142
- <text style={{ height: 1 }}>
143
- {props.masked ? <span fg={theme.text}>{"•".repeat(Math.min(props.masked, Math.max(1, props.width - 8)))}</span> : <span fg={theme.faint}>{props.placeholder}</span>}
144
- </text>
145
- ) : (
146
- <textarea
147
- key={props.inputKey}
148
- ref={ref}
149
- placeholder={props.placeholder}
150
- focused={props.focused !== false}
151
- initialValue={props.initialValue || ""}
152
- wrapMode="word"
153
- keyBindings={PROMPT_KEYS}
154
- onSubmit={() => props.onSubmit(ref.current?.plainText ?? "")}
155
- onContentChange={fit}
156
- style={{ height: rows }}
157
- backgroundColor={theme.panel}
158
- focusedBackgroundColor={theme.panel}
159
- textColor={theme.text}
160
- focusedTextColor={theme.text}
161
- placeholderColor={theme.faint}
162
- />
163
- )}
164
- {props.compact ? null : (
165
- <box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
166
- <text>
167
- <span fg={theme.muted}>{"Model "}</span>
168
- <span fg={theme.text}>{props.model || "…"}</span>
169
- </text>
170
- <text>
171
- {props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
172
- {props.right ? <span fg={theme.faint}>{" · "}</span> : null}
173
- <span fg={theme.muted}>{"profile "}</span>
174
- <span fg={theme.text}>{props.profile || "none"}</span>
127
+ <box
128
+ border
129
+ borderStyle="rounded"
130
+ borderColor={props.focused === false ? theme.border : theme.muted}
131
+ style={{ flexDirection: "column", width: props.width, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}
132
+ >
133
+ <box style={{ flexDirection: "row" }}>
134
+ <text fg={props.modeColor} style={{ width: 2, flexShrink: 0 }}>
135
+ {">"}
136
+ </text>
137
+ <box style={{ flexDirection: "column", flexGrow: 1, flexShrink: 1 }}>
138
+ {props.masked !== undefined ? (
139
+ <text style={{ height: 1 }}>
140
+ {props.masked ? <span fg={theme.text}>{"•".repeat(Math.min(props.masked, Math.max(1, props.width - 8)))}</span> : <span fg={theme.faint}>{props.placeholder}</span>}
175
141
  </text>
176
- </box>
177
- )}
142
+ ) : (
143
+ <textarea
144
+ key={props.inputKey}
145
+ ref={ref}
146
+ placeholder={props.placeholder}
147
+ focused={props.focused !== false}
148
+ initialValue={props.initialValue || ""}
149
+ wrapMode="word"
150
+ keyBindings={PROMPT_KEYS}
151
+ onSubmit={() => props.onSubmit(ref.current?.plainText ?? "")}
152
+ onContentChange={fit}
153
+ style={{ height: rows }}
154
+ textColor={theme.text}
155
+ focusedTextColor={theme.text}
156
+ placeholderColor={theme.faint}
157
+ />
158
+ )}
159
+ </box>
178
160
  </box>
179
- <text>
180
- <span fg={props.modeColor}>{edge.slice(0, 1)}</span>
181
- <span fg={theme.panel}>{edge.slice(1)}</span>
182
- </text>
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
+ )}
183
175
  </box>
184
176
  )
185
177
  }