@capagents/forgeqe 2.0.0 → 2.0.2

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
@@ -74,7 +74,8 @@ Everything ForgeQE can do, by area. ForgeQE's agent answers "can you…?" questi
74
74
  | Operations | **Teams and email notifications**: Tells the team in Microsoft Teams channels or by email when a suite fails or passes, when a release readiness verdict is in, and when the domain evaluation falls below its gate, with the failing cases, their bugs, and links to the report and the CI build. Each channel picks its events; the agent also posts a summary to a channel when asked. | `notify` | [docs](#17-notifications) |
75
75
  | Release | **Reports you can share**: Writes up what ForgeQE's agent found, such as open defects across projects, release status, or pipeline health, as a page with filters (project, state, priority, assignee, …), charts, and a sortable table, plus Markdown and CSV. Every table is built from evidence: the results of the tool calls the agent made, or a read-only query ForgeQE runs itself, listed on the page's Sources with each raw result kept beside the report. | `write_report` | [docs](#reports-from-the-agent) |
76
76
  | Operations | **CI pipelines**: Writes a GitHub Actions or Azure Pipelines workflow that runs ForgeQE, stores the secrets it needs, pushes a branch, and makes the pipeline green. | `push_branch`, `set_ci_secret` | [docs](#8-pipelines) |
77
- | Systems | **Work with Azure DevOps, Jira, GitHub, and Confluence**: Reads anything in the configured systems (work items, issues, queries, boards, pages), makes changes the user asks for, puts run evidence (screenshots, reports) on work items and issues as attachments or comments, and builds Azure DevOps dashboards and wiki pages. | `query_api`, `read_confluence`, `create_ado_dashboard`, `write_ado_wiki_page`, `attach_to_work_item` | [docs](README.md#azure-devops-jira-and-other-systems) |
77
+ | Systems | **Work with Azure DevOps, Jira, GitHub, and Confluence**: Reads anything in the configured systems (work items, issues, queries, boards, pages), makes changes the user asks for, puts run evidence (screenshots, reports) on work items and issues as attachments or comments, and builds Azure DevOps dashboards and wiki pages. | `read_confluence`, `create_ado_dashboard`, `write_ado_wiki_page`, `attach_to_work_item` | [docs](README.md#azure-devops-jira-and-other-systems) |
78
+ | Systems | **Read any data in Azure DevOps, Jira, Xray, and Confluence**: Reads through a system's REST API what its MCP tools do not cover: pull requests and repositories, test plans and suites, classic releases, and anything else in Azure DevOps, Jira, Xray, or Confluence. Given on every turn once a system is set up, so the agent never turns to the browser or the shell to read it. | `query_api` | [docs](README.md#azure-devops-jira-and-other-systems) |
78
79
  | Setup | **Setup, skills, and documentation**: Shows the model profiles and MCP servers, installs or repairs the browser engine, creates a forgeqe.yaml, manages skills, and answers questions about ForgeQE from its documentation and this list. | `forgeqe_setup`, `capabilities`, `playbook`, `use_capabilities` | [docs](#2-scaffold-config) |
79
80
 
80
81
  Not supported: Native mobile apps (iOS, Android) and desktop applications (instead: web applications in local Chrome or Chromium, or configured remote web grids such as BrowserStack and TestMu AI/LambdaTest); Load, stress, or soak testing with many simultaneous users (instead: per-page Core Web Vitals with budgets); Penetration testing or dynamic security scanning (instead: security findings in pull request reviews).
@@ -389,7 +389,7 @@ forgeqe docs --copy ./docs # copy the guide, the architecture page, and the re
389
389
  <div class="area"><a class="area-name" href="#area-api"><span class="mono">API</span></a><ul class="tiles"><li><a class="tile" href="#cap-api-testing"><b>API and contract testing</b><span>Turn the last run's API calls into tests</span></a></li></ul></div>
390
390
  <div class="area"><a class="area-name" href="#area-quality"><span class="mono">Quality</span></a><ul class="tiles"><li><a class="tile" href="#cap-accessibility-performance"><b>Accessibility and page performance</b><span>Is the checkout page accessible?</span></a></li><li><a class="tile" href="#cap-visual-comparison"><b>Visual comparison with baselines</b><span>What changed visually since the last run?</span></a></li><li><a class="tile" href="#cap-defect-intelligence"><b>Defect intelligence: trends and prediction</b><span>Which defects keep coming back?</span></a></li></ul></div>
391
391
  <div class="area"><a class="area-name" href="#area-release"><span class="mono">Release</span></a><ul class="tiles"><li><a class="tile" href="#cap-release-readiness"><b>Requirement coverage and release readiness</b><span>Are we ready to release?</span></a></li><li><a class="tile" href="#cap-reports"><b>Reports you can share</b><span>Create a release report for all projects</span></a></li></ul></div>
392
- <div class="area"><a class="area-name" href="#area-systems"><span class="mono">Systems</span></a><ul class="tiles"><li><a class="tile" href="#cap-systems"><b>Work with Azure DevOps, Jira, GitHub, and Confluence</b><span>Show the open bugs in this sprint</span></a></li></ul></div>
392
+ <div class="area"><a class="area-name" href="#area-systems"><span class="mono">Systems</span></a><ul class="tiles"><li><a class="tile" href="#cap-systems"><b>Work with Azure DevOps, Jira, GitHub, and Confluence</b><span>Show the open bugs in this sprint</span></a></li><li><a class="tile" href="#cap-system-data"><b>Read any data in Azure DevOps, Jira, Xray, and Confluence</b><span>Are there open pull requests in any repository of QI-Claims?</span></a></li></ul></div>
393
393
  <h3 class="band-title">Not supported</h3>
394
394
  <ul class="plainlist"><li><b>Native mobile apps (iOS, Android) and desktop applications</b>. Instead: web applications in local Chrome or Chromium, or configured remote web grids such as BrowserStack and TestMu AI/LambdaTest.</li><li><b>Load, stress, or soak testing with many simultaneous users</b>. Instead: per-page Core Web Vitals with budgets.</li><li><b>Penetration testing or dynamic security scanning</b>. Instead: security findings in pull request reviews.</li></ul>
395
395
  </section>
@@ -2505,7 +2505,7 @@ forgeqe visual approve --page /home # make this run's /home the baseline (asks
2505
2505
  <div class="facts">
2506
2506
  <div class="fact"><span class="mono">Ask it</span><ul class="asks"><li>Show the open bugs in this sprint</li><li>Read the checkout requirements from Confluence</li><li>Make a dashboard of this sprint's test results</li><li>Add the last screenshot of that run to ADO 256 as a comment</li><li>Run the GitHub issues labelled login as test cases</li></ul></div>
2507
2507
  <div class="fact"><span class="mono">What it needs</span><ul class="needs"><li><span class="need req">Required</span><span>Azure DevOps or Jira or Xray or Confluence: set up under sources: in forgeqe.yaml, or sign in with /auth</span></li></ul></div>
2508
- <div class="fact"><span class="mono">Tools</span><p class="tools"><code>query_api</code> <code>read_confluence</code> <code>create_ado_dashboard</code> <code>write_ado_wiki_page</code> <code>attach_to_work_item</code></p></div>
2508
+ <div class="fact"><span class="mono">Tools</span><p class="tools"><code>read_confluence</code> <code>create_ado_dashboard</code> <code>write_ado_wiki_page</code> <code>attach_to_work_item</code></p></div>
2509
2509
  </div>
2510
2510
  <div class="detail"><span class="mono">In detail</span>
2511
2511
  <section class="doc" id="doc-readme-azure-devops-jira-and-other-systems">
@@ -2542,6 +2542,18 @@ mcp:
2542
2542
  </section>
2543
2543
  </div>
2544
2544
  </article>
2545
+ <article class="spec feat" id="cap-system-data">
2546
+ <div class="hd"><span class="mono">Systems</span><h3>Read any data in Azure DevOps, Jira, Xray, and Confluence</h3></div>
2547
+ <p class="ds">Reads through a system's REST API what its MCP tools do not cover: pull requests and repositories, test plans and suites, classic releases, and anything else in Azure DevOps, Jira, Xray, or Confluence. Given on every turn once a system is set up, so the agent never turns to the browser or the shell to read it.</p>
2548
+ <div class="facts">
2549
+ <div class="fact"><span class="mono">Ask it</span><ul class="asks"><li>Are there open pull requests in any repository of QI-Claims?</li><li>List the test plans in QI-MobileBanking with their suites</li></ul></div>
2550
+ <div class="fact"><span class="mono">What it needs</span><ul class="needs"><li><span class="need req">Required</span><span>Azure DevOps or Jira or Xray or Confluence: set up under sources: in forgeqe.yaml, or sign in with /auth</span></li></ul></div>
2551
+ <div class="fact"><span class="mono">Tools</span><p class="tools"><code>query_api</code></p></div>
2552
+ </div>
2553
+ <div class="detail"><span class="mono">In detail</span>
2554
+ <p class="see">See <a href="#doc-readme-azure-devops-jira-and-other-systems">Azure DevOps, Jira, and other systems</a> (from the README).</p>
2555
+ </div>
2556
+ </article>
2545
2557
  </section>
2546
2558
  <section class="chapter" id="reference" aria-labelledby="reference-h">
2547
2559
  <header class="chead"><span class="mono">16</span><h2 id="reference-h">Reference</h2><p>MCP, tuning, logs, environment variables, and every command</p></header>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/prompts/brain.md CHANGED
@@ -53,11 +53,11 @@ Read data from Azure DevOps, Jira, Xray, Confluence, and other systems in this s
53
53
  **On failure**:
54
54
  - When a system refuses the credentials (401, 403, or "needs <NAME> in the environment"), tell the user to type /auth <system>. Outside the shell, it is forgeqe auth login <system>.
55
55
  - /auth signs in through the browser and saves the token. You get it on the next message.
56
- - When the MCP tools have no tool for the data, use query_api. You MUST NOT use the shell, curl, or a script instead.
56
+ - When the MCP tools have no tool for the data (pull requests, test plans, releases), use query_api. You MUST NOT use the browser, the shell, curl, or a script to read data that a system's API gives.
57
57
  - When a browser_run fails and gives resume steps, run the same task again with resume_from and that run_dir, by the playbook "Browser runs".
58
58
  - When the browser engine does not start or is missing, use forgeqe_setup install_engine. Use force to install it again.
59
59
 
60
- **Evidence**: give the ids and links of the items that you read or changed, the run folders and report files, and the tool results that your answer depends on.
60
+ **Evidence**: give the work item ids, the run folders and report files, and the tool calls that your answer depends on, in one short "Source:" line. Do not repeat what the answer already shows.
61
61
 
62
62
  ## Rules
63
63
  - You MUST NOT print, echo, or write the value of a secret or API key. Use the name of its environment variable.
@@ -356,7 +356,9 @@ The shell shows replies as markdown in a narrow chat pane. Write them so that th
356
356
  - When there is more than one group, you SHOULD give each group a short `##` heading.
357
357
  - You SHOULD write bullets of one line each.
358
358
  - You MUST use bold only for the one word or number that is important in a line, such as a status or a count.
359
- - You SHOULD use a table only for two or three short columns.
359
+ - When the answer lists several items that have the same fields (projects, work items, builds, runs, test cases), you MUST give a markdown table: one row for each item, and only the two to four fields that answer the request. You MUST NOT write the fields of an item as a run of "key value;" in a bullet. When a field has the same value in every row, you MUST say it once under the table, not as a column, and you MUST NOT use bold in table cells.
360
+ - You MUST put the link of an item on its name, as [name](url), with the address of its web page, not of its REST API.
361
+ - You MUST NOT show internal ids (GUIDs), REST API URLs, revision numbers, or raw codes unless the user asks for them. Write a work item as #1234, and a date as YYYY-MM-DD.
360
362
  - You MUST keep your own comments short. You MUST give in full the test cases, tests, and reviews that the user asked for.
361
363
  - When the user asks for a report, a write-up, or a summary to share, you MUST save the report with write_report, with its data as datasets. You MUST NOT type rows, counts, or tables into the report yourself.
362
364
  - For "all" of something (all open defects, every project), you SHOULD give the dataset a query (a WIQL query or a REST path), so that ForgeQE reads every item itself. Otherwise, name the tool calls that you made in this session (from: the tool name or the evidence id).
@@ -490,7 +490,7 @@ export const CAPABILITIES: Capability[] = [
490
490
  area: "Systems",
491
491
  title: "Work with Azure DevOps, Jira, GitHub, and Confluence",
492
492
  summary: "Reads anything in the configured systems (work items, issues, queries, boards, pages), makes changes the user asks for, puts run evidence (screenshots, reports) on work items and issues as attachments or comments, and builds Azure DevOps dashboards and wiki pages.",
493
- tools: ["query_api", "read_confluence", "create_ado_dashboard", "write_ado_wiki_page", "attach_to_work_item"],
493
+ tools: ["read_confluence", "create_ado_dashboard", "write_ado_wiki_page", "attach_to_work_item"],
494
494
  keywords: ["jira", "azure devops", "ado", "confluence", "system", "github issue", "work item", "bug", "sprint", "dashboard", "wiki", "comment", "backlog", "issue", "ticket", "requirement"],
495
495
  examples: [
496
496
  "Show the open bugs in this sprint",
@@ -506,6 +506,18 @@ export const CAPABILITIES: Capability[] = [
506
506
  { heading: "Dashboards and wiki pages in Azure DevOps", when: "an Azure DevOps dashboard or wiki page" },
507
507
  ],
508
508
  },
509
+ {
510
+ id: "system-data",
511
+ area: "Systems",
512
+ title: "Read any data in Azure DevOps, Jira, Xray, and Confluence",
513
+ summary:
514
+ "Reads through a system's REST API what its MCP tools do not cover: pull requests and repositories, test plans and suites, classic releases, and anything else in Azure DevOps, Jira, Xray, or Confluence. Given on every turn once a system is set up, so the agent never turns to the browser or the shell to read it.",
515
+ tools: ["query_api"],
516
+ keywords: ["pull request", "pull requests", "prs", "open pr", "repos", "repository", "branch", "commit", "test plan", "test plans", "release"],
517
+ examples: ["Are there open pull requests in any repository of QI-Claims?", "List the test plans in QI-MobileBanking with their suites"],
518
+ requires: [{ kind: "system", anyOf: ["azure_devops", "jira", "xray", "confluence"] }],
519
+ docs: [{ doc: "README.md", heading: "Azure DevOps, Jira, and other systems" }],
520
+ },
509
521
  {
510
522
  id: "setup-help",
511
523
  area: "Setup",
@@ -654,7 +654,9 @@ export async function openSession<R = AgentReport>(opts: {
654
654
  throw err
655
655
  }
656
656
  const client: OpencodeClient = createOpencodeClient({ baseUrl: server.url, directory: opts.repo })
657
- const emit = opts.onEvent || (() => {})
657
+ /** The agent the prompt in progress runs as; every event is named with it, so the shell shows who is working. */
658
+ let speaker = agent.name
659
+ const emit = (e: CodeEvent) => opts.onEvent?.({ ...e, agent: speaker })
658
660
  const usage: Usage = { input: 0, output: 0 }
659
661
 
660
662
  let session: { id: string }
@@ -829,12 +831,13 @@ export async function openSession<R = AgentReport>(opts: {
829
831
  async prompt(text: string, options: PromptOptions = {}): Promise<PromptOutcome<R>> {
830
832
  sessionError = ""
831
833
  turn = { structured: null, error: "", text: [] }
834
+ speaker = options.agent || agent.name
832
835
  const done = new Promise<void>((resolve) => {
833
836
  idle = resolve
834
837
  })
835
838
  const sent = await client.session.promptAsync({
836
839
  sessionID: session.id,
837
- agent: options.agent || agent.name,
840
+ agent: speaker,
838
841
  model,
839
842
  ...(options.structured === false ? {} : { format: { type: "json_schema" as const, schema: (opts.schema || RESULT_SCHEMA) as never } }),
840
843
  ...(options.tools ? { tools: options.tools } : {}),
package/src/mcp/scope.ts CHANGED
@@ -23,6 +23,12 @@ import { MCP_NAME } from "./server.ts"
23
23
 
24
24
  /** Always given: the agent needs them to answer, to set up, and to know the environment and its apps. */
25
25
  export const CORE = ["setup-help", "test-apps"]
26
+ /**
27
+ * Given on every turn once a system (Azure DevOps, Jira, Xray, Confluence) is set up: query_api reads what the
28
+ * system's MCP tools do not cover. Without it a question about pull requests or test plans, which no keyword names,
29
+ * left the agent the browser, and its rule to read such data through the API could not be followed.
30
+ */
31
+ export const SYSTEM_DATA = "system-data"
26
32
  /** Turns a capability stays after its tools last ran. */
27
33
  export const STICKY_TURNS = 3
28
34
  /** Most capabilities picked from a request's words (recent and core ones come on top). */
@@ -119,12 +125,14 @@ export class CapabilityScope {
119
125
  reasons[id] = why
120
126
  }
121
127
  for (const id of CORE) take(id, "core")
128
+ if (usable.has(SYSTEM_DATA)) take(SYSTEM_DATA, "a system is set up: its API reads what its MCP tools do not cover")
129
+ const base = new Set(Object.keys(reasons))
122
130
  for (const id of this.cfg.agentScope?.always || []) take(id, "agent.capabilities.always")
123
131
  for (const id of this.requested) take(id, "asked for with use_capabilities")
124
132
  for (const [id, at] of this.lastUsed) if (this.turn - at < STICKY_TURNS) take(id, "used in a recent turn")
125
133
  const scored = [...scoreRequest(text, this.cfg, this.caps)].filter(([id]) => usable.has(id)).sort((a, b) => b[1].score - a[1].score)
126
134
  for (const [id, s] of scored.slice(0, MAX_PICKED)) take(id, `the request: ${s.why.slice(0, 3).join(", ")}`)
127
- if (Object.keys(reasons).every((id) => CORE.includes(id))) take(FALLBACK, "nothing more specific in the request")
135
+ if (Object.keys(reasons).every((id) => base.has(id))) take(FALLBACK, "nothing more specific in the request")
128
136
  return { ids: Object.keys(reasons), reasons }
129
137
  }
130
138
 
package/src/tui/App.tsx CHANGED
@@ -54,7 +54,10 @@ import { watchBudgets } from "../tokenomics/budget.ts"
54
54
  import {
55
55
  ACTIVITY_FILTERS,
56
56
  ActivityPanel,
57
+ AgentWorking,
57
58
  ContextPanel,
59
+ CrewStrip,
60
+ crewRows,
58
61
  FilesPanel,
59
62
  InfoStrip,
60
63
  nowNext,
@@ -70,10 +73,13 @@ import {
70
73
  type ContextInfo,
71
74
  type Focus,
72
75
  } from "./inspector.tsx"
76
+ import { BRAIN, BROWSER, SCRIBE } from "./agentIcons.ts"
73
77
  import {
74
78
  addSkill,
79
+ agentWorking,
75
80
  caseDone,
76
81
  caseStarted,
82
+ crewDone,
77
83
  filesByUse,
78
84
  newInspector,
79
85
  newRequest,
@@ -81,7 +87,6 @@ import {
81
87
  progressDone,
82
88
  setContext,
83
89
  setPlan,
84
- setWho,
85
90
  startProgress,
86
91
  stepOutcome,
87
92
  stepStarted,
@@ -287,6 +292,10 @@ export function App({ options }: { options: TuiOptions }) {
287
292
  else return
288
293
  bump()
289
294
  }
295
+ /** `agent` works now (its name in src/agents.ts): the status bar and the crew strip follow it. */
296
+ const working = (agent: string) => {
297
+ if (agentWorking(inspRef.current, agent)) bump()
298
+ }
290
299
  /** What the agent is on between calls: the plan's task in progress, rather than a bare "working". */
291
300
  const nowWorking = () => {
292
301
  const task = inspRef.current.plan.find((p) => p.status === "in_progress")
@@ -838,7 +847,7 @@ export function App({ options }: { options: TuiOptions }) {
838
847
  setStep(0)
839
848
  setBrowsing(true)
840
849
  startProgress(inspRef.current, `Browser · ${clip(e.goal, 40)}`)
841
- setWho(inspRef.current, "Browser agent")
850
+ agentWorking(inspRef.current, BROWSER)
842
851
  bump()
843
852
  if (agent) push("code", `Browser: ${clip(e.goal, 110)}`)
844
853
  if (!aborted()) setActivity(cfg?.llm.provider === "mock" ? "Running the offline demo…" : "Starting the browser…")
@@ -909,7 +918,7 @@ export function App({ options }: { options: TuiOptions }) {
909
918
  setLastStats(summary.stats)
910
919
  setBrowsing(false)
911
920
  progressDone(inspRef.current, aborted())
912
- setWho(inspRef.current, codingRef.current ? "Scribe" : "ForgeQE's agent")
921
+ agentWorking(inspRef.current, codingRef.current ? SCRIBE : BRAIN)
913
922
  bump()
914
923
  if (agent && !aborted()) setActivity("The agent is reading the result…")
915
924
  return
@@ -925,7 +934,7 @@ export function App({ options }: { options: TuiOptions }) {
925
934
  const tag = (id: string) => ((configRef.current?.suite.parallel || 1) > 1 ? `[${id}] ` : "")
926
935
  if (s.type === "case-start") {
927
936
  caseStarted(inspRef.current, s.case.id, s.case.title)
928
- setWho(inspRef.current, "Browser agent")
937
+ agentWorking(inspRef.current, BROWSER)
929
938
  bump()
930
939
  push("code", `${s.index + 1}/${s.total} ${s.case.id} ${s.case.title}`)
931
940
  // What was read from the test management system: where the case lives and the steps the browser will follow.
@@ -985,6 +994,8 @@ export function App({ options }: { options: TuiOptions }) {
985
994
  return
986
995
  case "agent": {
987
996
  const ev = e.event
997
+ // A subagent's session names it on each event (openSession); from then on it is the one working.
998
+ if (ev.agent) working(ev.agent)
988
999
  inspect(ev)
989
1000
  if (ev.kind === "plan") return
990
1001
  if (ev.kind === "usage") useTokens(`agent-tool-${runIndex.current}`, ev.usage.input + ev.usage.output)
@@ -1009,7 +1020,7 @@ export function App({ options }: { options: TuiOptions }) {
1009
1020
  } else if (ev.kind === "answer") record("code", answerText(ev), true)
1010
1021
  }
1011
1022
  if (aborted()) return
1012
- const who = codingRef.current ? "Coding agent" : "Agent"
1023
+ const who = inspRef.current.who
1013
1024
  if (ev.kind === "start") setActivity(`${who}: ${clip(ev.text, 60)}…`)
1014
1025
  else if (ev.kind === "tool") setActivity(`${who}: ${clip(ev.text, 60)}`)
1015
1026
  else if (ev.kind === "info" && ev.text.startsWith("ForgeQE runs the test")) setActivity("ForgeQE is running the new test…")
@@ -1022,7 +1033,7 @@ export function App({ options }: { options: TuiOptions }) {
1022
1033
  codingRef.current = true
1023
1034
  codeRunDir.current = e.runDir
1024
1035
  setCoding(true)
1025
- setWho(inspRef.current, "Scribe")
1036
+ agentWorking(inspRef.current, SCRIBE)
1026
1037
  bump()
1027
1038
  const show = shortPath
1028
1039
  pushLine({
@@ -1047,7 +1058,7 @@ export function App({ options }: { options: TuiOptions }) {
1047
1058
  case "code-done":
1048
1059
  codingRef.current = false
1049
1060
  setCoding(false)
1050
- setWho(inspRef.current, "ForgeQE's agent")
1061
+ agentWorking(inspRef.current, BRAIN)
1051
1062
  bump()
1052
1063
  producedRef.current.tests.push({ runDir: codeRunDir.current, result: e.result, error: e.error })
1053
1064
  if (e.error) push("err", `Coding agent: ${e.error}`)
@@ -1066,6 +1077,7 @@ export function App({ options }: { options: TuiOptions }) {
1066
1077
  }
1067
1078
 
1068
1079
  const onBrainEvent = (e: CodeEvent) => {
1080
+ working(e.agent || BRAIN)
1069
1081
  inspect(e)
1070
1082
  if (e.kind === "plan") return
1071
1083
  if (e.kind === "usage") {
@@ -1133,6 +1145,7 @@ export function App({ options }: { options: TuiOptions }) {
1133
1145
  })
1134
1146
  setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on, followUps }))
1135
1147
  progressDone(inspRef.current, finalStatus === "stopped")
1148
+ crewDone(inspRef.current, ended === "idle" ? "done" : ended)
1136
1149
  bump()
1137
1150
  setStatus(finalStatus)
1138
1151
  setActivity("")
@@ -1245,6 +1258,7 @@ export function App({ options }: { options: TuiOptions }) {
1245
1258
 
1246
1259
  const askAgent = async (text: string, cfg: AppConfig) => {
1247
1260
  const controller = begin("Thinking…", text)
1261
+ working(BRAIN)
1248
1262
  const meterStart = meterSnapshot()
1249
1263
  setThinking(true)
1250
1264
  const setup = sessionSetup(cfg, options.repo)
@@ -1883,9 +1897,9 @@ export function App({ options }: { options: TuiOptions }) {
1883
1897
  capabilities: CAPABILITIES.length,
1884
1898
  }
1885
1899
  const placeholder = running
1886
- ? coding
1887
- ? "The coding agent is writing a test · esc to stop"
1888
- : `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
1900
+ ? coding && insp.agent === SCRIBE
1901
+ ? "Scribe is writing a test · esc to stop"
1902
+ : `${insp.who} is working · esc to stop`
1889
1903
  : "Ask ForgeQE… (/ for commands, @ to reference a run, file, or work item)"
1890
1904
  const chatLines = lines.filter((l) => l.pane !== "activity")
1891
1905
  const activityLines = lines.filter((l) => l.pane === "activity")
@@ -1915,13 +1929,15 @@ export function App({ options }: { options: TuiOptions }) {
1915
1929
  const activityColumn = (paneWidth: number, rowsAvailable: number) => {
1916
1930
  const where = nowNext(insp)
1917
1931
  const plan = planRows(insp, Math.max(3, Math.min(10, Math.floor(rowsAvailable / 4))))
1918
- const activityRows = Math.max(5, rowsAvailable - STATUS_ROWS - 1 - plan)
1932
+ const crew = crewRows(insp)
1933
+ const activityRows = Math.max(5, rowsAvailable - STATUS_ROWS - 1 - crew - plan)
1919
1934
  const focusOn = (f: Focus) => () => setNav((n) => ({ ...n, focus: f }))
1920
1935
  const sel = nav.focus === "plan" ? Math.min(nav.plan, Math.max(0, planLines(insp).length - 1)) : undefined
1921
1936
  return (
1922
1937
  <box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
1923
- <StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress || planProgress(insp)} actions={paneButtons} />
1938
+ <StatusBar running={running} who={insp.who} agent={insp.agent} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress || planProgress(insp)} actions={paneButtons} />
1924
1939
  <box style={{ height: 1, flexShrink: 0 }} />
1940
+ {crew ? <CrewStrip s={insp} width={paneWidth} running={running} /> : null}
1925
1941
  {plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel} onFocus={focusOn("plan")} animate={running} /> : null}
1926
1942
  <ActivityPanel
1927
1943
  lines={activityLines}
@@ -1949,14 +1965,8 @@ export function App({ options }: { options: TuiOptions }) {
1949
1965
  <TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
1950
1966
  ))}
1951
1967
  {running && !panelsOpen ? (
1952
- // What is happening now, for how long, and with how many tokens, as one line under the conversation.
1953
- <box style={{ flexDirection: "row", marginTop: 1, flexShrink: 0 }}>
1954
- <Spinner kind="dots" color={theme.memory} />
1955
- <text>
1956
- <span fg={theme.memory}>{` ${activity || "Working…"}`}</span>
1957
- <span fg={theme.faint}>{` (${[elapsed, progressText, tokens ? `${formatTokens(tokens)} tokens` : "", "esc to interrupt"].filter(Boolean).join(" · ")})`}</span>
1958
- </text>
1959
- </box>
1968
+ // Who is working and on what, the request's agents, how long, and how many tokens, under the conversation.
1969
+ <AgentWorking s={insp} activity={activity} stats={[elapsed, progressText, tokens ? `${formatTokens(tokens)} tokens` : "", "esc to interrupt"].filter(Boolean).join(" · ")} width={room} />
1960
1970
  ) : null}
1961
1971
  </scrollbox>
1962
1972
  </LineRoom.Provider>
@@ -0,0 +1,109 @@
1
+ import { AGENT_HUES, theme } from "./theme.ts"
2
+
3
+ /**
4
+ * Each agent's face in the shell: a short name and an icon, so it is clear which agent or subagent is working. The
5
+ * icons follow one set of hexagon tiles (soft tones, a deeper one where shapes overlap); each agent has a picture of its
6
+ * job and a colour of its own, for its icon and its name.
7
+ * Plain data, tested without a terminal.
8
+ */
9
+
10
+ /**
11
+ * Eight pixels wide and four high, drawn as two rows of half blocks: `#` the agent's main tone, `+` its deeper tone
12
+ * (where shapes overlap, or the part that stands out), `.` empty. Each agent's is a picture of its job.
13
+ */
14
+ export const SHAPES = {
15
+ /** A plain hexagon: an agent not listed. */
16
+ hexagon: ["..####..", ".######.", ".######.", "..####.."],
17
+ /** Hexagons stacked into one: ForgeQE's agent, which starts the others. */
18
+ hub: ["...##...", ".##++##.", "##++++##", ".##..##."],
19
+ /** A browser window with its title bar. */
20
+ window: ["++++++++", "#......#", "#......#", "########"],
21
+ /** A magnifier. */
22
+ magnifier: [".###....", "#+++#...", ".###.#..", ".....##."],
23
+ /** A bar chart. */
24
+ chart: ["......##", "...##.##", "##.##.##", "++++++++"],
25
+ /** A funnel: a request narrowed to a specification. */
26
+ funnel: ["########", ".######.", "..++++..", "...++..."],
27
+ /** A list with its bullets. */
28
+ list: ["+.######", "........", "+.####..", "........"],
29
+ /** A flag on its pole. */
30
+ flag: ["##+++++.", "##++++..", "##......", "##......"],
31
+ /** A pencil, its tip at the bottom left. */
32
+ pencil: [".....+++", "...###+.", ".###....", "+#......"],
33
+ /** An open book on its spine. */
34
+ book: ["##....##", "###..###", "###++###", "...++..."],
35
+ /** A cross. */
36
+ cross: ["...##...", ".##++##.", ".##++##.", "...##..."],
37
+ /** A plug with its prongs and cable. */
38
+ plug: ["..#..#..", ".++++++.", ".++++++.", "...##..."],
39
+ /** A check mark. */
40
+ check: ["......##", "##...##.", ".##+##..", "..+++..."],
41
+ /** A comment bubble. */
42
+ bubble: ["########", "#.+.+.+#", "########", ".##....."],
43
+ /** A gavel on its block. */
44
+ gavel: [".++++...", ".++++#..", ".....##.", "###...##"],
45
+ /** A bug. */
46
+ bug: ["+..##..+", ".######.", "+##++##+", ".##++##."],
47
+ } as const
48
+
49
+ export type Shape = keyof typeof SHAPES
50
+ export type Hue = keyof typeof AGENT_HUES
51
+
52
+ export type AgentFace = { name: string; shape: Shape; hue: Hue }
53
+
54
+ /**
55
+ * Keyed by the agent's name in src/agents.ts (its AgentSpec name, or "engine"). Each has its own hue; the hue's
56
+ * neighbourhood says what the agent does: purples plan and lead, blues run and look, greens write code, warm hues judge.
57
+ */
58
+ export const FACES: Record<string, AgentFace> = {
59
+ forgeqe: { name: "ForgeQE's agent", shape: "hub", hue: "purple" },
60
+ "forgeqe-refine": { name: "Refine", shape: "funnel", hue: "indigo" },
61
+ "forgeqe-api-plan": { name: "API planner", shape: "list", hue: "orchid" },
62
+ "forgeqe-explore-plan": { name: "Explorer", shape: "flag", hue: "magenta" },
63
+ engine: { name: "Browser agent", shape: "window", hue: "blue" },
64
+ "forgeqe-find": { name: "Test finder", shape: "magnifier", hue: "cyan" },
65
+ "forgeqe-bi": { name: "Insights", shape: "chart", hue: "yellow" },
66
+ "forgeqe-test": { name: "Scribe", shape: "pencil", hue: "green" },
67
+ "forgeqe-read": { name: "Scribe, reading", shape: "book", hue: "leaf" },
68
+ "forgeqe-heal": { name: "Healer", shape: "cross", hue: "lime" },
69
+ "forgeqe-api": { name: "API test writer", shape: "plug", hue: "teal" },
70
+ "forgeqe-review": { name: "Reviewer", shape: "check", hue: "pink" },
71
+ "forgeqe-pr-review": { name: "PR reviewer", shape: "bubble", hue: "rose" },
72
+ "forgeqe-judge": { name: "Judge", shape: "gavel", hue: "coral" },
73
+ "forgeqe-defect-rca": { name: "Root cause", shape: "bug", hue: "orange" },
74
+ }
75
+
76
+ export const BRAIN = "forgeqe"
77
+ export const BROWSER = "engine"
78
+ export const SCRIBE = "forgeqe-test"
79
+
80
+ /** An agent's face; one not listed is named by its id, without the "forgeqe-" prefix, with a plain grey hexagon. */
81
+ export function faceOf(agent: string): AgentFace & { main: string; deep: string } {
82
+ const face = FACES[agent]
83
+ if (face) return { ...face, ...AGENT_HUES[face.hue] }
84
+ const name = agent.replace(/^forgeqe-/, "").replace(/-/g, " ")
85
+ return { name: name.charAt(0).toUpperCase() + name.slice(1), shape: "hexagon", hue: "blue", main: theme.faint, deep: theme.border }
86
+ }
87
+
88
+ /** One terminal cell of an icon: a half or full block in up to two colours, or a blank. */
89
+ export type IconCell = { ch: string; fg?: string; bg?: string }
90
+
91
+ /** The agent's icon as two rows of cells: a pixel's tone on top is the foreground of ▀, the one below its background. */
92
+ export function iconCells(agent: string): IconCell[][] {
93
+ const face = faceOf(agent)
94
+ const px = SHAPES[face.shape]
95
+ const tone = (p: string | undefined) => (p === "#" ? face.main : p === "+" ? face.deep : undefined)
96
+ return [0, 1].map((r) =>
97
+ [...px[0]].map((_, c) => {
98
+ const top = tone(px[r * 2]![c])
99
+ const bottom = tone(px[r * 2 + 1]![c])
100
+ if (!top && !bottom) return { ch: " " }
101
+ if (!bottom) return { ch: "▀", fg: top }
102
+ if (!top) return { ch: "▄", fg: bottom }
103
+ return top === bottom ? { ch: "█", fg: top } : { ch: "▀", fg: top, bg: bottom }
104
+ }),
105
+ )
106
+ }
107
+
108
+ /** Columns an icon takes. */
109
+ export const ICON_WIDTH = SHAPES.hexagon[0].length
@@ -2,10 +2,11 @@ import type { ScrollBoxRenderable } from "@opentui/core"
2
2
  import type { ReactNode, RefObject } from "react"
3
3
 
4
4
  import type { Usage } from "../types.ts"
5
+ import { faceOf, iconCells, ICON_WIDTH } from "./agentIcons.ts"
5
6
  import { fitButtons, Spinner, type Button } from "./components.tsx"
6
- import { filesByUse, type FileUse, type Inspector, type Progress } from "./session.ts"
7
- import { clip, tailCut } from "./text.ts"
8
- import { formatTokens, theme } from "./theme.ts"
7
+ import { crewMs, filesByUse, type CrewMember, type FileUse, type Inspector, type Progress } from "./session.ts"
8
+ import { clip, plainText, tailCut } from "./text.ts"
9
+ import { formatDuration, formatTokens, theme } from "./theme.ts"
9
10
  import { isMarker, type Line } from "./transcript.tsx"
10
11
 
11
12
  /**
@@ -66,7 +67,23 @@ export function Panel(props: { title: string; color: string; width?: number; hei
66
67
  )
67
68
  }
68
69
 
69
- /** The right pane's first line: who is working on what and for how long, and Stop while something runs. */
70
+ /** An agent's icon (src/tui/agentIcons.ts): two rows of half blocks in its hue. */
71
+ export function AgentIcon({ agent }: { agent: string }) {
72
+ return (
73
+ <box style={{ flexDirection: "column", width: ICON_WIDTH, height: 2, flexShrink: 0 }}>
74
+ {iconCells(agent).map((row, r) => (
75
+ <text key={r}>
76
+ {row.map((cell, c) => (
77
+ <span key={c} {...(cell.fg ? { fg: cell.fg } : {})} {...(cell.bg ? { bg: cell.bg } : {})}>
78
+ {cell.ch}
79
+ </span>
80
+ ))}
81
+ </text>
82
+ ))}
83
+ </box>
84
+ )
85
+ }
86
+
70
87
  /** How far the agent's own plan has got, while it has one of two steps or more. */
71
88
  export function planProgress(s: Inspector): { done: number; total: number } | null {
72
89
  return s.plan.length > 1 ? { done: s.plan.filter((p) => p.status === "completed").length, total: s.plan.length } : null
@@ -88,6 +105,10 @@ export const STATUS_ROWS = 2
88
105
  export function StatusBar(props: {
89
106
  running: boolean
90
107
  who: string
108
+ /** The agent working (its name in src/agents.ts): its icon leads both rows while the request runs. */
109
+ agent?: string
110
+ /** The colour `who` is written in: the working agent's own. */
111
+ whoColor?: string
91
112
  activity: string
92
113
  elapsed: string
93
114
  status: string
@@ -98,6 +119,17 @@ export function StatusBar(props: {
98
119
  /** Buttons for the view (Focus panels, Close), before Stop; the first to give way when the line is short. */
99
120
  actions?: Button[]
100
121
  }) {
122
+ const icon = props.running && props.agent ? props.agent : null
123
+ if (icon) {
124
+ return (
125
+ <box style={{ flexDirection: "row", height: STATUS_ROWS, flexShrink: 0, paddingLeft: 1 }}>
126
+ <AgentIcon agent={icon} />
127
+ <box style={{ width: props.width - ICON_WIDTH - 1, flexShrink: 0 }}>
128
+ <StatusBar {...props} agent={undefined} whoColor={faceOf(icon).main} width={props.width - ICON_WIDTH - 1} />
129
+ </box>
130
+ </box>
131
+ )
132
+ }
101
133
  const done = props.status === "done"
102
134
  const failed = props.status === "fail"
103
135
  const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
@@ -114,7 +146,7 @@ export function StatusBar(props: {
114
146
  <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1 }}>
115
147
  <box style={{ flexDirection: "row" }}>
116
148
  {props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
117
- <text fg={props.running ? theme.text : color}>{` ${clip(state, Math.max(8, props.width - fixed + state.length))}`}</text>
149
+ <text fg={props.running ? props.whoColor || theme.text : color}>{` ${clip(state, Math.max(8, props.width - fixed + state.length))}`}</text>
118
150
  </box>
119
151
  <box style={{ flexDirection: "row" }}>
120
152
  {actions.map((b) => (
@@ -150,6 +182,123 @@ export function StatusBar(props: {
150
182
  )
151
183
  }
152
184
 
185
+ /** Each crew member's state: its mark, the mark's colour, and the word for it when it has no time to show. */
186
+ const CREW_MARK: Record<CrewMember["state"], [string, string]> = {
187
+ working: ["◐", theme.warn],
188
+ waiting: ["○", theme.faint],
189
+ done: ["✓", theme.ok],
190
+ failed: ["✗", theme.err],
191
+ stopped: ["■", theme.warn],
192
+ }
193
+
194
+ /** Rows the crew strip takes, the blank row under it included: none until a second agent has joined the request. */
195
+ export function crewRows(s: Inspector): number {
196
+ return s.crew.length > 1 ? 3 : 0
197
+ }
198
+
199
+ /** What a member's second row says: "waiting", or how long it has worked. */
200
+ function crewState(m: CrewMember, now: number): string {
201
+ return m.state === "waiting" ? "waiting" : formatDuration(crewMs(m, now))
202
+ }
203
+
204
+ /** Columns a tile's text takes, and the tile with its icon. */
205
+ const CREW_TEXT = 18
206
+ const crewTextWidth = (m: CrewMember, now: number) => Math.min(CREW_TEXT, Math.max(faceOf(m.agent).name.length, crewState(m, now).length + 2))
207
+ const CREW_GAP = " › "
208
+
209
+ /**
210
+ * The chat view's line under the conversation while a request runs (the panels closed): the working agent's icon, its
211
+ * name in its colour and what it is doing; under them the agents of the request ("✓ Browser agent › ◐ Scribe"), the
212
+ * latest that fit, then the time, tokens and how to interrupt.
213
+ */
214
+ export function AgentWorking(props: { s: Inspector; activity: string; stats: string; width: number }) {
215
+ const { s } = props
216
+ const face = faceOf(s.agent)
217
+ const room = Math.max(10, props.width - ICON_WIDTH - 1)
218
+ const name = clip(face.name, Math.max(8, room - 4))
219
+ const stats = `(${props.stats})`
220
+ const chain = s.crew.length > 1 ? s.crew : []
221
+ const size = (m: CrewMember) => 2 + faceOf(m.agent).name.length
222
+ let first = chain.length
223
+ let used = 0
224
+ while (first > 0) {
225
+ const more = size(chain[first - 1]!) + (first < chain.length ? CREW_GAP.length : 0)
226
+ if (used + more + (first > 1 ? 4 : 0) + stats.length + 2 > room && first < chain.length) break
227
+ used += more
228
+ first--
229
+ }
230
+ return (
231
+ <box style={{ flexDirection: "row", marginTop: 1, flexShrink: 0 }}>
232
+ <AgentIcon agent={s.agent} />
233
+ <box style={{ flexDirection: "column", paddingLeft: 1, flexGrow: 1, flexShrink: 1 }}>
234
+ <box style={{ flexDirection: "row", height: 1 }}>
235
+ <Spinner kind="dots" color={face.main} />
236
+ <text>
237
+ <span fg={face.main}>{` ${name}`}</span>
238
+ <span fg={theme.memory}>{` ${clip(props.activity || "Working…", Math.max(8, room - name.length - 4))}`}</span>
239
+ </text>
240
+ </box>
241
+ <text style={{ height: 1 }}>
242
+ {first > 0 ? <span fg={theme.faint}>{`+${first} `}</span> : null}
243
+ {chain.slice(first).flatMap((m, i) => {
244
+ const [mark, color] = CREW_MARK[m.state]
245
+ return [
246
+ ...(i ? [<span key={`${m.agent}-gap`} fg={theme.border}>{CREW_GAP}</span>] : []),
247
+ <span key={`${m.agent}-mark`} fg={color}>{`${mark} `}</span>,
248
+ <span key={m.agent} fg={faceOf(m.agent).main}>{faceOf(m.agent).name}</span>,
249
+ ]
250
+ })}
251
+ <span fg={theme.faint}>{`${chain.length ? " " : ""}${clip(stats, Math.max(8, room - used))}`}</span>
252
+ </text>
253
+ </box>
254
+ </box>
255
+ )
256
+ }
257
+
258
+ /**
259
+ * Every agent that took part in the request, in the order they started, as tiles: the icon, its name, and whether it
260
+ * is working (spinning, with its time), waiting for a subagent, or done. The latest that fit; "+N" for the earlier.
261
+ */
262
+ export function CrewStrip(props: { s: Inspector; width: number; running: boolean; now?: number }) {
263
+ const now = props.now ?? Date.now()
264
+ const crew = props.s.crew
265
+ const tileWidth = (m: CrewMember) => ICON_WIDTH + 1 + crewTextWidth(m, now)
266
+ let first = crew.length
267
+ let used = 0
268
+ while (first > 0) {
269
+ const more = tileWidth(crew[first - 1]!) + (first < crew.length ? CREW_GAP.length : 0)
270
+ // Room for "+N " in front when anything is left out.
271
+ if (used + more + (first > 1 ? 4 : 0) > props.width - 2 && first < crew.length) break
272
+ used += more
273
+ first--
274
+ }
275
+ return (
276
+ <box style={{ flexDirection: "row", height: 2, flexShrink: 0, marginBottom: 1, paddingLeft: 1 }}>
277
+ {first > 0 ? <text fg={theme.faint}>{`+${first} `}</text> : null}
278
+ {crew.slice(first).map((m, i) => {
279
+ const face = faceOf(m.agent)
280
+ const [mark, color] = CREW_MARK[m.state]
281
+ const live = m.state === "working" && props.running
282
+ const text = crewTextWidth(m, now)
283
+ return (
284
+ <box key={m.agent} style={{ flexDirection: "row", flexShrink: 0 }}>
285
+ {i ? <text fg={theme.border}>{CREW_GAP}</text> : null}
286
+ <AgentIcon agent={m.agent} />
287
+ <box style={{ flexDirection: "column", width: text + 1, paddingLeft: 1, flexShrink: 0 }}>
288
+ {/* Each agent's name in its own colour, so its tile, the status bar, and the icon read as one agent. */}
289
+ <text fg={face.main}>{clip(face.name, text)}</text>
290
+ <box style={{ flexDirection: "row", height: 1 }}>
291
+ <box style={{ width: 2, flexShrink: 0 }}>{live ? <Spinner kind="dots" color={color} /> : <text fg={color}>{mark}</text>}</box>
292
+ <text fg={m.state === "working" ? theme.warn : theme.faint}>{crewState(m, now)}</text>
293
+ </box>
294
+ </box>
295
+ </box>
296
+ )
297
+ })}
298
+ </box>
299
+ )
300
+ }
301
+
153
302
  const PLAN_MARK: Record<string, [string, string]> = {
154
303
  completed: ["✓", theme.ok],
155
304
  in_progress: ["◐", theme.warn],
@@ -320,7 +469,7 @@ export function ActivityRow({ line, width = 0, first = false }: { line: Line; wi
320
469
  <ToolCall parts={toolArgs(line.text)!} width={width} />
321
470
  ) : (
322
471
  <text fg={textColor} style={{ flexGrow: 1, flexShrink: 1 }}>
323
- {fit(line.text, oneRow ? 1 : NOTE_ROWS) || " "}
472
+ {fit(line.kind === "thought" ? plainText(line.text) : line.text, oneRow ? 1 : NOTE_ROWS) || " "}
324
473
  </text>
325
474
  )}
326
475
  </box>
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Makes a model's markdown readable in the shell, whatever wrote it: the shape is fixed here, never the content, and
3
+ * nothing the text says is dropped. Records written as list items of "key value; key value" become a table (or, where
4
+ * a table would not fit, one short block per record), ISO timestamps become dates and times, and raw JSON a code block.
5
+ * Code, fenced or inline, is left as it was. Plain strings in, plain strings out, so it is tested without a terminal.
6
+ */
7
+
8
+ /** A list item: its indent, its marker, and its text. */
9
+ const ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
10
+ const FENCE = /^\s*(```|~~~)/
11
+
12
+ /** "2023-02-24T09:07:36.893Z" → "2023-02-24 09:07 UTC"; an offset is kept, a time of midnight with no zone dropped. */
13
+ const TIMESTAMP = /\b(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?\b/g
14
+
15
+ export function readableTimes(text: string): string {
16
+ return outsideCode(text, (part) =>
17
+ part.replace(TIMESTAMP, (_, date: string, time: string, zone?: string) => {
18
+ if (!zone && time === "00:00") return date
19
+ return `${date} ${time}${zone === "Z" ? " UTC" : zone ? ` ${zone}` : ""}`
20
+ }),
21
+ )
22
+ }
23
+
24
+ /** Applies `fix` to the text outside inline code spans. */
25
+ function outsideCode(line: string, fix: (part: string) => string): string {
26
+ return line
27
+ .split(/(`[^`]*`)/)
28
+ .map((part) => (part.startsWith("`") ? part : fix(part)))
29
+ .join("")
30
+ }
31
+
32
+ /** One field of a record: "state wellFormed", "visibility code 2", "description: Card payments…". */
33
+ export type Field = { key: string; value: string }
34
+
35
+ /** A record: what names it, and its fields; `notes` holds what had no key. */
36
+ export type Record = { name: string; fields: Field[]; notes: string[] }
37
+
38
+ /** "**Autothon** — ID 6d73…; state wellFormed; URL https://…" → its name and the text of its fields. */
39
+ const NAMED = /^(?:\*\*(.+?)\*\*|`(.+?)`|([^—–:;]{1,60}?))\s*(?:—|–|:|\s-)\s+(.+)$/
40
+
41
+ /** A key is one to three words; the value is what follows it. */
42
+ const KEY_VALUE = /^([A-Za-z][\w./-]*(?: [A-Za-z][\w./-]*){0,2}):\s+(.+)$/
43
+ const KEY_TOKEN = /^([A-Za-z][\w./-]*(?: [a-z][\w./-]*){0,2}) (\S+)$/
44
+
45
+ function field(segment: string): Field | null {
46
+ const s = segment.trim().replace(/\.$/, "")
47
+ const colon = KEY_VALUE.exec(s)
48
+ if (colon) return { key: colon[1]!, value: colon[2]!.trim() }
49
+ const spaced = KEY_TOKEN.exec(s)
50
+ return spaced ? { key: spaced[1]!, value: spaced[2]! } : null
51
+ }
52
+
53
+ /** A list item as a record, or null when it is a sentence rather than fields. */
54
+ export function parseRecord(text: string): Record | null {
55
+ const named = NAMED.exec(text.trim())
56
+ if (!named) return null
57
+ const name = (named[1] ?? named[2] ?? named[3] ?? "").trim()
58
+ const body = named[4]!
59
+ // Fields are split by semicolons; by commas only when every part then reads "key: value".
60
+ let parts = body.split(/;\s+/)
61
+ if (parts.length < 2) {
62
+ const commas = body.split(/,\s+/)
63
+ parts = commas.length >= 2 && commas.every((c) => KEY_VALUE.test(c.trim())) ? commas : parts
64
+ }
65
+ if (parts.length < 2) return null
66
+ const fields: Field[] = []
67
+ const notes: string[] = []
68
+ for (const part of parts) {
69
+ const f = field(part)
70
+ if (f) fields.push(f)
71
+ else if (part.trim()) notes.push(part.trim().replace(/\.$/, ""))
72
+ }
73
+ return fields.length >= 2 ? { name, fields, notes } : null
74
+ }
75
+
76
+ const LINK_KEY = /^(url|link|href|web ?url|address)$/i
77
+ const URL = /^<?(https?:\/\/[^\s>]+)>?$/
78
+
79
+ /** The columns of a set of records: the keys more than half of them have (both of two), in the order they first appear. */
80
+ function columnsOf(records: Record[]): string[] {
81
+ const seen = new Map<string, { key: string; n: number }>()
82
+ for (const r of records)
83
+ for (const k of new Set(r.fields.map((f) => f.key.toLowerCase()))) {
84
+ const at = seen.get(k)
85
+ if (at) at.n++
86
+ else seen.set(k, { key: r.fields.find((f) => f.key.toLowerCase() === k)!.key, n: 1 })
87
+ }
88
+ return [...seen.values()].filter((c) => c.n * 2 > records.length).map((c) => c.key)
89
+ }
90
+
91
+ /**
92
+ * Whether items read as records: two keys or more that half of them share, holding most of their fields. Steps or
93
+ * sentences split by semicolons ("open the page; click Search") share no keys, and stay as they were written.
94
+ */
95
+ export function areRecords(records: Record[]): boolean {
96
+ const columns = new Set(columnsOf(records).map((c) => c.toLowerCase()))
97
+ const all = records.reduce((n, r) => n + r.fields.length + r.notes.length, 0)
98
+ const kept = records.reduce((n, r) => n + r.fields.filter((f) => columns.has(f.key.toLowerCase())).length, 0)
99
+ return columns.size >= 2 && kept >= all * 0.6
100
+ }
101
+
102
+ const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
103
+ /** A table cell: pipes escaped, so a value cannot open a column. */
104
+ const cell = (s: string) => s.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim() || "—"
105
+
106
+ /**
107
+ * Records as markdown: a table where it fits `width` (a long column may wrap), else a block per record. Every field
108
+ * is kept: a link becomes the name's, a value every record shares is said once under them, the rest go in Notes.
109
+ */
110
+ export function recordsMarkdown(records: Record[], width: number, indent = ""): string {
111
+ let columns = columnsOf(records)
112
+ const value = (r: Record, key: string) => r.fields.find((f) => f.key.toLowerCase() === key.toLowerCase())?.value
113
+ // A link column moves onto the name.
114
+ const linkKey = columns.find((c) => LINK_KEY.test(c) && records.every((r) => !value(r, c) || URL.test(value(r, c)!)))
115
+ const name = (r: Record) => {
116
+ const href = linkKey ? URL.exec(value(r, linkKey) || "")?.[1] : undefined
117
+ return href && !/[[\]]/.test(r.name) ? `[${r.name}](${href})` : r.name
118
+ }
119
+ if (linkKey) columns = columns.filter((c) => c !== linkKey)
120
+ // A value every record has the same is said once.
121
+ const shared = columns.filter((c) => records.every((r) => value(r, c) !== undefined) && new Set(records.map((r) => value(r, c))).size === 1 && records.length > 1)
122
+ columns = columns.filter((c) => !shared.includes(c))
123
+ const inColumns = new Set([...columns, ...shared, ...(linkKey ? [linkKey] : [])].map((c) => c.toLowerCase()))
124
+ const notes = (r: Record) => [...r.fields.filter((f) => !inColumns.has(f.key.toLowerCase())).map((f) => `${cap(f.key)}: ${f.value}`), ...r.notes].join("; ")
125
+ const withNotes = records.some((r) => notes(r))
126
+ const header = ["Name", ...columns.map(cap), ...(withNotes ? ["Notes"] : [])]
127
+ const rows = records.map((r) => [name(r), ...columns.map((c) => value(r, c) ?? "—"), ...(withNotes ? [notes(r)] : [])].map(cell))
128
+ const sharedLine = shared.length ? `${records.length === 2 ? "Both" : `All ${records.length}`}: ${shared.map((c) => `${c} ${value(records[0]!, c)}`).join("; ")}.` : ""
129
+
130
+ // How wide the table would be: its widest cells and the gaps between them. A long column may wrap; a table half as
131
+ // wide again as the pane would wrap every column, so then each record is a block of its own.
132
+ const shown = (s: string) => s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").length
133
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => shown(row[i]!))))
134
+ const tableWidth = widths.reduce((a, b) => a + b, 0) + 2 * (widths.length - 1)
135
+ const out: string[] = []
136
+ if (tableWidth <= width * 1.5 || header.length <= 3) {
137
+ out.push(`${indent}| ${header.join(" | ")} |`, `${indent}|${header.map(() => "---").join("|")}|`)
138
+ for (const row of rows) out.push(`${indent}| ${row.join(" | ")} |`)
139
+ } else {
140
+ for (const [i, r] of records.entries()) {
141
+ out.push(`${indent}- **${name(r)}**`)
142
+ for (const [j, c] of columns.entries()) if (value(r, c) !== undefined) out.push(`${indent} - ${cap(c)}: ${rows[i]![j + 1]}`)
143
+ if (notes(r)) out.push(`${indent} - ${notes(r)}`)
144
+ }
145
+ }
146
+ if (sharedLine) out.push("", `${indent}${sharedLine}`)
147
+ return out.join("\n")
148
+ }
149
+
150
+ /** Raw JSON written as text: an object or array, whole, that parses. */
151
+ function jsonBlock(text: string): string | null {
152
+ const t = text.trim()
153
+ if (!/^[[{]/.test(t) || t.length < 2) return null
154
+ try {
155
+ const value = JSON.parse(t)
156
+ if (value === null || typeof value !== "object") return null
157
+ return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``
158
+ } catch {
159
+ return null
160
+ }
161
+ }
162
+
163
+ /** The whole pass. `width`: the columns the reply has, which decides between a table and blocks. */
164
+ export function readable(md: string, width = 80): string {
165
+ const lines = md.replace(/\r/g, "").split("\n")
166
+ const out: string[] = []
167
+ let fence = false
168
+ for (let n = 0; n < lines.length; n++) {
169
+ const line = lines[n]!
170
+ if (FENCE.test(line)) {
171
+ fence = !fence
172
+ out.push(line)
173
+ continue
174
+ }
175
+ if (fence) {
176
+ out.push(line)
177
+ continue
178
+ }
179
+ // Raw JSON: the fewest lines from here, within the paragraph, that parse as an object or an array.
180
+ if (/^\s*[[{]/.test(line)) {
181
+ let json: string | null = null
182
+ let end = n
183
+ for (; end < lines.length && lines[end]!.trim(); end++) if ((json = jsonBlock(lines.slice(n, end + 1).join("\n")))) break
184
+ if (json) {
185
+ out.push(json)
186
+ n = end
187
+ continue
188
+ }
189
+ }
190
+ // A run of list items at one depth, each with the lines that continue it.
191
+ const first = ITEM.exec(line)
192
+ if (first) {
193
+ const depth = first[1]!.length
194
+ const items: string[] = []
195
+ let end = n - 1
196
+ while (end + 1 < lines.length) {
197
+ const next = lines[end + 1]!
198
+ const item = ITEM.exec(next)
199
+ if (item && item[1]!.length === depth) items.push(item[3]!)
200
+ else if (next.trim() && /^\s+\S/.test(next) && !item && items.length && next.search(/\S/) > depth) items[items.length - 1] += ` ${next.trim()}`
201
+ else break
202
+ end++
203
+ }
204
+ // Fields are read before times are rewritten: "last updated 2023-02-24T09:07Z" is one key and one value.
205
+ const records = items.map((t) => {
206
+ const r = parseRecord(t)
207
+ // A value wholly in backticks ("`100`", "`wellFormed`") is emphasis, not code: in a table it would colour every cell.
208
+ const plain = (v: string) => readableTimes(/^`[^`]+`$/.test(v) ? v.slice(1, -1) : v)
209
+ return r && { ...r, name: /^`[^`]+`$/.test(r.name) ? r.name.slice(1, -1) : r.name, fields: r.fields.map((f) => ({ ...f, value: plain(f.value) })), notes: r.notes.map(plain) }
210
+ })
211
+ if (items.length >= 2 && records.every(Boolean) && areRecords(records as Record[])) {
212
+ out.push(recordsMarkdown(records as Record[], width, " ".repeat(depth)))
213
+ n = end
214
+ continue
215
+ }
216
+ }
217
+ out.push(readableTimes(line))
218
+ }
219
+ return out.join("\n")
220
+ }
@@ -1,4 +1,5 @@
1
1
  import type { FileAction, PlanItem } from "../types.ts"
2
+ import { BRAIN, faceOf } from "./agentIcons.ts"
2
3
 
3
4
  /**
4
5
  * What the shell's right-hand pane shows about the request in progress: the agent's plan, the browser steps or suite
@@ -10,7 +11,11 @@ export type FileUse = { path: string; reads: number; changes: number; last: File
10
11
 
11
12
  export type Progress = { label: string; status: "running" | "ok" | "fail" | "skipped"; detail?: string; actions?: string[] }
12
13
 
13
- export type Who = "ForgeQE's agent" | "Browser agent" | "Scribe"
14
+ /**
15
+ * An agent that took part in the request, by its name in src/agents.ts. Waiting: ForgeQE's agent while a subagent it
16
+ * started works. `ms`: how long it has worked, `since` when its current turn started.
17
+ */
18
+ export type CrewMember = { agent: string; state: "working" | "waiting" | "done" | "failed" | "stopped"; ms: number; since?: number }
14
19
 
15
20
  export type Inspector = {
16
21
  /** The agent's own to-do list. */
@@ -21,7 +26,11 @@ export type Inspector = {
21
26
  /** Input tokens of the latest model call: how full its context window is. */
22
27
  context: number
23
28
  skills: string[]
24
- who: Who
29
+ /** The agent working now (its name in src/agents.ts), and what the shell calls it. */
30
+ agent: string
31
+ who: string
32
+ /** Every agent that took part in the request, in the order they started. */
33
+ crew: CrewMember[]
25
34
  /** Counts file uses, so the most recent comes first. */
26
35
  seq: number
27
36
  }
@@ -29,14 +38,16 @@ export type Inspector = {
29
38
  export const MAX_PROGRESS = 200
30
39
 
31
40
  export function newInspector(): Inspector {
32
- return { plan: [], progress: { title: "", items: [] }, files: new Map(), context: 0, skills: [], who: "ForgeQE's agent", seq: 0 }
41
+ return { plan: [], progress: { title: "", items: [] }, files: new Map(), context: 0, skills: [], agent: BRAIN, who: faceOf(BRAIN).name, crew: [], seq: 0 }
33
42
  }
34
43
 
35
44
  /** A new request keeps the files, skills, and context of the session, and starts a fresh plan and progress. */
36
45
  export function newRequest(s: Inspector): void {
37
46
  s.plan = []
38
47
  s.progress = { title: "", items: [] }
39
- s.who = "ForgeQE's agent"
48
+ s.agent = BRAIN
49
+ s.who = faceOf(BRAIN).name
50
+ s.crew = []
40
51
  }
41
52
 
42
53
  export function setPlan(s: Inspector, items: PlanItem[]): void {
@@ -59,8 +70,39 @@ export function setContext(s: Inspector, tokens: number): void {
59
70
  if (tokens > 0) s.context = tokens
60
71
  }
61
72
 
62
- export function setWho(s: Inspector, who: Who): void {
63
- s.who = who
73
+ /**
74
+ * `agent` works now. The one that worked before stops its clock: ForgeQE's agent waits for what it started, any other
75
+ * is done until it works again. True when this changed who is working.
76
+ */
77
+ export function agentWorking(s: Inspector, agent: string, now = Date.now()): boolean {
78
+ const member = s.crew.find((m) => m.agent === agent)
79
+ if (s.agent === agent && member?.state === "working") return false
80
+ for (const m of s.crew) {
81
+ if (m.state !== "working") continue
82
+ m.ms += now - (m.since ?? now)
83
+ m.since = undefined
84
+ m.state = m.agent === BRAIN ? "waiting" : "done"
85
+ }
86
+ if (member) Object.assign(member, { state: "working", since: now })
87
+ else s.crew.push({ agent, state: "working", ms: 0, since: now })
88
+ s.agent = agent
89
+ s.who = faceOf(agent).name
90
+ return true
91
+ }
92
+
93
+ /** The request is over: every agent is done, and the one working at the end failed or was stopped with it. */
94
+ export function crewDone(s: Inspector, status: "done" | "fail" | "stopped", now = Date.now()): void {
95
+ for (const m of s.crew) {
96
+ const working = m.state === "working"
97
+ if (working) m.ms += now - (m.since ?? now)
98
+ m.since = undefined
99
+ m.state = working && status === "fail" ? "failed" : working && status === "stopped" ? "stopped" : "done"
100
+ }
101
+ }
102
+
103
+ /** How long a member has worked, its current turn included. */
104
+ export function crewMs(m: CrewMember, now = Date.now()): number {
105
+ return m.ms + (m.since !== undefined ? now - m.since : 0)
64
106
  }
65
107
 
66
108
  export function startProgress(s: Inspector, title: string): void {
package/src/tui/text.ts CHANGED
@@ -7,6 +7,14 @@ export function clip(text: string, n = 72): string {
7
7
  return s.length <= n ? s : `${s.slice(0, n - 1)}…`
8
8
  }
9
9
 
10
+ /** Markdown as the text it reads as, for rows cut to a width, where markers would show: "**3** in `src`" → "3 in src". */
11
+ export function plainText(md: string): string {
12
+ return md
13
+ .replace(/\[([^\]]+)\]\([^)\s]+\)/g, "$1")
14
+ .replace(/\*\*|__|`/g, "")
15
+ .replace(/^#{1,6}\s+/, "")
16
+ }
17
+
10
18
  /** "…/pages/LoginPage.ts": a path cut from the start, since its end tells files apart. */
11
19
  export function tailCut(text: string, n: number): string {
12
20
  return text.length <= n ? text : `…${text.slice(text.length - n + 1)}`
package/src/tui/theme.ts CHANGED
@@ -19,6 +19,32 @@ export const theme = {
19
19
  onButton: "#0d1117",
20
20
  }
21
21
 
22
+ /**
23
+ * The agents' colours (src/tui/agentIcons.ts), one per agent: its main tone, for its name and icon, and the deeper one
24
+ * where its icon's shapes overlap. Neighbouring hues go to agents that do the same kind of work.
25
+ */
26
+ export const AGENT_HUES = {
27
+ // Plan and lead.
28
+ purple: { main: "#a78bfa", deep: "#5b2bc4" },
29
+ indigo: { main: "#8fa2ff", deep: "#3d4fd6" },
30
+ orchid: { main: "#e0a3f5", deep: "#a443d6" },
31
+ magenta: { main: "#f59fe0", deep: "#c2389e" },
32
+ // Run and look.
33
+ blue: { main: "#a9d0f8", deep: "#5ea6f0" },
34
+ cyan: { main: "#8ee3f5", deep: "#1fa3c4" },
35
+ yellow: { main: "#f0e48c", deep: "#b8a32a" },
36
+ // Write code.
37
+ green: { main: "#6fd3b2", deep: "#3a9a7e" },
38
+ leaf: { main: "#9be89b", deep: "#3fa34d" },
39
+ lime: { main: "#cde88a", deep: "#86a92c" },
40
+ teal: { main: "#7fe0d6", deep: "#1f9c90" },
41
+ // Judge.
42
+ pink: { main: "#f29bc9", deep: "#e0457b" },
43
+ rose: { main: "#ff9eaa", deep: "#d93a52" },
44
+ coral: { main: "#ffb199", deep: "#e0603a" },
45
+ orange: { main: "#ffc58a", deep: "#d6701f" },
46
+ }
47
+
22
48
  /** 950 → "950", 47_100 → "47.1K", 2_300_000 → "2.3M". */
23
49
  export function formatTokens(n: number): string {
24
50
  if (n < 1000) return String(n)
@@ -2,7 +2,8 @@ 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 { createContext, useContext, type ReactNode } from "react"
5
+ import { useRenderer } from "@opentui/react"
6
+ import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
6
7
 
7
8
  import type { CodeResult, RunStatus } from "../types.ts"
8
9
  import { CARD_MARK, type Card } from "../card.ts"
@@ -11,6 +12,7 @@ import { suiteExitCode, type SuiteResult } from "../suite/run.ts"
11
12
  import { STATUS_MARK, shortPath, type RunSummary } from "../summary.ts"
12
13
  import { BAR } from "./components.tsx"
13
14
  import { formatDuration, formatTokens, theme } from "./theme.ts"
15
+ import { readable } from "./readable.ts"
14
16
  import { clip, shortLink } from "./text.ts"
15
17
 
16
18
  /** The transcript: its lines, and the cards for a run, a suite, a tool result, and what a request produced. */
@@ -363,30 +365,132 @@ function replyStyle(): SyntaxStyle {
363
365
  return style
364
366
  }
365
367
 
366
- /** A reply cut into what reads better laid out than wrapped: headings, list items with their depth, and the rest. */
368
+ let notes: SyntaxStyle | undefined
369
+ /** The agent's notes between its calls: the reply's markdown styles, in the colour of thoughts. */
370
+ function noteStyle(): SyntaxStyle {
371
+ notes ||= SyntaxStyle.fromStyles({
372
+ default: { fg: theme.thought },
373
+ "markup.strong": { bold: true },
374
+ "markup.italic": { italic: true },
375
+ "markup.raw": { fg: theme.memory },
376
+ "markup.link": { fg: theme.accent, underline: true },
377
+ "markup.link.label": { fg: theme.accent, underline: true },
378
+ "markup.link.url": { fg: theme.muted },
379
+ })
380
+ return notes
381
+ }
382
+
383
+ /** A reply cut into what reads better laid out than wrapped: headings, list items with their depth, tables, and the rest. */
367
384
  export type ReplyBlock =
368
385
  | { kind: "heading"; text: string }
369
386
  | { kind: "item"; depth: number; mark: string; text: string }
387
+ | { kind: "table"; text: string }
370
388
  | { kind: "markdown"; text: string }
371
389
 
390
+ /**
391
+ * A table cell in a terminal without hyperlinks shows a link's label only: there the address is written out beside
392
+ * it and would take the column over. Where links are clickable the cell keeps them.
393
+ */
394
+ export function tableText(md: string): string {
395
+ return md.replace(/\[([^\]]+)\]\((?:https?:|mailto:)[^)\s]*\)/g, "$1")
396
+ }
397
+
398
+ /** Bare addresses longer than this get a short label, where the terminal makes the label a link. */
399
+ const BARE_LINK_MAX = 40
400
+ /** An address on its own: not a link's target ("](") or label, nor in angle brackets, quotes, or a word. */
401
+ const BARE_URL = /(?<![\w[<"'`/])(?<!\]\()https?:\/\/[^\s<>()[\]`"']+/g
402
+
403
+ /** "dev.azure.com/…/edit/1234": the host and the last parts of the path that fit, since those tell links apart. */
404
+ export function urlLabel(href: string, max = BARE_LINK_MAX): string {
405
+ const shown = href.replace(/^https?:\/\//, "").replace(/\/$/, "")
406
+ if (shown.length <= max) return shown
407
+ const [host, ...parts] = shown.split("/")
408
+ let tail = ""
409
+ for (const part of parts.reverse()) {
410
+ const next = tail ? `${part}/${tail}` : part
411
+ if (host!.length + 3 + next.length > max) break
412
+ tail = next
413
+ }
414
+ // Even the last part does not fit: as much of its start as does.
415
+ if (!tail) tail = `${parts[0]!.slice(0, Math.max(4, max - host!.length - 4))}…`
416
+ return `${host}/…/${tail}`
417
+ }
418
+
419
+ /** A long bare address as a short link, clickable to the whole address. Inline code and trailing punctuation stay. */
420
+ export function linkBareUrls(line: string): string {
421
+ return line
422
+ .split(/(`[^`]*`)/)
423
+ .map((part) =>
424
+ part.startsWith("`")
425
+ ? part
426
+ : part.replace(BARE_URL, (url) => {
427
+ const tail = /[.,;:!?]+$/.exec(url)?.[0] || ""
428
+ const href = tail ? url.slice(0, -tail.length) : url
429
+ if (href.replace(/^https?:\/\//, "").length <= BARE_LINK_MAX) return url
430
+ return `[${urlLabel(href).replace(/([\\`*_[\]])/g, "\\$1")}](${href})${tail}`
431
+ }),
432
+ )
433
+ .join("")
434
+ }
435
+
436
+ /** A table's delimiter row: "|---|:--:|", or "--- | ---" without the outer pipes. */
437
+ const TABLE_RULE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
438
+
439
+ /** A line that starts a table: a row with a leading pipe, or any row with a pipe that a delimiter row follows. */
440
+ function startsTable(line: string, next: string | undefined): boolean {
441
+ return /^\s*\|/.test(line) || (line.includes("|") && next !== undefined && next.includes("|") && TABLE_RULE.test(next))
442
+ }
443
+
444
+ /** Whether the terminal makes links clickable (OSC 8); the answer can come after the first frame. */
445
+ function useHyperlinks(): boolean {
446
+ const renderer = useRenderer()
447
+ const [on, setOn] = useState(renderer.capabilities?.hyperlinks === true)
448
+ useEffect(() => {
449
+ const changed = (c: { hyperlinks?: boolean } | null) => setOn(c?.hyperlinks === true)
450
+ renderer.on("capabilities", changed)
451
+ return () => {
452
+ renderer.off("capabilities", changed)
453
+ }
454
+ }, [renderer])
455
+ return on
456
+ }
457
+
372
458
  const ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
373
459
 
374
- export function replyBlocks(md: string): ReplyBlock[] {
460
+ /** `hyperlinks`: the terminal makes links clickable, so long bare addresses become short links and table cells keep theirs. */
461
+ export function replyBlocks(md: string, opts: { hyperlinks?: boolean } = {}): ReplyBlock[] {
375
462
  const blocks: ReplyBlock[] = []
376
463
  let rest: string[] = []
464
+ let table: string[] = []
377
465
  let fence = false
378
466
  const flush = () => {
379
467
  const text = rest.join("\n").trim()
380
468
  if (text) blocks.push({ kind: "markdown", text })
381
469
  rest = []
382
470
  }
383
- for (const line of md.replace(/\r/g, "").split("\n")) {
384
- if (/^\s*(```|~~~)/.test(line)) fence = !fence
471
+ // A table is a block of its own: in one block with the text around it, the text above it was not drawn.
472
+ const endTable = () => {
473
+ if (table.length) blocks.push({ kind: "table", text: table.join("\n") })
474
+ table = []
475
+ }
476
+ const links = (line: string) => (opts.hyperlinks ? linkBareUrls(line) : line)
477
+ const lines = md.replace(/\r/g, "").split("\n")
478
+ for (let n = 0; n < lines.length; n++) {
479
+ const raw = lines[n]!
480
+ if (/^\s*(```|~~~)/.test(raw)) fence = !fence
481
+ // A table runs from its first row while its lines have a pipe; a row may have no leading pipe.
482
+ if (!fence && (table.length ? raw.trim() !== "" && raw.includes("|") : startsTable(raw, lines[n + 1]))) {
483
+ if (!table.length) flush()
484
+ table.push(opts.hyperlinks ? linkBareUrls(raw) : tableText(raw))
485
+ continue
486
+ }
487
+ endTable()
385
488
  const last = blocks.at(-1)
386
- if (fence || /^\s*(```|~~~)/.test(line) || /^\s*\|/.test(line)) {
387
- rest.push(line)
489
+ if (fence || /^\s*(```|~~~)/.test(raw)) {
490
+ rest.push(raw)
388
491
  continue
389
492
  }
493
+ const line = links(raw)
390
494
  const heading = /^#{1,6}\s+(.*)$/.exec(line)
391
495
  const item = ITEM.exec(line)
392
496
  if (heading) {
@@ -404,13 +508,15 @@ export function replyBlocks(md: string): ReplyBlock[] {
404
508
  rest.push(line)
405
509
  } else rest.push(line)
406
510
  }
511
+ endTable()
407
512
  flush()
408
513
  return blocks
409
514
  }
410
515
 
411
516
  /** The agent's reply: headings with room above, list items with their marks in a column so wrapped lines hang under the text. */
412
517
  function ReplyBody({ text }: { text: string }) {
413
- const blocks = replyBlocks(text)
518
+ // Whatever wrote it, the reply is made readable first: records as tables, times as dates, JSON as code.
519
+ const blocks = replyBlocks(readable(text, useContext(LineRoom) || 80), { hyperlinks: useHyperlinks() })
414
520
  return (
415
521
  <box style={{ flexDirection: "column", flexShrink: 0 }}>
416
522
  {blocks.map((b, i) =>
@@ -419,7 +525,7 @@ function ReplyBody({ text }: { text: string }) {
419
525
  <strong>{b.text}</strong>
420
526
  </text>
421
527
  ) : b.kind === "item" ? (
422
- <box key={i} style={{ flexDirection: "row", flexShrink: 0, paddingLeft: b.depth * 2, marginTop: blocks[i - 1]?.kind === "markdown" ? 1 : 0 }}>
528
+ <box key={i} style={{ flexDirection: "row", flexShrink: 0, paddingLeft: b.depth * 2, marginTop: blocks[i - 1]?.kind === "markdown" || blocks[i - 1]?.kind === "table" ? 1 : 0 }}>
423
529
  <text fg={theme.accent} style={{ width: b.mark.length + 1, flexShrink: 0 }}>
424
530
  {b.mark}
425
531
  </text>
@@ -427,6 +533,11 @@ function ReplyBody({ text }: { text: string }) {
427
533
  <markdown content={b.text} syntaxStyle={replyStyle()} fg={theme.text} conceal />
428
534
  </box>
429
535
  </box>
536
+ ) : b.kind === "table" ? (
537
+ // Columns without a grid, so a long table stays one row per item; short columns (names, dates) keep their width and long ones wrap.
538
+ <box key={i} style={{ marginTop: i ? 1 : 0, flexShrink: 0 }}>
539
+ <markdown content={b.text} syntaxStyle={replyStyle()} fg={theme.text} conceal tableOptions={{ style: "columns", widthMode: "content", columnFitter: "balanced", wrapMode: "word", borderColor: theme.border }} />
540
+ </box>
430
541
  ) : (
431
542
  <box key={i} style={{ marginTop: i ? 1 : 0, flexShrink: 0 }}>
432
543
  <markdown content={b.text} syntaxStyle={replyStyle()} fg={theme.text} conceal tableOptions={{ widthMode: "content", wrapMode: "word" }} />
@@ -456,6 +567,7 @@ function callParts(text: string): [string, string] {
456
567
  * and the first line under an entry gets "⎿".
457
568
  */
458
569
  export function TranscriptLine({ line, prev }: { line: Line; prev?: Line }) {
570
+ const room = useContext(LineRoom)
459
571
  // An entry with nothing to say is left out rather than shown as a bare mark.
460
572
  if (!line.text.trim() && !line.card && !line.summary && !line.suite && line.kind !== "sys") return null
461
573
  if (line.kind === "card" && line.card) return <ResultCard card={line.card} />
@@ -517,7 +629,8 @@ export function TranscriptLine({ line, prev }: { line: Line; prev?: Line }) {
517
629
  <span fg={theme.text}>{line.text}</span>
518
630
  </text>
519
631
  ) : line.kind === "thought" ? (
520
- <text fg={theme.thought}>{line.text}</text>
632
+ // What the agent says between its calls is markdown too: bold, code, and links read as they do in a reply.
633
+ <markdown content={readable(line.text, room || 80)} syntaxStyle={noteStyle()} fg={theme.thought} conceal />
521
634
  ) : line.kind === "timing" ? (
522
635
  <text fg={theme.faint}>{line.text}</text>
523
636
  ) : line.kind === "code" ? (
package/src/types.ts CHANGED
@@ -135,7 +135,7 @@ export type FileAction = "read" | "write" | "edit"
135
135
  /** One item of an agent's own to-do list (the agent runtime's todowrite). */
136
136
  export type PlanItem = { text: string; status: "pending" | "in_progress" | "completed" | "cancelled" }
137
137
 
138
- export type CodeEvent =
138
+ export type CodeEvent = (
139
139
  | { type: "code"; kind: "info"; text: string }
140
140
  /** A finished tool call: `tool` is its name, and `file` the file it read or changed, relative to the repository. */
141
141
  | { type: "code"; kind: "tool"; text: string; ok: boolean; tool?: string; file?: { path: string; action: FileAction }; skill?: string; id?: string }
@@ -153,6 +153,10 @@ export type CodeEvent =
153
153
  | { type: "code"; kind: "attempt"; attempt: number; max: number; why: string; fixing: string[] }
154
154
  /** What Scribe said it did in an attempt: its notes, and the files it changed. */
155
155
  | { type: "code"; kind: "answer"; attempt: number; notes?: string; changed: string[] }
156
+ ) & {
157
+ /** The agent whose session sent it (its name in src/agents.ts), set by openSession, so the shell shows who is working. */
158
+ agent?: string
159
+ }
156
160
 
157
161
  export type AgentEvent =
158
162
  | { type: "log"; level: "info" | "warn" | "error"; text: string }