@capagents/forgeqe 1.1.7 → 1.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui/App.tsx CHANGED
@@ -30,10 +30,11 @@ import { initWorkspace } from "../init.ts"
30
30
  import { ShortcutsPanel } from "./shortcuts.tsx"
31
31
  import { ago, getStarted, projectLine, setupSentence, type Fact, type GetStarted, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
32
32
  import { TIPS, formatDuration, formatTokens, packageVersion, shortCwd, theme } from "./theme.ts"
33
- import { emptyProduced, Line, Produced, producedCard, settleCall, sinceRequest, TestsSetting, TranscriptLine, trimLines } from "./transcript.tsx"
33
+ import { emptyProduced, Line, LineRoom, Produced, producedCard, settleCall, sinceRequest, TestsSetting, TranscriptLine, trimLines } from "./transcript.tsx"
34
34
  import { Dialog, dialogSpec } from "./dialogs.ts"
35
35
  import { activityFromLog, clip, modelLabel } from "./text.ts"
36
36
  import { featuresText } from "../capabilities/render.ts"
37
+ import { answerText, attemptCard, reviewCard } from "../code/exchange.ts"
37
38
  import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
38
39
  import { commandSuggestions, complete, lookupWorkItem, recentRuns, referenceSuggestions, repoFiles, triggerAt, type Suggestion, type WorkItem } from "./suggest.ts"
39
40
  import { initialChoice, REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
@@ -988,6 +989,17 @@ export function App({ options }: { options: TuiOptions }) {
988
989
  else if (ev.kind === "say") record("thought", ev.text, shown)
989
990
  else if (ev.kind === "tool") pushTool(ev.text, ev.ok, ev.id, shown)
990
991
  else if (ev.kind === "test") record(ev.ok ? "ok" : "err", ev.text, shown)
992
+ // The exchange with the reviewer: its verdict and Scribe's retries as cards in the conversation, each with a
993
+ // line in Activity, so it is clear why a test was rejected and what is being fixed.
994
+ else if (ev.kind === "review") {
995
+ const card = reviewCard(ev)
996
+ pushLine({ kind: "card", text: card.title, card })
997
+ record(ev.approved ? "ok" : "err", `Review, attempt ${ev.attempt}/${ev.max}: ${ev.approved ? "approved" : `changes needed (${ev.issues.length})`} · ${clip(ev.summary, 140)}`, false)
998
+ } else if (ev.kind === "attempt") {
999
+ const card = attemptCard(ev)
1000
+ pushLine({ kind: "card", text: card.title, card })
1001
+ record("code", `Attempt ${ev.attempt}/${ev.max}: ${ev.why}${ev.fixing.length ? ` · fixing ${ev.fixing.length}` : ""}`, false)
1002
+ } else if (ev.kind === "answer") record("code", answerText(ev), true)
991
1003
  }
992
1004
  if (aborted()) return
993
1005
  const who = codingRef.current ? "Coding agent" : "Agent"
@@ -995,6 +1007,8 @@ export function App({ options }: { options: TuiOptions }) {
995
1007
  else if (ev.kind === "tool") setActivity(`${who}: ${clip(ev.text, 60)}`)
996
1008
  else if (ev.kind === "info" && ev.text.startsWith("ForgeQE runs the test")) setActivity("ForgeQE is running the new test…")
997
1009
  else if (ev.kind === "say") setActivity(`${who} is working…`)
1010
+ else if (ev.kind === "review") setActivity(ev.approved ? "The reviewer approved the test" : `Scribe is fixing ${ev.issues.length} review ${ev.issues.length === 1 ? "issue" : "issues"}…`)
1011
+ else if (ev.kind === "attempt") setActivity(`Scribe, attempt ${ev.attempt} of ${ev.max}: ${clip(ev.why, 50)}…`)
998
1012
  return
999
1013
  }
1000
1014
  case "code-start": {
@@ -1626,7 +1640,7 @@ export function App({ options }: { options: TuiOptions }) {
1626
1640
  const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
1627
1641
  const recentRows = recent.length ? 1 + 1 + recent.length : 0
1628
1642
  const setupRows = loading || open.length ? 2 : 0
1629
- const fixed = 5 + 2 + 1 + 3 + 1 + 1 + 1 // logo, project line, gap, prompt border, buttons, footer, one row to spare
1643
+ const fixed = 4 + 2 + 1 + 3 + 1 + 1 + 1 // logo and byline, project line, gap, prompt border, buttons, footer, one row to spare
1630
1644
  const room = height - fixed
1631
1645
  const want = Math.max(5, Math.min(promptLines, room - 1))
1632
1646
  // Refine or a question in the prompt's place takes the room the sections would: they are for starting, not deciding.
@@ -1661,7 +1675,7 @@ export function App({ options }: { options: TuiOptions }) {
1661
1675
  </box>
1662
1676
  <box style={{ height: 1, flexShrink: 0 }} />
1663
1677
  <box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0 }}>
1664
- {renderControls(promptWidth, "Describe what you want to test…", [5, maxRows], height - 5 - 2 - 1 - 1 - 1)}
1678
+ {renderControls(promptWidth, "Describe what you want to test…", [5, maxRows], height - 4 - 2 - 1 - 1 - 1)}
1665
1679
  {showStart ? (
1666
1680
  <box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
1667
1681
  {heading(offerTitle)}
@@ -1813,10 +1827,12 @@ export function App({ options }: { options: TuiOptions }) {
1813
1827
  )
1814
1828
  }
1815
1829
 
1816
- const transcript = (every: Line[], style: Record<string, unknown>) => {
1830
+ /** `room`: the columns a line of the conversation has (less the scrollbar), so long links are drawn to fit. */
1831
+ const transcript = (every: Line[], style: Record<string, unknown>, room: number) => {
1817
1832
  // Quiet lines (reads, searches, calls in progress) are the Activity panel's; the conversation stays readable.
1818
1833
  const all = every.filter((l) => !l.quiet)
1819
1834
  return (
1835
+ <LineRoom.Provider value={Math.max(20, room - 1)}>
1820
1836
  <scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
1821
1837
  {all.map((line, i) => (
1822
1838
  <TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
@@ -1832,6 +1848,7 @@ export function App({ options }: { options: TuiOptions }) {
1832
1848
  </box>
1833
1849
  ) : null}
1834
1850
  </scrollbox>
1851
+ </LineRoom.Provider>
1835
1852
  )
1836
1853
  }
1837
1854
  // A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
@@ -1847,7 +1864,7 @@ export function App({ options }: { options: TuiOptions }) {
1847
1864
  <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1848
1865
  <box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1849
1866
  {infoPanels(leftWidth - 3)}
1850
- {transcript(chatLines, {})}
1867
+ {transcript(chatLines, {}, leftWidth - 3)}
1851
1868
  <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1852
1869
  </box>
1853
1870
  <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
@@ -1883,7 +1900,7 @@ export function App({ options }: { options: TuiOptions }) {
1883
1900
  }
1884
1901
  return (
1885
1902
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1886
- {transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 })}
1903
+ {transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 }, width - 4)}
1887
1904
  <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>{controls(promptWidth)}</box>
1888
1905
  <Footer left={shortCwd()} right={footerRight} width={width} />
1889
1906
  {paletteOverlay}
@@ -18,14 +18,15 @@ export const BAR = {
18
18
  }
19
19
 
20
20
  export function Logo() {
21
- const name = logoRows("forge")
21
+ // One wordmark in capitals, FORGE in the text colour and QE in the accent: one height, weight, and baseline.
22
+ const name = logoRows("FORGE")
22
23
  const suffix = logoRows("QE")
23
24
  return (
24
25
  <box style={{ flexDirection: "column", alignItems: "flex-end", flexShrink: 0 }}>
25
26
  {name.map((row, i) => (
26
27
  <text key={i}>
27
- <span fg={theme.faint}>{row}</span>
28
- <span fg={theme.text}>{` ${suffix[i]}`}</span>
28
+ <span fg={theme.text}>{row}</span>
29
+ <span fg={theme.accent}>{` ${suffix[i]}`}</span>
29
30
  </text>
30
31
  ))}
31
32
  <text fg={theme.muted}>by Capgemini</text>
package/src/tui/text.ts CHANGED
@@ -31,3 +31,19 @@ export function activityFromLog(text: string): string {
31
31
  if (text.startsWith("ForgeQE engine ·")) return "Starting the browser…"
32
32
  return clip(text, 70)
33
33
  }
34
+
35
+ /**
36
+ * A link's text shortened to `max` columns, so it never wraps into what reads as two links. The start and the last
37
+ * part (the file, or the folder of a path ending in "/") are kept: ".forgeqe/reports/open-defect-rep…/report.html".
38
+ * The link itself keeps its whole target; only what is drawn is shorter.
39
+ */
40
+ export function shortLink(text: string, max: number): string {
41
+ if (text.length <= max || max < 8) return text
42
+ const trimmed = text.replace(/\/$/, "")
43
+ const slash = trimmed.lastIndexOf("/")
44
+ const tail = slash > 0 ? text.slice(slash) : ""
45
+ // The last part, whole, when it leaves room for a useful start; otherwise both ends of the text.
46
+ if (tail && tail.length <= max - 10) return `${text.slice(0, max - tail.length - 1)}…${tail}`
47
+ const end = Math.floor((max - 1) / 2)
48
+ return `${text.slice(0, max - 1 - end)}…${text.slice(text.length - end)}`
49
+ }
package/src/tui/theme.ts CHANGED
@@ -35,35 +35,34 @@ export function formatDuration(ms: number, precise = false): string {
35
35
  return `${m}m ${String(Math.floor(s % 60)).padStart(2, "0")}s`
36
36
  }
37
37
 
38
- /** Four rows: ascenders, then a three-row x-height. Capitals use all four. */
39
- const GLYPHS: Record<string, [string, string, string, string]> = {
40
- w: [" ", "█ █", "█ █ █", "▀▀▀▀▀"],
41
- e: [" ", "█▀▀█", "█▀▀▀", "▀▀▀▀"],
42
- b: ["▄ ", "█▀▀█", "█ █", "▀▀▀▀"],
43
- p: [" ", "█▀▀█", "█ █", "█▀▀▀"],
44
- i: [" ", "▀", "█", "▀"],
45
- l: ["▄", "█", "█", "▀"],
46
- o: [" ", "█▀▀█", "█ █", "▀▀▀▀"],
47
- t: [" ▄ ", "▀█▀▀", " █ ", " ▀▀▀"],
48
- r: [" ", "█▀▀", "█ ", "▀ "],
49
- c: [" ", "█▀▀▀", "█ ", "▀▀▀▀"],
50
- h: ["▄ ", "█▀▀█", "█ █", "▀ ▀"],
51
- s: [" ", "█▀▀▀", "▀▀▀█", "▀▀▀▀"],
52
- a: [" ", "▀▀▀█", "█▀▀█", "▀▀▀▀"],
53
- f: [" ▄▀", "▀█▀", " █ ", " ▀ "],
54
- g: [" ", "█▀▀█", "█ █", "▀▀▀█"],
55
- Q: ["█▀▀█ ", "█ █ ", "█ █ ", "▀▀▀█▄"],
56
- E: ["█▀▀▀", "█▄▄ ", "█ ", "▀▀▀▀"],
57
- ".": [" ", " ", " ", "▀"],
38
+ /**
39
+ * The wordmark's capitals as pixel maps, five pixels tall: every letter has the same weight, height and baseline. A
40
+ * sixth row, left blank, is the Q's tail. Two pixel rows go in one text row as half blocks, so it is three rows high.
41
+ */
42
+ const LETTERS: Record<string, string[]> = {
43
+ F: ["####", "#...", "###.", "#...", "#..."],
44
+ O: ["####", "#..#", "#..#", "#..#", "####"],
45
+ R: ["####", "#..#", "###.", "#..#", "#..#"],
46
+ G: ["####", "#...", "#.##", "#..#", "####"],
47
+ E: ["####", "#...", "###.", "#...", "####"],
48
+ Q: ["####.", "#..#.", "#..#.", "#..#.", "####.", "...##"],
58
49
  }
59
50
 
51
+ /** The letters of `word` (capitals; others are left out) as three rows of half blocks, one space between letters. */
60
52
  export function logoRows(word: string): string[] {
61
- const rows = ["", "", "", ""]
62
- const letters = [...word]
63
- letters.forEach((ch, index) => {
64
- const glyph = GLYPHS[ch] ?? GLYPHS[ch.toLowerCase()]
65
- if (!glyph) return
66
- for (let r = 0; r < 4; r++) rows[r] += (index ? " " : "") + glyph[r]
53
+ const rows = ["", "", ""]
54
+ ;[...word.toUpperCase()].filter((ch) => LETTERS[ch]).forEach((ch, index) => {
55
+ const px = LETTERS[ch]!
56
+ const at = (r: number, c: number) => px[r]?.[c] === "#"
57
+ for (let r = 0; r < 3; r++) {
58
+ let cells = ""
59
+ for (let c = 0; c < px[0]!.length; c++) {
60
+ const top = at(r * 2, c)
61
+ const bottom = at(r * 2 + 1, c)
62
+ cells += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " "
63
+ }
64
+ rows[r] += (index ? " " : "") + cells
65
+ }
67
66
  })
68
67
  return rows
69
68
  }
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs"
2
2
  import os from "node:os"
3
3
  import path from "node:path"
4
4
  import { SyntaxStyle } from "@opentui/core"
5
- import type { ReactNode } from "react"
5
+ import { createContext, useContext, type ReactNode } from "react"
6
6
 
7
7
  import type { CodeResult, RunStatus } from "../types.ts"
8
8
  import { CARD_MARK, type Card } from "../card.ts"
@@ -11,7 +11,7 @@ import { suiteExitCode, type SuiteResult } from "../suite/run.ts"
11
11
  import { STATUS_MARK, shortPath, type RunSummary } from "../summary.ts"
12
12
  import { BAR } from "./components.tsx"
13
13
  import { formatDuration, formatTokens, theme } from "./theme.ts"
14
- import { clip } from "./text.ts"
14
+ import { clip, shortLink } from "./text.ts"
15
15
 
16
16
  /** The transcript: its lines, and the cards for a run, a suite, a tool result, and what a request produced. */
17
17
 
@@ -195,7 +195,7 @@ export function SummaryCard({ summary }: { summary: RunSummary }) {
195
195
  {summary.savedDir ? (
196
196
  <text>
197
197
  <span fg={theme.faint}>{"Saved "}</span>
198
- <Linked text={`${summary.savedDir}/ `} color={theme.muted} />
198
+ <Linked text={`${summary.savedDir}/ `} color={theme.muted} lead={UNDER + 10} />
199
199
  <span fg={theme.faint}>{summary.savedFiles.join(" · ")}</span>
200
200
  </text>
201
201
  ) : null}
@@ -218,14 +218,27 @@ export function linkTarget(token: string, cwd = process.cwd()): string | null {
218
218
  * Text whose web addresses and existing file paths can be opened from the terminal (OSC 8 links: cmd/ctrl+click in
219
219
  * VS Code and most terminals). A path that does not exist stays plain text, so no link leads nowhere.
220
220
  */
221
- export function Linked({ text, color }: { text: string; color: string }) {
222
- const parts: { text: string; href?: string }[] = []
221
+ /** The columns a line of the conversation has, for links to fit in; 0 when not known (no shortening). */
222
+ export const LineRoom = createContext(0)
223
+
224
+ /** Columns before a card's line of text: the "⎿" gutter under the card's title. */
225
+ const UNDER = 4
226
+
227
+ /**
228
+ * `lead`: the columns before this text on its row (a gutter, a label in another span). A link longer than what is left
229
+ * of the row is drawn shortened, its target whole, so it moves to the next row in one piece instead of splitting.
230
+ */
231
+ export function Linked({ text, color, lead = UNDER }: { text: string; color: string; lead?: number }) {
232
+ const room = useContext(LineRoom)
233
+ const parts: { text: string; href?: string; max?: number }[] = []
223
234
  let at = 0
224
235
  for (const m of text.matchAll(LINKABLE)) {
225
236
  const href = linkTarget(m[0])
226
237
  if (!href) continue
227
238
  if (m.index! > at) parts.push({ text: text.slice(at, m.index) })
228
- parts.push({ text: m[0], href })
239
+ // Beside its label when a useful part fits there ("Page .forgeqe/…/report.html"), else alone on the next row.
240
+ const beside = room - lead - m.index! - 1
241
+ parts.push({ text: m[0], href, ...(room ? { max: beside >= 30 ? beside : room - lead - 1 } : {}) })
229
242
  at = m.index! + m[0].length
230
243
  }
231
244
  if (at < text.length) parts.push({ text: text.slice(at) })
@@ -234,7 +247,7 @@ export function Linked({ text, color }: { text: string; color: string }) {
234
247
  {parts.map((p, i) =>
235
248
  p.href ? (
236
249
  <a key={i} href={p.href} fg={theme.accent}>
237
- {p.text}
250
+ {p.max ? shortLink(p.text, p.max) : p.text}
238
251
  </a>
239
252
  ) : (
240
253
  <span key={i} fg={color}>
@@ -251,7 +264,7 @@ export function Row({ label, value, color = theme.muted }: { label: string; valu
251
264
  return (
252
265
  <text>
253
266
  <span fg={theme.faint}>{label.padEnd(10)}</span>
254
- <Linked text={value} color={color} />
267
+ <Linked text={value} color={color} lead={UNDER + 10} />
255
268
  </text>
256
269
  )
257
270
  }
@@ -307,7 +320,7 @@ export const CARD_COLOR: Record<Card["status"], string> = { ok: theme.ok, fail:
307
320
 
308
321
  export function ResultCard({ card }: { card: Card }) {
309
322
  const color = CARD_COLOR[card.status]
310
- const lineColor = (l: string) => (/^\s*✓/.test(l) ? theme.ok : /^\s*[✗!]/.test(l) ? theme.err : /^\s*\?/.test(l) ? theme.warn : theme.muted)
323
+ const lineColor = (l: string) => (/^\s*✓/.test(l) ? theme.ok : /^\s*[✗!]/.test(l) ? theme.err : /^\s*[?~]/.test(l) ? theme.warn : /^\s*(Fix:|→)/.test(l) ? theme.text : theme.muted)
311
324
  // What a run used is in the footer and the reports; price talk stays out of the conversation.
312
325
  const body = card.body.filter((l) => !/^\s*Usage\s{2,}/.test(l)).filter((l, i, all) => l || (i > 0 && i < all.length - 1))
313
326
  return (
package/src/types.ts CHANGED
@@ -136,6 +136,12 @@ export type CodeEvent =
136
136
  | { type: "code"; kind: "usage"; usage: Usage; context?: number }
137
137
  /** The agent's to-do list, each time it changes. */
138
138
  | { type: "code"; kind: "plan"; items: PlanItem[] }
139
+ /** The reviewer's verdict on a test that passed: approved or not, why, and each issue with its file and fix. */
140
+ | { type: "code"; kind: "review"; attempt: number; max: number; approved: boolean; summary: string; issues: CodeReview["issues"] }
141
+ /** Scribe starts another attempt: why, and what it was asked to fix (the reviewer's issues, the test's error). */
142
+ | { type: "code"; kind: "attempt"; attempt: number; max: number; why: string; fixing: string[] }
143
+ /** What Scribe said it did in an attempt: its notes, and the files it changed. */
144
+ | { type: "code"; kind: "answer"; attempt: number; notes?: string; changed: string[] }
139
145
 
140
146
  export type AgentEvent =
141
147
  | { type: "log"; level: "info" | "warn" | "error"; text: string }