@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/HOW_TO_USE.md +40 -10
- package/docs/user-guide.html +40 -10
- package/forgeqe.example.yaml +1 -1
- package/package.json +1 -1
- package/prompts/brain.md +8 -2
- package/prompts/refine.md +8 -4
- package/src/brain/index.ts +9 -2
- package/src/code/runtime.ts +12 -0
- package/src/commands.ts +6 -1
- package/src/mcp/external.ts +28 -5
- package/src/mcp/tools.ts +2 -2
- package/src/tui/App.tsx +111 -57
- package/src/tui/components.tsx +47 -55
- package/src/tui/landing.ts +101 -28
- package/src/tui/next.ts +1 -1
- package/src/tui/palette.tsx +1 -0
- package/src/tui/refine-panel.tsx +23 -2
- package/src/tui/shortcuts.tsx +1 -1
- package/src/tui/transcript.tsx +155 -150
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(
|
|
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:
|
|
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
|
|
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:
|
|
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 > " " &&
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
1351
|
-
//
|
|
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: "
|
|
1355
|
-
{ icon: "
|
|
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: "
|
|
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, ...(
|
|
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
|
-
|
|
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={
|
|
1458
|
-
focused={!palette && !refining && (nav.focus === "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
|
|
1485
|
-
start: startActions(cfgHome),
|
|
1486
|
-
recent
|
|
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
|
-
|
|
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={
|
|
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(
|
|
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
|
-
:
|
|
1542
|
-
<box key={a.id} onMouseDown={
|
|
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
|
-
:
|
|
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 &&
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
<
|
|
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 =
|
|
1759
|
+
const split = panelsOpen
|
|
1702
1760
|
if (split) {
|
|
1703
|
-
|
|
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:
|
|
1709
|
-
<SessionPanel info={sessionInfo} width={leftWidth -
|
|
1710
|
-
{
|
|
1711
|
-
{
|
|
1712
|
-
|
|
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>
|
package/src/tui/components.tsx
CHANGED
|
@@ -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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
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
|
}
|