@capagents/forgeqe 1.0.7 → 1.0.9

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/ste.ts ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * How closely a prompt follows ASD-STE100 Simplified Technical English: the share of its sentences that keep the
3
+ * writing rules that can be checked in text. The full standard also needs its approved-word dictionary, which is
4
+ * licensed; WORDS holds the common words it does not approve, each with the approved word to use instead.
5
+ *
6
+ * Rules checked, per sentence (a list item is one or more sentences):
7
+ * - length: an instruction has 20 words or fewer; a description has 25 or fewer;
8
+ * - one instruction per sentence: an instruction does not join a second one with ";" or "and then";
9
+ * - active voice: an instruction is not passive ("is sent", "are written");
10
+ * - words: no word from WORDS (e.g., "ensure", "utilize", "prior to");
11
+ * and per paragraph: 6 sentences or fewer.
12
+ *
13
+ * Words are counted as written; text in `code`, a {{placeholder}}, and a "quoted example" each count as one word,
14
+ * because each is a name the reader takes in whole.
15
+ */
16
+
17
+ /** Common words ASD-STE100 does not approve, with the approved word to use instead. */
18
+ export const WORDS: Record<string, string> = {
19
+ "additional": "more",
20
+ "approximately": "about",
21
+ "assist": "help",
22
+ "attempt": "try",
23
+ "commence": "start",
24
+ "comprise": "contain",
25
+ "e.g.": "for example",
26
+ "ensure": "make sure",
27
+ "ensures": "makes sure",
28
+ "etc.": "a full list, or \"and other\"",
29
+ "i.e.": "that is",
30
+ "in order to": "to",
31
+ "obtain": "get",
32
+ "prior to": "before",
33
+ "require": "need",
34
+ "requires": "needs",
35
+ "sufficient": "enough",
36
+ "terminate": "stop",
37
+ "utilize": "use",
38
+ "utilise": "use",
39
+ "via": "through",
40
+ "whilst": "while",
41
+ "don't": "do not",
42
+ "doesn't": "does not",
43
+ "can't": "cannot",
44
+ "won't": "will not",
45
+ "isn't": "is not",
46
+ "aren't": "are not",
47
+ "shouldn't": "do not",
48
+ "wouldn't": "would not",
49
+ "it's": "it is",
50
+ }
51
+
52
+ /** Verbs a sentence starts with when it is an instruction (after a leading "If …," or "When …," clause). */
53
+ const VERBS = new Set(
54
+ (
55
+ "accept act add answer append apply ask assert attach avoid build call change check choose cite click close compare confirm copy count cover create " +
56
+ "decide delete derive describe dismiss do edit end explain fill find finish fix flag focus follow generate get give group include insert install " +
57
+ "join keep leave let limit link list load look make map mark mention merge move name navigate note open pass pick plan point post prefer press put " +
58
+ "quote raise read record refer remove rename reply report rerun resolve respond retry return review run save say scroll select send set show skip " +
59
+ "sort split start state stay stop summarise summarize take tell test treat trust try type update use verify wait work write never always"
60
+ ).split(" "),
61
+ )
62
+
63
+ const LEAD = /^(if|when|whenever|before|after|unless|while|once|until|for|in|on|with|without|where|to)\b[^,]{0,140},\s+/i
64
+ const PASSIVE = /\b(is|are|was|were|be|been|being)\s+(\w+ly\s+)?(\w+ed|written|sent|given|made|done|shown|taken|known|seen|found|run|built|kept|left|set|put|read|chosen|broken|held)\b/i
65
+
66
+ export type SentenceIssue = "length" | "two instructions" | "passive" | "word"
67
+
68
+ export type SteSentence = { text: string; words: number; instruction: boolean; issues: { rule: SentenceIssue; detail: string }[] }
69
+
70
+ export type SteScore = {
71
+ /** Sentences that keep every rule, of all sentences, 0 to 1. */
72
+ score: number
73
+ sentences: number
74
+ compliant: number
75
+ /** Paragraphs with more than 6 sentences. */
76
+ longParagraphs: number
77
+ issues: SteSentence[]
78
+ }
79
+
80
+ /** Text a reader takes in as one word: `code`, {{placeholders}}, and "quoted examples". */
81
+ function collapse(text: string): string {
82
+ return text
83
+ .replace(/`[^`]*`/g, "CODE")
84
+ .replace(/\{\{[^}]*\}\}/g, "NAME")
85
+ .replace(/"[^"\n]{1,200}"/g, "QUOTE")
86
+ .replace(/“[^”\n]{1,200}”/g, "QUOTE")
87
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
88
+ }
89
+
90
+ export function countWords(text: string): number {
91
+ return (collapse(text).match(/[A-Za-z0-9][\w'’./-]*/g) || []).length
92
+ }
93
+
94
+ export function isInstruction(sentence: string): boolean {
95
+ const s = collapse(sentence).replace(/^[\s>*_-]+/, "").replace(LEAD, "")
96
+ const first = (s.match(/^[A-Za-z']+/)?.[0] || "").toLowerCase()
97
+ return VERBS.has(first)
98
+ }
99
+
100
+ /** Sentences of a prompt, by paragraph: list items, and sentences inside them, each one; code blocks and headings left out. */
101
+ const ITEM_MARK = "\u0001"
102
+
103
+ export function steParagraphs(markdown: string): string[][] {
104
+ const paragraphs: string[][] = []
105
+ let fence = false
106
+ let current: string[] = []
107
+ const end = () => {
108
+ if (current.length) paragraphs.push(current)
109
+ current = []
110
+ }
111
+ const blocks: string[] = []
112
+ for (const raw of markdown.replace(/\r/g, "").split("\n")) {
113
+ if (/^\s*(```|~~~)/.test(raw)) {
114
+ fence = !fence
115
+ continue
116
+ }
117
+ if (fence) continue
118
+ const line = raw.trimEnd()
119
+ if (!line.trim() || /^#{1,6}\s/.test(line) || /^\s*\|/.test(line) || /^\{\{>/.test(line.trim())) {
120
+ if (blocks.length) blocks.push("\n")
121
+ blocks.push("\n")
122
+ continue
123
+ }
124
+ if (/^\s*([-*+]|\d+[.)])\s+/.test(line)) blocks.push("\n", ITEM_MARK + line.replace(/^\s*([-*+]|\d+[.)])\s+/, ""))
125
+ else blocks.push(` ${line.trim()}`)
126
+ }
127
+ // A blank line ends a paragraph. A list is not a paragraph: each item is its own, so only prose counts toward 6.
128
+ for (const part of blocks.join("").split(/\n\n+/)) {
129
+ for (const item of part.split("\n")) {
130
+ if (item.startsWith(ITEM_MARK)) {
131
+ end()
132
+ current = splitSentences(item.slice(ITEM_MARK.length))
133
+ end()
134
+ } else for (const sentence of splitSentences(item)) current.push(sentence)
135
+ }
136
+ end()
137
+ }
138
+ end()
139
+ return paragraphs.filter((p) => p.length)
140
+ }
141
+
142
+ function splitSentences(text: string): string[] {
143
+ const t = text.trim()
144
+ if (!t) return []
145
+ // Protect what has a full stop inside: abbreviations, versions, file names, and quoted text.
146
+ const masked = t.replace(/"[^"]*"|`[^`]*`|\b(e\.g|i\.e|etc|vs|approx)\.|\w\.\w/g, (m) => m.replace(/[.!?]/g, "\u0000"))
147
+ return masked
148
+ .split(/(?<=[.!?])\s+(?=[A-Z"`(*])/)
149
+ .map((s) => s.replace(/\u0000/g, ".").trim())
150
+ .filter((s) => /[A-Za-z]/.test(s))
151
+ }
152
+
153
+ export function checkSentence(text: string): SteSentence {
154
+ const words = countWords(text)
155
+ const instruction = isInstruction(text)
156
+ const issues: SteSentence["issues"] = []
157
+ const limit = instruction ? 20 : 25
158
+ if (words > limit) issues.push({ rule: "length", detail: `${words} words; ${instruction ? "an instruction" : "a description"} has ${limit} or fewer` })
159
+ const plain = collapse(text)
160
+ if (instruction && (/;\s+\w/.test(plain) || /,?\s+and then\s+/i.test(plain))) issues.push({ rule: "two instructions", detail: "one instruction per sentence" })
161
+ if (instruction && PASSIVE.test(plain)) issues.push({ rule: "passive", detail: `"${plain.match(PASSIVE)![0]}": use the active voice` })
162
+ const lower = ` ${plain.toLowerCase()} `
163
+ for (const [word, use] of Object.entries(WORDS)) {
164
+ const at = lower.search(new RegExp(`[^a-z]${word.replace(/[.]/g, "\\.")}[^a-z]`))
165
+ if (at >= 0) issues.push({ rule: "word", detail: `"${word}": use ${use}` })
166
+ }
167
+ return { text, words, instruction, issues }
168
+ }
169
+
170
+ export function steScore(markdown: string): SteScore {
171
+ const paragraphs = steParagraphs(markdown)
172
+ const all = paragraphs.flat().map(checkSentence)
173
+ const compliant = all.filter((s) => !s.issues.length).length
174
+ return {
175
+ score: all.length ? compliant / all.length : 1,
176
+ sentences: all.length,
177
+ compliant,
178
+ longParagraphs: paragraphs.filter((p) => p.length > 6).length,
179
+ issues: all.filter((s) => s.issues.length),
180
+ }
181
+ }
182
+
183
+ /** The share of sentences every built-in prompt keeps to; a project's own prompts are scored against it too. */
184
+ export const STE_TARGET = 0.8
package/src/tui/App.tsx CHANGED
@@ -26,7 +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 { EXAMPLES, TIPS, formatDuration, formatTokens, packageVersion, pick, shortCwd, theme } from "./theme.ts"
29
+ import { ShortcutsPanel } from "./shortcuts.tsx"
30
+ import { ago, projectLine, setupSentence, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
31
+ import { TIPS, formatDuration, formatTokens, packageVersion, shortCwd, theme } from "./theme.ts"
30
32
  import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
31
33
  import { Dialog, dialogSpec } from "./dialogs.ts"
32
34
  import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
@@ -154,6 +156,12 @@ export function App({ options }: { options: TuiOptions }) {
154
156
  const [next, setNext] = useState<NextStep[]>([])
155
157
  /** What the prompt holds, for suggestions; and the suggestion state: which is selected, which word was dismissed. */
156
158
  const [promptText, setPromptText] = useState("")
159
+ // The rows the request in the prompt needs; the start screen gives a long one room by folding its other sections.
160
+ const [promptLines, setPromptLines] = useState(1)
161
+ const [tipIndex, setTipIndex] = useState(() => Math.floor(Math.random() * TIPS.length))
162
+ const [shortcuts, setShortcuts] = useState(false)
163
+ const shortcutsRef = useRef(false)
164
+ shortcutsRef.current = shortcuts
157
165
  const [sugg, setSugg] = useState<{ selected: number; dismissed?: string }>({ selected: 0 })
158
166
  const [workItem, setWorkItem] = useState<WorkItem | null | undefined>(undefined)
159
167
  const suggRef = useRef<{ list: Suggestion[]; trigger: ReturnType<typeof triggerAt> }>({ list: [], trigger: null })
@@ -171,7 +179,7 @@ export function App({ options }: { options: TuiOptions }) {
171
179
  const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
172
180
  // The split view's chat at the full width, for reading a long answer; the panels come back with Split view.
173
181
  const [chatWide, setChatWide] = useState(false)
174
- const setupRef = useRef<{ key: string; items: SetupItem[] }>({ key: "", items: [] })
182
+ const setupRef = useRef<{ key: string; items: SetupItem[]; start: StartAction[]; recent: RecentItem[] }>({ key: "", items: [], start: [], recent: [] })
175
183
  // Which panel the keys go to (shift+tab moves on), the row selected in the plan and files, and the activity filter.
176
184
  const [nav, setNavState] = useState<{ focus: Focus; plan: number; files: number; filter: ActivityFilter }>({ focus: "chat", plan: 0, files: 0, filter: "all" })
177
185
  const navRef = useRef(nav)
@@ -183,6 +191,8 @@ export function App({ options }: { options: TuiOptions }) {
183
191
  const contextInfo = useRef<{ key: string; knowledge: number; mcp: string[] }>({ key: "", knowledge: 0, mcp: [] })
184
192
 
185
193
  const [lines, setLines] = useState<Line[]>([])
194
+ const linesRef = useRef(0)
195
+ linesRef.current = lines.length
186
196
  const [headed, setHeaded] = useState(Boolean(options.headed))
187
197
  const [maxSteps, setMaxSteps] = useState(40)
188
198
  const [profile, setProfile] = useState(options.profile || "")
@@ -201,8 +211,6 @@ export function App({ options }: { options: TuiOptions }) {
201
211
  const [coding, setCoding] = useState(false)
202
212
  const [suiteProgress, setSuiteProgress] = useState<{ done: number; total: number } | null>(null)
203
213
  const [copied, setCopied] = useState("")
204
- const [example] = useState(() => pick(EXAMPLES))
205
- const [tip] = useState(() => pick(TIPS))
206
214
  const started = useRef<number | null>(null)
207
215
 
208
216
  const detailRef = useRef<Detail>(detailFrom(options.verbose))
@@ -289,6 +297,13 @@ export function App({ options }: { options: TuiOptions }) {
289
297
  return () => clearInterval(timer)
290
298
  }, [status])
291
299
 
300
+ // The start screen's tip changes every 8 seconds; only while it shows, so a session does not re-render for it.
301
+ useEffect(() => {
302
+ if (lines.length || status === "running") return
303
+ const timer = setInterval(() => setTipIndex((n) => n + 1), 8000)
304
+ return () => clearInterval(timer)
305
+ }, [lines.length, status])
306
+
292
307
  // The terminal hands mouse drags to the TUI, so text selected with the mouse is copied here when the drag ends.
293
308
  useEffect(() => {
294
309
  let timer: ReturnType<typeof setTimeout> | undefined
@@ -562,6 +577,7 @@ export function App({ options }: { options: TuiOptions }) {
562
577
  else if (run.id === "details" || run.id === "chat") setNarrowView(run.id)
563
578
  else if (run.id === "refine") refineRef.current()
564
579
  else if (run.id === "wide") setChatWide((w) => !w)
580
+ else if (run.id === "shortcuts") setShortcuts(true)
565
581
  else if (run.id === "panels") setNav((n) => ({ ...n, focus: planLines(inspRef.current).length ? "plan" : "activity" }))
566
582
  }
567
583
 
@@ -575,6 +591,8 @@ export function App({ options }: { options: TuiOptions }) {
575
591
  }
576
592
 
577
593
  useKeyboard((key) => {
594
+ // Esc is the shell's (stop, close a list or panel, cancel): the prompt never acts on it, or it would lose focus.
595
+ if (key.name === "escape") key.preventDefault()
578
596
  const pal = paletteRef.current
579
597
  if (pal) {
580
598
  // Keys can arrive faster than renders: every change builds on the latest state, never on this render's copy.
@@ -652,11 +670,15 @@ export function App({ options }: { options: TuiOptions }) {
652
670
  const insp = inspRef.current
653
671
  const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && width >= SPLIT_MIN)
654
672
  if (panes && key.name === "tab" && key.shift && !entryRef.current) {
673
+ // Consumed here: the prompt can take focus within this key press and must not also act on it.
674
+ key.preventDefault()
655
675
  setNav((n) => ({ ...n, focus: nextFocus(n.focus, shown) }))
656
676
  return
657
677
  }
658
678
  const at = navRef.current.focus
659
679
  if (panes && at !== "chat" && shown(at)) {
680
+ // Keys a panel takes are its own: the prompt, focused again by esc, must not see the same esc (it would blur).
681
+ if (key.name === "escape" || key.name === "return" || key.name === "enter" || key.name === "kpenter") key.preventDefault()
660
682
  const list = at === "plan" ? planLines(insp).length : at === "files" ? filesByUse(insp).length : 0
661
683
  const move = (d: number) => setNav((n) => ({ ...n, [at]: Math.max(0, Math.min(list - 1, (n[at as "plan" | "files"] ?? 0) + d)) }))
662
684
  const page = Math.max(3, Math.floor(height / 3))
@@ -691,6 +713,23 @@ export function App({ options }: { options: TuiOptions }) {
691
713
  if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && width >= SPLIT_MIN) return setNav((n) => ({ ...n, focus: "chat" }))
692
714
  if (!key.ctrl && !key.meta) return
693
715
  }
716
+ // ? on an empty prompt: every key in one panel; esc or ? closes it.
717
+ if (shortcutsRef.current) {
718
+ if (key.name === "escape" || key.sequence === "?") setShortcuts(false)
719
+ return
720
+ }
721
+ if (key.sequence === "?" && !promptTextRef.current.trim() && !entryRef.current && !dialogRef.current) {
722
+ setShortcuts(true)
723
+ setDraft("")
724
+ setInputKey((k) => k + 1)
725
+ return
726
+ }
727
+ // The start screen's actions: alt+1 to alt+4 put one in the prompt.
728
+ if (key.meta && /^[1-4]$/.test(key.name || "") && linesRef.current === 0 && !abortRef.current) {
729
+ const a = setupRef.current.start[Number(key.name) - 1]
730
+ if (a) fillPrompt(a.request)
731
+ return
732
+ }
694
733
  if (suggRef.current.list.length && suggRef.current.trigger && !entryRef.current) {
695
734
  const n = suggRef.current.list.length
696
735
  if (key.name === "up") return setSugg((x) => ({ ...x, selected: (x.selected + n - 1) % n }))
@@ -1322,7 +1361,9 @@ export function App({ options }: { options: TuiOptions }) {
1322
1361
  : [{ icon: "◧", label: "Details", keys: "ctrl+o", color: theme.thought, onPress: () => setNarrowView("details"), drop: 1 }]
1323
1362
  const idleButtons: Button[] = [refineButton, searchButton, ...viewButtons, ...(wide ? [] : [{ icon: "⇄", label: "Profile", keys: "tab", color: theme.muted, onPress: cycleProfile, drop: 4 }])]
1324
1363
  const runningButtons: Button[] = [{ icon: "■", label: "Stop", keys: "esc", color: theme.warn, onPress: () => openDialog("stop") }, searchButton, ...viewButtons]
1325
- const idleHints: Hint[] = [...copiedHint, ...(lastStats && !wide && !copied ? [{ key: `last run ${lastStats}`, label: "" }] : []), closeHint]
1364
+ const shortcutsHint: Hint = { key: "?", label: "shortcuts", onPress: () => setShortcuts(true) }
1365
+ // ? 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]
1326
1367
  // The one-pane view has no tokens panel, so the tokens and time show here.
1327
1368
  const runningHints: Hint[] = [...copiedHint, ...(wide || copied ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" }]), closeHint]
1328
1369
  const runningLeft = (
@@ -1420,52 +1461,147 @@ export function App({ options }: { options: TuiOptions }) {
1420
1461
  promptTextRef.current = t
1421
1462
  setPromptText(t)
1422
1463
  }}
1464
+ onLines={setPromptLines}
1423
1465
  />
1424
1466
  <ButtonBar width={promptWidth} buttons={running ? runningButtons : idleButtons} hints={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
1425
1467
  </>
1426
1468
  )
1427
1469
  }
1428
1470
 
1471
+ // Back on the start screen after /clear, its sections are read again: new reports and runs show up.
1472
+ if (!home) setupRef.current.key = ""
1473
+ const shortcutsOverlay = shortcuts ? <ShortcutsPanel width={width} wide={width >= SPLIT_MIN} onClose={() => setShortcuts(false)} /> : null
1474
+
1429
1475
  if (home) {
1430
- const promptWidth = Math.max(40, Math.min(width - 8, 120))
1476
+ // One column, as wide as the prompt, every section on its left edge.
1477
+ const promptWidth = Math.max(40, Math.min(width - 8, 140))
1431
1478
  // What is set up, while something is not: each open item says how, or runs the command that does it.
1432
1479
  const cfgHome = configRef.current
1433
1480
  const setupKey = cfgHome ? `${cfgHome.configPath}|${cfgHome.activeProfile}` : ""
1434
- if (cfgHome && setupRef.current.key !== setupKey) setupRef.current = { key: setupKey, items: setupChecklist(cfgHome) }
1435
- const setup = setupRef.current.items
1481
+ if (cfgHome && setupRef.current.key !== setupKey) {
1482
+ setupRef.current = {
1483
+ key: setupKey,
1484
+ items: setupChecklist(cfgHome),
1485
+ start: startActions(cfgHome),
1486
+ recent: recentWork(path.resolve(cfgHome.export.outDir)),
1487
+ }
1488
+ }
1489
+ const { items: setup, start, recent } = setupRef.current
1436
1490
  const open = setup.filter((i) => !i.done)
1437
- const pickSetup = (i: SetupItem) => (i.command ? submitRef.current(i.command) : i.fix && push("sys", i.fix, "chat"))
1491
+ // Each missing item with how to set it up, in the chat; the first that a command sets up (/auth) is offered last.
1492
+ 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
+
1495
+ // Rows: the logo, the project line, and the prompt always; then Start with, Continue, and Setup while they fit.
1496
+ // A long request takes rows from them, last section first, so the box never scrolls while there is room.
1497
+ const loading = !cfgHome
1498
+ // 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
1500
+ const recentRows = recent.length ? 1 + 1 + recent.length : 0
1501
+ const setupRows = loading || open.length ? 2 : 0
1502
+ const fixed = 5 + 2 + 1 + 5 + 1 + 1 + 1 // logo, project line, gap, prompt chrome, buttons, footer, one row to spare
1503
+ const room = height - fixed
1504
+ const want = Math.max(5, Math.min(promptLines, room - 1))
1505
+ let left = room - want
1506
+ const showStart = startRows > 0 && left >= startRows
1507
+ if (showStart) left -= startRows
1508
+ const showRecent = recentRows > 0 && left >= recentRows
1509
+ if (showRecent) left -= recentRows
1510
+ const showSetup = setupRows > 0 && left >= setupRows
1511
+ const maxRows = Math.max(5, room - (showStart ? startRows : 0) - (showRecent ? recentRows : 0) - (showSetup ? setupRows : 0))
1512
+ const heading = (t: string) => <text fg={theme.muted}>{t}</text>
1513
+ const keyCell = (k: string) => <span fg={theme.faint}>{k.padEnd(4)}</span>
1514
+ const done = setup.length - open.length
1438
1515
  return (
1439
1516
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1440
- <box style={{ flexDirection: "column", flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
1517
+ {/* Clipped, never pushed: the footer stays on screen whatever the sections above it need. */}
1518
+ <box style={{ flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", alignItems: "center", justifyContent: "center" }}>
1441
1519
  <Logo />
1442
- <box style={{ height: 2 }} />
1443
- {renderControls(promptWidth, `Describe a goal… "${example}"`, [4, Math.max(4, Math.min(12, height - 22))])}
1444
- <box style={{ height: open.length ? 2 : 3 }} />
1445
- {open.length ? (
1446
- <box style={{ flexDirection: "column", alignItems: "center", flexShrink: 0, marginBottom: 1 }}>
1447
- <box style={{ flexDirection: "row", flexShrink: 0 }}>
1448
- <text fg={theme.muted}>{"Setup "}</text>
1449
- {(setup.reduce((n, i) => n + i.label.length + 5, 7) <= width - 4 ? setup : open).map((i) => (
1450
- <box key={i.id} onMouseDown={i.done ? undefined : () => pickSetup(i)} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: i.done ? undefined : theme.button }}>
1451
- <text>
1452
- <span fg={i.done ? theme.ok : theme.warn}>{i.done ? "✓ " : "○ "}</span>
1453
- <span fg={i.done ? theme.muted : theme.text}>{i.label}</span>
1520
+ <box style={{ paddingTop: 1, height: 2, flexShrink: 0 }}>
1521
+ <text>
1522
+ {facts.length ? (
1523
+ facts.map((f, i) => (
1524
+ <span key={i} fg={i === 0 ? theme.text : theme.muted}>
1525
+ {`${i ? " · " : ""}${f}`}
1526
+ </span>
1527
+ ))
1528
+ ) : (
1529
+ <span fg={theme.faint}>{" "}</span>
1530
+ )}
1531
+ </text>
1532
+ </box>
1533
+ <box style={{ height: 1, flexShrink: 0 }} />
1534
+ <box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0 }}>
1535
+ {renderControls(promptWidth, "Describe what you want to test…", [5, maxRows])}
1536
+ {showStart ? (
1537
+ <box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
1538
+ {heading("Start with")}
1539
+ {loading
1540
+ ? [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 }}>
1543
+ <text>
1544
+ <span fg={theme.accent}>{" ▸ "}</span>
1545
+ <span fg={theme.text}>{a.label.padEnd(32)}</span>
1546
+ <span fg={theme.faint}>{clip(a.detail, Math.max(10, promptWidth - 50))}</span>
1547
+ </text>
1548
+ <text>{keyCell(`alt+${i + 1}`)}</text>
1549
+ </box>
1550
+ ))}
1551
+ </box>
1552
+ ) : null}
1553
+ {showRecent ? (
1554
+ <box style={{ flexDirection: "column", marginTop: 1, height: recentRows - 1, flexShrink: 0 }}>
1555
+ {heading("Continue")}
1556
+ {recent.map((r) => (
1557
+ <box key={r.dir} style={{ flexDirection: "row", justifyContent: "space-between", flexShrink: 0 }}>
1558
+ <text onMouseDown={() => openFile(r.open)}>
1559
+ <span fg={r.kind === "report" ? theme.memory : r.status === "complete" || r.status === "passed" ? theme.ok : r.status ? theme.err : theme.muted}>
1560
+ {r.kind === "report" ? " ▤ " : r.status === "complete" || r.status === "passed" ? " ✓ " : r.status ? " ✗ " : " ▸ "}
1561
+ </span>
1562
+ <span fg={theme.muted}>{r.kind === "report" ? "Report " : "Run "}</span>
1563
+ <span fg={theme.text}>{clip(r.title, Math.max(10, promptWidth - 40))}</span>
1454
1564
  </text>
1565
+ <box style={{ flexDirection: "row" }}>
1566
+ <text fg={theme.faint}>{`${ago(r.when)} `}</text>
1567
+ <text fg={theme.accent} onMouseDown={() => openFile(r.open)}>
1568
+ {"open"}
1569
+ </text>
1570
+ {r.kind === "run" ? (
1571
+ <text fg={theme.accent} onMouseDown={() => fillPrompt(`Run this goal again: ${r.title}`)}>
1572
+ {" rerun"}
1573
+ </text>
1574
+ ) : null}
1575
+ </box>
1455
1576
  </box>
1456
1577
  ))}
1457
1578
  </box>
1458
- <text fg={theme.faint}>{`${setup.length - open.length} of ${setup.length} set up · click one to see how · /features shows what each feature needs`}</text>
1459
- </box>
1460
- ) : null}
1461
- <text>
1462
- <span fg={theme.warn}>{"● "}</span>
1463
- <span fg={theme.text}>{"Tip "}</span>
1464
- <span fg={theme.muted}>{tip}</span>
1465
- </text>
1579
+ ) : null}
1580
+ {showSetup ? (
1581
+ // Setup in one line: how far it is, what is missing in words, and one link that says how to set it up.
1582
+ <box style={{ flexDirection: "row", height: 1, flexShrink: 0, marginTop: 1 }}>
1583
+ <text>
1584
+ <span fg={theme.muted}>{"Setup "}</span>
1585
+ <span fg={theme.ok}>{"▰".repeat(done)}</span>
1586
+ <span fg={theme.muted}>{"▱".repeat(open.length)}</span>
1587
+ <span fg={theme.muted}>{loading ? " checking…" : ` ${done} of ${setup.length} · `}</span>
1588
+ {loading ? null : <span fg={theme.text}>{setupSentence(open.map((i) => i.label), promptWidth - 44)}</span>}
1589
+ </text>
1590
+ {loading ? null : (
1591
+ <box onMouseDown={() => explainSetup(open)} style={{ marginLeft: 2, paddingLeft: 1, paddingRight: 1, backgroundColor: theme.button }}>
1592
+ <text>
1593
+ <span fg={theme.warn}>{"▸ "}</span>
1594
+ <span fg={theme.text}>{"how to set up"}</span>
1595
+ </text>
1596
+ </box>
1597
+ )}
1598
+ </box>
1599
+ ) : null}
1600
+ </box>
1466
1601
  </box>
1467
- <Footer left={shortCwd()} right={footerRight} width={width} />
1602
+ <Footer left={`● Tip ${TIPS[tipIndex % TIPS.length]}`} right={footerRight} width={width} cut="end" />
1468
1603
  {paletteOverlay}
1604
+ {shortcutsOverlay}
1469
1605
  </box>
1470
1606
  )
1471
1607
  }
@@ -1559,7 +1695,8 @@ export function App({ options }: { options: TuiOptions }) {
1559
1695
  ) : null}
1560
1696
  </scrollbox>
1561
1697
  )
1562
- const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.min(10, Math.floor(height / 3)))])
1698
+ // A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
1699
+ const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.floor(height * 0.45))])
1563
1700
 
1564
1701
  const split = width >= SPLIT_MIN
1565
1702
  if (split) {
@@ -21,7 +21,7 @@ export function Logo() {
21
21
  const name = logoRows("forge")
22
22
  const suffix = logoRows("QE")
23
23
  return (
24
- <box style={{ flexDirection: "column", alignItems: "flex-end" }}>
24
+ <box style={{ flexDirection: "column", alignItems: "flex-end", flexShrink: 0 }}>
25
25
  {name.map((row, i) => (
26
26
  <text key={i}>
27
27
  <span fg={theme.faint}>{row}</span>
@@ -56,6 +56,32 @@ export type PromptProps = {
56
56
  focused?: boolean
57
57
  /** Called with the text as it changes, for actions on what is being typed (Refine). */
58
58
  onText?: (text: string) => void
59
+ /** Called with the rows the text needs, so the screen around the box can make room for a long request. */
60
+ onLines?: (lines: number) => void
61
+ }
62
+
63
+ /** The rows `text` takes when word-wrapped at `width` columns, as the prompt wraps it. */
64
+ export function wrappedLines(text: string, width: number): number {
65
+ if (width < 1) return 1
66
+ let rows = 0
67
+ for (const line of text.split("\n")) {
68
+ let used = 0
69
+ let count = 1
70
+ for (const word of line.split(/(\s+)/)) {
71
+ if (!word) continue
72
+ if (used + word.length <= width) used += word.length
73
+ else if (/^\s+$/.test(word)) used = width
74
+ else if (word.length > width) {
75
+ count += Math.ceil((used + word.length) / width) - 1
76
+ used = (used + word.length) % width
77
+ } else {
78
+ count++
79
+ used = word.length
80
+ }
81
+ }
82
+ rows += count
83
+ }
84
+ return Math.max(1, rows)
59
85
  }
60
86
 
61
87
  /** Enter sends; Shift+Enter, Alt+Enter or Ctrl+J start a new line. */
@@ -70,16 +96,31 @@ const PROMPT_KEYS: KeyBinding[] = [
70
96
  export function Prompt(props: PromptProps) {
71
97
  const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
72
98
  const ref = useRef<TextareaRenderable>(null)
73
- const [rows, setRows] = useState(props.minRows)
74
- useEffect(() => setRows(props.minRows), [props.inputKey, props.minRows])
99
+ // The rows the text needs; the box shows that many between minRows and maxRows, so it also grows when the screen
100
+ // around it makes more room (the start screen folds a section for a long request).
101
+ const [lines, setLines] = useState(1)
102
+ useEffect(() => setLines(1), [props.inputKey])
103
+ const rows = Math.min(props.maxRows, Math.max(props.minRows, lines))
104
+ // The text scrolled while the box was smaller: once it all fits, show it from the top.
105
+ useEffect(() => {
106
+ const t = ref.current
107
+ if (!t || lines > rows || t.scrollY === 0) return
108
+ // Going to the top scrolls the view there; going back to where the cursor was keeps it there, now that all fits.
109
+ const at = t.cursorOffset
110
+ t.gotoBufferHome()
111
+ t.cursorOffset = at
112
+ }, [rows, lines])
75
113
  // Inserted text (a completion, an example, a refined request) is continued from its end.
76
114
  useEffect(() => {
77
115
  if (props.initialValue) ref.current?.gotoBufferEnd()
78
116
  }, [props.inputKey])
79
117
  const fit = () => {
80
118
  props.onText?.(ref.current?.plainText ?? "")
81
- const lines = ref.current?.virtualLineCount ?? 1
82
- setRows(Math.min(props.maxRows, Math.max(props.minRows, lines)))
119
+ // virtualLineCount counts only the lines in view, so a box could never ask to grow: count the wrapped lines.
120
+ const t = ref.current
121
+ const needed = t ? wrappedLines(t.plainText, t.width || props.width - 5) : 1
122
+ props.onLines?.(needed)
123
+ setLines(needed)
83
124
  }
84
125
  return (
85
126
  <box style={{ flexDirection: "column", width: props.width, flexShrink: 0 }}>
@@ -328,9 +369,10 @@ export function ChoicePanel(props: ChoicePanelProps) {
328
369
  )
329
370
  }
330
371
 
331
- export function Footer({ left, right, width }: { left: string; right: string; width: number }) {
372
+ /** `cut`: which end of `left` gives way when it is too long: the start of a path, the end of a sentence (the tip). */
373
+ export function Footer({ left, right, width, cut = "start" }: { left: string; right: string; width: number; cut?: "start" | "end" }) {
332
374
  const room = Math.max(8, width - right.length - 8)
333
- const shown = left.length <= room ? left : `…${left.slice(left.length - room + 1)}`
375
+ const shown = left.length <= room ? left : cut === "end" ? `${left.slice(0, room - 1)}…` : `…${left.slice(left.length - room + 1)}`
334
376
  return (
335
377
  <box
336
378
  style={{ flexDirection: "row", justifyContent: "space-between", paddingLeft: 2, paddingRight: 2, height: 1, flexShrink: 0 }}