@capagents/forgeqe 1.0.8 → 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 +27 -1
- package/docs/user-guide.html +26 -2
- package/package.json +1 -1
- 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/shortcuts.tsx +66 -0
package/HOW_TO_USE.md
CHANGED
|
@@ -385,6 +385,30 @@ The chat gets a short summary and a card with both paths as links, and **Open th
|
|
|
385
385
|
|
|
386
386
|
### The screen
|
|
387
387
|
|
|
388
|
+
**The start screen.** Before your first request, the screen is one column under the logo:
|
|
389
|
+
|
|
390
|
+
```
|
|
391
|
+
playwright-csharp-pom-framework · .NET · Azure DevOps · azure/gpt-5.4
|
|
392
|
+
|
|
393
|
+
┃ Describe what you want to test…
|
|
394
|
+
✦ Refine ctrl+r ⌕ Search ctrl+p ? shortcuts ctrl+c close
|
|
395
|
+
|
|
396
|
+
Start with
|
|
397
|
+
▸ Run test cases Run test plan 12 suite 34 from Azure DevOps… alt+1
|
|
398
|
+
▸ Check release readiness Are we ready to release? alt+2
|
|
399
|
+
Continue
|
|
400
|
+
▤ Report QI-Payments pipeline status report 18 min ago open
|
|
401
|
+
✓ Run Search hotels in Mumbai on booking.com 2 days ago open rerun
|
|
402
|
+
Setup ▰▰▰▱▱ 3 of 5 · Domain and Notifications are not set up ▸ how to set up
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
- The line under the logo is what ForgeQE works with here: the folder, what it is built with (from its files, such as a `.sln` or `playwright.config.ts`), the systems that are set up, and the model.
|
|
406
|
+
- The prompt starts five rows high and grows with a long request, up to nearly half the screen, before it scrolls; the sections below give way, last first, to make room.
|
|
407
|
+
- **Start with** offers up to four things to ask, from the capabilities that work in this setup, each with an example in the words of your setup. Click one, or press Alt-1 to Alt-4, to put it in the prompt to adjust and send.
|
|
408
|
+
- **Continue** lists the newest reports and runs in the reports folder: **open** opens the report, **rerun** puts the run's goal in the prompt.
|
|
409
|
+
- **Setup** shows how much is set up and what is missing (see the setup checklist below); it goes away once everything is set up.
|
|
410
|
+
- The tip in the footer changes every few seconds. Press **?** on an empty prompt for every key; Esc closes the list.
|
|
411
|
+
|
|
388
412
|
On a terminal 120 columns wide or more, the shell splits in two once you send a request:
|
|
389
413
|
|
|
390
414
|
- **Left (40%)**: the **Session** panel (model and profile, folder, domain, requests so far), the conversation, and the prompt.
|
|
@@ -408,7 +432,7 @@ On a narrower terminal the chat fills the screen, and Ctrl-O switches between th
|
|
|
408
432
|
|
|
409
433
|
**Links.** Report folders, report files, and web addresses in the chat are links: Cmd-click (Ctrl-click on Windows and Linux) opens them in terminals that support links, such as VS Code, iTerm2, Windows Terminal, and recent GNOME Terminal. A path that no longer exists stays plain text.
|
|
410
434
|
|
|
411
|
-
**Setup checklist.** Until everything is set up, the start screen shows
|
|
435
|
+
**Setup checklist.** Until everything is set up, the start screen's **Setup** line shows how many of the five things most features rest on are set up, and names the ones that are not: **Model** (a live model profile), **Coding agent**, **Work items** (Azure DevOps, Jira, TestRail, Zephyr, qTest, or Xray), **Domain**, and **Notifications**. **▸ how to set up** puts the steps for each missing one in the chat (for **Work items**, type `/auth`). The checks are the same ones `/features` uses, so the two always agree.
|
|
412
436
|
|
|
413
437
|
**Command palette.** Ctrl-P opens a palette of every action, command, profile, and example request; type a few letters to find one ("rr" finds *Refine my request*, "auth" finds the sign-in commands), ↑/↓ to choose, Enter to run it. Example requests are put in the prompt for you to edit, never sent straight away.
|
|
414
438
|
|
|
@@ -444,6 +468,8 @@ A read-only agent writes it. It starts knowing every capability and whether it w
|
|
|
444
468
|
| Ctrl-R | Refine the request in the prompt |
|
|
445
469
|
| Ctrl-O | Chat or details, on a terminal narrower than 120 columns |
|
|
446
470
|
| Alt-1 to Alt-3 | Take a suggested next step |
|
|
471
|
+
| Alt-1 to Alt-4 | On the start screen: put a **Start with** action in the prompt |
|
|
472
|
+
| ? | On an empty prompt: every key, in one list |
|
|
447
473
|
| Esc | Ask to stop the run: **Stop run**, **Stop and close ForgeQE**, or **Keep running**. In a panel, back to the prompt |
|
|
448
474
|
| Ctrl-C | Ask to close ForgeQE: **Close** or **Cancel**. A second Ctrl-C closes straight away |
|
|
449
475
|
|
package/docs/user-guide.html
CHANGED
|
@@ -547,7 +547,29 @@ Add a GitHub Actions workflow that runs tests/cases.yaml on every PR, and make i
|
|
|
547
547
|
</code></pre>
|
|
548
548
|
<p>The text is the agent's own result. Failed runs show <code>✗ Task failed</code> with the reason, and stopped runs show <code>■ Run stopped</code>. <code>forgeqe run</code> prints the same summary at the end.</p>
|
|
549
549
|
</div>
|
|
550
|
-
<section class="doc sub" id="doc-how_to_use-the-screen"><h4>The screen</h4><div class="prose"><p>
|
|
550
|
+
<section class="doc sub" id="doc-how_to_use-the-screen"><h4>The screen</h4><div class="prose"><p><strong>The start screen.</strong> Before your first request, the screen is one column under the logo:</p>
|
|
551
|
+
<pre><code> playwright-csharp-pom-framework · .NET · Azure DevOps · azure/gpt-5.4
|
|
552
|
+
|
|
553
|
+
┃ Describe what you want to test…
|
|
554
|
+
✦ Refine ctrl+r ⌕ Search ctrl+p ? shortcuts ctrl+c close
|
|
555
|
+
|
|
556
|
+
Start with
|
|
557
|
+
▸ Run test cases Run test plan 12 suite 34 from Azure DevOps… alt+1
|
|
558
|
+
▸ Check release readiness Are we ready to release? alt+2
|
|
559
|
+
Continue
|
|
560
|
+
▤ Report QI-Payments pipeline status report 18 min ago open
|
|
561
|
+
✓ Run Search hotels in Mumbai on booking.com 2 days ago open rerun
|
|
562
|
+
Setup ▰▰▰▱▱ 3 of 5 · Domain and Notifications are not set up ▸ how to set up
|
|
563
|
+
</code></pre>
|
|
564
|
+
<ul>
|
|
565
|
+
<li>The line under the logo is what ForgeQE works with here: the folder, what it is built with (from its files, such as a <code>.sln</code> or <code>playwright.config.ts</code>), the systems that are set up, and the model.</li>
|
|
566
|
+
<li>The prompt starts five rows high and grows with a long request, up to nearly half the screen, before it scrolls; the sections below give way, last first, to make room.</li>
|
|
567
|
+
<li><strong>Start with</strong> offers up to four things to ask, from the capabilities that work in this setup, each with an example in the words of your setup. Click one, or press Alt-1 to Alt-4, to put it in the prompt to adjust and send.</li>
|
|
568
|
+
<li><strong>Continue</strong> lists the newest reports and runs in the reports folder: <strong>open</strong> opens the report, <strong>rerun</strong> puts the run's goal in the prompt.</li>
|
|
569
|
+
<li><strong>Setup</strong> shows how much is set up and what is missing (see the setup checklist below); it goes away once everything is set up.</li>
|
|
570
|
+
<li>The tip in the footer changes every few seconds. Press <strong>?</strong> on an empty prompt for every key; Esc closes the list.</li>
|
|
571
|
+
</ul>
|
|
572
|
+
<p>On a terminal 120 columns wide or more, the shell splits in two once you send a request:</p>
|
|
551
573
|
<ul>
|
|
552
574
|
<li><strong>Left (40%)</strong>: the <strong>Session</strong> panel (model and profile, folder, domain, requests so far), the conversation, and the prompt.</li>
|
|
553
575
|
<li><strong>Right (60%)</strong>: a status line with who is working, for how long, and a <strong>■ Stop</strong> button (a running suite adds a progress bar, <code>██████░░░░░░ 5/8</code>); then <strong>Plan</strong> (the agent's to-do list, then each browser step or suite case with ✓, ✗ and why, ◐ running), <strong>Activity</strong> (every tool call, browser step, and the agents' reasoning), <strong>Files</strong> (each file read ◉, written ✚, or edited ✎, newest first), <strong>Context</strong> (how full the model's context window is, and the skills, product knowledge, domain, and MCP servers in it), and <strong>Tokens</strong> (total, input, output, cached; no prices, which are in reports and <code>forgeqe models</code>).</li>
|
|
@@ -568,7 +590,7 @@ Add a GitHub Actions workflow that runs tests/cases.yaml on every PR, and make i
|
|
|
568
590
|
</tbody>
|
|
569
591
|
</table></div>
|
|
570
592
|
<p><strong>Links.</strong> Report folders, report files, and web addresses in the chat are links: Cmd-click (Ctrl-click on Windows and Linux) opens them in terminals that support links, such as VS Code, iTerm2, Windows Terminal, and recent GNOME Terminal. A path that no longer exists stays plain text.</p>
|
|
571
|
-
<p><strong>Setup checklist.</strong> Until everything is set up, the start screen shows
|
|
593
|
+
<p><strong>Setup checklist.</strong> Until everything is set up, the start screen's <strong>Setup</strong> line shows how many of the five things most features rest on are set up, and names the ones that are not: <strong>Model</strong> (a live model profile), <strong>Coding agent</strong>, <strong>Work items</strong> (Azure DevOps, Jira, TestRail, Zephyr, qTest, or Xray), <strong>Domain</strong>, and <strong>Notifications</strong>. <strong>▸ how to set up</strong> puts the steps for each missing one in the chat (for <strong>Work items</strong>, type <code>/auth</code>). The checks are the same ones <code>/features</code> uses, so the two always agree.</p>
|
|
572
594
|
<p><strong>Command palette.</strong> Ctrl-P opens a palette of every action, command, profile, and example request; type a few letters to find one ("rr" finds <em>Refine my request</em>, "auth" finds the sign-in commands), ↑/↓ to choose, Enter to run it. Example requests are put in the prompt for you to edit, never sent straight away.</p>
|
|
573
595
|
<p><strong><code>/</code> and <code>@</code> in the prompt.</strong> Typing <code>/</code> lists the commands that match; typing <code>@</code> suggests references: recent runs (<em>last run</em>, then each by its folder), files in the repository by name or path (<code>@Login</code> finds <code>pages/Login.ts</code>), and, after <code>@</code> and a number such as <code>@1761</code>, the Azure DevOps work item with its title (read-only). Tab completes, ↑/↓ choose, Esc closes the list.</p>
|
|
574
596
|
<p><strong>Refine.</strong> Ctrl-R (or <em>Refine my request</em> in the palette) turns what you typed into a task specification for ForgeQE's agent, in ASD-STE100 Simplified Technical English, before it is sent:</p>
|
|
@@ -599,6 +621,8 @@ Result: A shareable report file with a short summary for the manager.
|
|
|
599
621
|
<tr><td>Ctrl-R</td><td>Refine the request in the prompt</td></tr>
|
|
600
622
|
<tr><td>Ctrl-O</td><td>Chat or details, on a terminal narrower than 120 columns</td></tr>
|
|
601
623
|
<tr><td>Alt-1 to Alt-3</td><td>Take a suggested next step</td></tr>
|
|
624
|
+
<tr><td>Alt-1 to Alt-4</td><td>On the start screen: put a <strong>Start with</strong> action in the prompt</td></tr>
|
|
625
|
+
<tr><td>?</td><td>On an empty prompt: every key, in one list</td></tr>
|
|
602
626
|
<tr><td>Esc</td><td>Ask to stop the run: <strong>Stop run</strong>, <strong>Stop and close ForgeQE</strong>, or <strong>Keep running</strong>. In a panel, back to the prompt</td></tr>
|
|
603
627
|
<tr><td>Ctrl-C</td><td>Ask to close ForgeQE: <strong>Close</strong> or <strong>Cancel</strong>. A second Ctrl-C closes straight away</td></tr>
|
|
604
628
|
</tbody>
|
package/package.json
CHANGED
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 }}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { AppConfig } from "../config.ts"
|
|
5
|
+
import { CAPABILITIES } from "../capabilities/registry.ts"
|
|
6
|
+
import { allStatuses, configuredSystems, SYSTEM_LABEL, type Probes, MACHINE } from "../capabilities/status.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the start screen shows before the first request: the project it works on, the actions that work in this setup,
|
|
10
|
+
* and the reports and runs to pick up again. Everything comes from the config, the capability registry, and the files
|
|
11
|
+
* in the folder, so it changes as the setup changes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** "C# · .NET solution · Playwright Test": what the folder's own files say it is built with. */
|
|
15
|
+
export function projectStack(cwd: string): string[] {
|
|
16
|
+
let names: string[] = []
|
|
17
|
+
try {
|
|
18
|
+
names = readdirSync(cwd)
|
|
19
|
+
} catch {
|
|
20
|
+
return []
|
|
21
|
+
}
|
|
22
|
+
const has = (re: RegExp) => names.some((n) => re.test(n))
|
|
23
|
+
const out: string[] = []
|
|
24
|
+
if (has(/^playwright\.config\.(ts|js|mjs|cjs)$/)) out.push("Playwright Test")
|
|
25
|
+
if (has(/^cypress\.config\.(ts|js|mjs|cjs)$/)) out.push("Cypress")
|
|
26
|
+
if (has(/\.sln$/) || has(/\.csproj$/)) out.push(".NET")
|
|
27
|
+
if (has(/^pom\.xml$/)) out.push("Maven")
|
|
28
|
+
if (has(/^build\.gradle(\.kts)?$/)) out.push("Gradle")
|
|
29
|
+
if (has(/^(pytest\.ini|conftest\.py)$/) || (has(/^pyproject\.toml$/) && !out.length)) out.push("Python")
|
|
30
|
+
if (has(/^package\.json$/) && !out.some((s) => s === "Playwright Test" || s === "Cypress")) out.push("Node")
|
|
31
|
+
return out.slice(0, 2)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The project line: folder, stack, the systems ForgeQE works with here, and the model. */
|
|
35
|
+
export function projectLine(cfg: AppConfig | null, cwd: string, model: string): string[] {
|
|
36
|
+
const parts = [path.basename(cwd), ...projectStack(cwd)]
|
|
37
|
+
if (cfg) parts.push(...[...configuredSystems(cfg)].filter((s) => s !== "xray" || !configuredSystems(cfg).has("jira")).map((s) => SYSTEM_LABEL[s]))
|
|
38
|
+
if (model) parts.push(model)
|
|
39
|
+
return parts.filter(Boolean)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ALL_SYSTEMS = Object.values(SYSTEM_LABEL)
|
|
43
|
+
|
|
44
|
+
export type StartAction = { id: string; label: string; detail: string; request: string }
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The first things worth asking, in this order, among the capabilities that work in this setup: work from the systems
|
|
48
|
+
* first, then what a team asks for most. Each puts its example request in the prompt to adjust.
|
|
49
|
+
*/
|
|
50
|
+
const START_ORDER = ["run-test-cases", "release-readiness", "reports", "exploratory-testing", "write-test-cases", "pr-review", "api-testing", "find-run-tests", "browser-goals"]
|
|
51
|
+
|
|
52
|
+
const START_LABEL: Record<string, string> = {
|
|
53
|
+
"run-test-cases": "Run test cases",
|
|
54
|
+
"release-readiness": "Check release readiness",
|
|
55
|
+
reports: "Write a report to share",
|
|
56
|
+
"exploratory-testing": "Explore a story for bugs",
|
|
57
|
+
"write-test-cases": "Write test cases from a story",
|
|
58
|
+
"pr-review": "Review a pull request",
|
|
59
|
+
"api-testing": "Test an API",
|
|
60
|
+
"find-run-tests": "Run the repository's tests",
|
|
61
|
+
"browser-goals": "Try a flow in the browser",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function startActions(cfg: AppConfig | null, limit = 4, probes: Probes = MACHINE): StartAction[] {
|
|
65
|
+
if (!cfg) return []
|
|
66
|
+
const statuses = allStatuses(cfg, probes)
|
|
67
|
+
const systems = [...configuredSystems(cfg)].map((x) => SYSTEM_LABEL[x])
|
|
68
|
+
const out: StartAction[] = []
|
|
69
|
+
for (const id of START_ORDER) {
|
|
70
|
+
const c = CAPABILITIES.find((x) => x.id === id)
|
|
71
|
+
if (!c || statuses.get(id)?.state === "needs_setup") continue
|
|
72
|
+
// An example in the words of this setup: one that names a system that is set up, else one that names none.
|
|
73
|
+
const named = c.examples.find((e) => systems.some((n) => e.includes(n)))
|
|
74
|
+
const neutral = c.examples.find((e) => !ALL_SYSTEMS.some((n) => e.includes(n)))
|
|
75
|
+
const example = named || neutral || c.examples[0] || c.title
|
|
76
|
+
out.push({ id, label: START_LABEL[id] || c.title, detail: example, request: example })
|
|
77
|
+
if (out.length === limit) break
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type RecentItem = { kind: "report" | "run"; title: string; dir: string; open: string; when: Date; status?: string }
|
|
83
|
+
|
|
84
|
+
/** The newest reports and runs in the reports folder: what a returning user picks up again. */
|
|
85
|
+
export function recentWork(outDir: string, limit = 3): RecentItem[] {
|
|
86
|
+
if (!existsSync(outDir)) return []
|
|
87
|
+
const items: RecentItem[] = []
|
|
88
|
+
for (const name of readdirSync(outDir)) {
|
|
89
|
+
const dir = path.join(outDir, name)
|
|
90
|
+
try {
|
|
91
|
+
if (!statSync(dir).isDirectory()) continue
|
|
92
|
+
const when = statSync(dir).mtime
|
|
93
|
+
if (existsSync(path.join(dir, "run.json"))) {
|
|
94
|
+
const run = JSON.parse(readFileSync(path.join(dir, "run.json"), "utf8")) as { goal?: string; status?: string }
|
|
95
|
+
const report = path.join(dir, "report.html")
|
|
96
|
+
items.push({ kind: "run", title: String(run.goal || name), dir, open: existsSync(report) ? report : dir, when, status: run.status })
|
|
97
|
+
} else if (existsSync(path.join(dir, "report.md"))) {
|
|
98
|
+
const first = readFileSync(path.join(dir, "report.md"), "utf8").split("\n")[0] || ""
|
|
99
|
+
const html = path.join(dir, "report.html")
|
|
100
|
+
items.push({ kind: "report", title: first.replace(/^#\s*/, "").trim() || name, dir, open: existsSync(html) ? html : path.join(dir, "report.md"), when })
|
|
101
|
+
}
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
return items.sort((a, b) => b.when.getTime() - a.when.getTime()).slice(0, limit)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** "just now", "12 min ago", "3 h ago", "yesterday", "4 days ago", then the date. */
|
|
108
|
+
export function ago(when: Date, now = new Date()): string {
|
|
109
|
+
const s = Math.max(0, (now.getTime() - when.getTime()) / 1000)
|
|
110
|
+
if (s < 60) return "just now"
|
|
111
|
+
if (s < 3600) return `${Math.floor(s / 60)} min ago`
|
|
112
|
+
if (s < 86400) return `${Math.floor(s / 3600)} h ago`
|
|
113
|
+
if (s < 2 * 86400) return "yesterday"
|
|
114
|
+
if (s < 7 * 86400) return `${Math.floor(s / 86400)} days ago`
|
|
115
|
+
return when.toISOString().slice(0, 10)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** "Domain", "Domain and Notifications", "Model, Domain, and Notifications". */
|
|
119
|
+
export function listWords(words: string[]): string {
|
|
120
|
+
if (words.length < 3) return words.join(" and ")
|
|
121
|
+
return `${words.slice(0, -1).join(", ")}, and ${words.at(-1)}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** "Domain and Notifications are not set up", or, when the names do not fit in `room`, "5 items are not set up". */
|
|
125
|
+
export function setupSentence(missing: string[], room: number): string {
|
|
126
|
+
const named = `${listWords(missing)} ${missing.length === 1 ? "is" : "are"} not set up`
|
|
127
|
+
return named.length <= room ? named : `${missing.length} ${missing.length === 1 ? "item is" : "items are"} not set up`
|
|
128
|
+
}
|
package/src/tui/palette.tsx
CHANGED
|
@@ -15,7 +15,7 @@ export type PaletteRun =
|
|
|
15
15
|
/** Put in the prompt to edit and send: an example request. */
|
|
16
16
|
| { kind: "insert"; text: string }
|
|
17
17
|
/** Something the shell does itself. */
|
|
18
|
-
| { kind: "action"; id: "refine" | "stop" | "details" | "chat" | "wide" | "panels" }
|
|
18
|
+
| { kind: "action"; id: "refine" | "stop" | "details" | "chat" | "wide" | "panels" | "shortcuts" }
|
|
19
19
|
|
|
20
20
|
export type PaletteItem = { id: string; group: PaletteGroup; title: string; detail?: string; keys?: string; run: PaletteRun }
|
|
21
21
|
|
|
@@ -31,6 +31,7 @@ export function paletteItems(opts: { running: boolean; headed: boolean; detailsF
|
|
|
31
31
|
if (!opts.chatWide) items.push({ id: "panels", group: "Action", title: "Move to the panels: plan, activity, files", keys: "shift+tab", run: { kind: "action", id: "panels" } })
|
|
32
32
|
}
|
|
33
33
|
if (opts.narrow) items.push({ id: "view", group: "Action", title: opts.view === "chat" ? "Show the details view" : "Back to the chat", keys: "ctrl+o", run: { kind: "action", id: opts.view === "chat" ? "details" : "chat" } })
|
|
34
|
+
items.push({ id: "shortcuts", group: "Action", title: "Keyboard shortcuts", keys: "?", run: { kind: "action", id: "shortcuts" } })
|
|
34
35
|
command("features", "What ForgeQE can do here", "/features", "and what each feature still needs")
|
|
35
36
|
command("auth", "Sign in to a system", "/auth", "Azure DevOps, GitHub, Jira, Confluence, TestRail, Zephyr, qTest, Xray")
|
|
36
37
|
command("auth-status", "What is signed in", "/auth status")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { theme } from "./theme.ts"
|
|
2
|
+
|
|
3
|
+
/** Every key the shell answers to, in one panel (? on an empty prompt); esc or ? closes it. */
|
|
4
|
+
export const SHORTCUTS: { group: string; keys: [string, string][] }[] = [
|
|
5
|
+
{
|
|
6
|
+
group: "Prompt",
|
|
7
|
+
keys: [
|
|
8
|
+
["enter", "send the request"],
|
|
9
|
+
["shift+enter, ctrl+j", "new line"],
|
|
10
|
+
["ctrl+r", "refine the request before it is sent"],
|
|
11
|
+
["/ @", "commands; runs, files, and work items to reference"],
|
|
12
|
+
["tab", "complete a suggestion, or the next model profile"],
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
group: "Find and move",
|
|
17
|
+
keys: [
|
|
18
|
+
["ctrl+p", "search: actions, commands, profiles, examples"],
|
|
19
|
+
["shift+tab", "move between the chat and the plan, activity, and files panels"],
|
|
20
|
+
["ctrl+o", "chat or details, on a narrow terminal"],
|
|
21
|
+
["alt+1 to alt+4", "a start action, or a next step"],
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
group: "Run",
|
|
26
|
+
keys: [
|
|
27
|
+
["esc", "stop the request (asks first)"],
|
|
28
|
+
["ctrl+c", "close ForgeQE (asks first; twice closes)"],
|
|
29
|
+
["drag", "copy the selected text"],
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export function ShortcutsPanel(props: { width: number; wide: boolean; onClose: () => void }) {
|
|
35
|
+
const boxWidth = Math.min(84, props.width - 8)
|
|
36
|
+
const keyWidth = 22
|
|
37
|
+
return (
|
|
38
|
+
<box
|
|
39
|
+
position="absolute"
|
|
40
|
+
zIndex={100}
|
|
41
|
+
border
|
|
42
|
+
borderStyle="rounded"
|
|
43
|
+
borderColor={theme.accent}
|
|
44
|
+
title=" Keyboard shortcuts "
|
|
45
|
+
titleColor={theme.accent}
|
|
46
|
+
bottomTitle=" esc close "
|
|
47
|
+
bottomTitleAlignment="right"
|
|
48
|
+
onMouseDown={props.onClose}
|
|
49
|
+
style={{ top: 2, left: Math.floor((props.width - boxWidth) / 2), width: boxWidth, flexDirection: "column", backgroundColor: theme.panel, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}
|
|
50
|
+
>
|
|
51
|
+
{SHORTCUTS.map((g) => (
|
|
52
|
+
<box key={g.group} style={{ flexDirection: "column", marginBottom: 1, flexShrink: 0 }}>
|
|
53
|
+
<text fg={theme.muted}>{g.group}</text>
|
|
54
|
+
{g.keys
|
|
55
|
+
.filter(([k]) => props.wide || k !== "shift+tab")
|
|
56
|
+
.map(([k, what]) => (
|
|
57
|
+
<text key={k}>
|
|
58
|
+
<span fg={theme.text}>{` ${k.padEnd(keyWidth)}`}</span>
|
|
59
|
+
<span fg={theme.muted}>{what}</span>
|
|
60
|
+
</text>
|
|
61
|
+
))}
|
|
62
|
+
</box>
|
|
63
|
+
))}
|
|
64
|
+
</box>
|
|
65
|
+
)
|
|
66
|
+
}
|