@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/HOW_TO_USE.md +49 -2
- package/docs/user-guide.html +44 -3
- package/package.json +1 -1
- package/prompts/README.md +6 -1
- package/prompts/api-scenarios.md +21 -10
- package/prompts/api-tests.md +55 -25
- package/prompts/brain.md +296 -138
- package/prompts/browser-capture.md +8 -1
- package/prompts/browser-case-report.md +12 -1
- package/prompts/browser-checks.md +22 -8
- package/prompts/browser-efficiency.md +1 -1
- package/prompts/browser-error-recovery.md +18 -6
- package/prompts/browser-goal-verification.md +9 -8
- package/prompts/browser-skill-select.md +1 -1
- package/prompts/domain-rule.md +16 -8
- package/prompts/eval-judge.md +15 -8
- package/prompts/explore-charters.md +19 -15
- package/prompts/find-tests.md +26 -16
- package/prompts/heal.md +32 -24
- package/prompts/pr-review.md +21 -13
- package/prompts/refine.md +30 -10
- package/prompts/skill-rule.md +1 -1
- package/prompts/test-agent.md +43 -18
- package/prompts/test-reviewer.md +37 -15
- package/prompts/test-rules.md +55 -19
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +21 -3
- package/src/refine.ts +53 -7
- package/src/ste.ts +184 -0
- package/src/tui/App.tsx +169 -32
- package/src/tui/components.tsx +49 -7
- package/src/tui/landing.ts +128 -0
- package/src/tui/palette.tsx +2 -1
- package/src/tui/refine-panel.tsx +14 -1
- package/src/tui/shortcuts.tsx +66 -0
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 {
|
|
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
|
|
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
|
-
|
|
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)
|
|
1435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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={
|
|
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
|
-
|
|
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) {
|
package/src/tui/components.tsx
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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 }}
|