@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 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 what most features rest on: **Model** (a live model profile), **Coding agent**, **Work items** (Azure DevOps, Jira, TestRail, Zephyr, qTest, or Xray), **Domain**, and **Notifications**, each ✓ or ○. Click an open item to see how to set it up; **Work items** starts `/auth`. The checks are the same ones `/features` uses, so the two always agree.
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
 
@@ -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>On a terminal 120 columns wide or more, the shell splits in two once you send a request:</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 what most features rest on: <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>, each ✓ or ○. Click an open item to see how to set it up; <strong>Work items</strong> starts <code>/auth</code>. The checks are the same ones <code>/features</code> uses, so the two always agree.</p>
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 (&quot;rr&quot; finds <em>Refine my request</em>, &quot;auth&quot; 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "ForgeQE: a browser agent CLI that runs goals and test cases in Chrome and turns passing runs into tests in your repo",
5
5
  "license": "MIT",
6
6
  "type": "module",
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 }}
@@ -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
+ }
@@ -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
+ }