Public: anyone can read this; the crew can change it.

Improve the Forge MCP UI as it appears inside ChatGPT. #11959

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11956-improve-the-forge-mcp-ui-as-it-appears-i → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/11956-improve-the-forge-mcp-ui-as-it-appears-i was merged into main as aeb08fc.

Review

Sign in to review this change.
8 files changed +166 −27
docs/MCP.md +4 −1
@@ -113,7 +113,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
113113 | `follow_mode` | — | How a fork follows its parent: take updates, tell me, or don't follow | 281 |
114114 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
115115 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
116 | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 |
116 | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 239 |
117117 | `forges` | — | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 387 |
118118 | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 |
119119 | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 397 |
@@ -533,6 +533,7 @@ answers
533533 | `ui://forge/scorecard` | `scorecard` | The crew's scorecard, one row per agent: landed and red rate all time, then the week (forge admin). |
534534 | `ui://forge/crew` | `now`, `crew` | `now`: what the crew is doing right now, with Stop buttons. `crew` (listing): one row per seat, who fills it and what runs and waits, or what it costs to leave it empty; linked to Settings › Crew. |
535535 | `ui://forge/health` | `health` | Green or red with the version, then one row per cause: draining, a blocked rollout, degraded lanes, incidents, apps (an admin sees all; a stranger only the verdict). |
536 | `ui://forge/forge` | `forge_view` | The forge in one card: healthy or not and what is running, then the apps with work going, busiest first; quiet apps and what waits on you are counted. |
536537 | `ui://forge/capacity` | `capacity` (listing, admin) | One row per setting with its live value (set ones marked), linked to Settings › Capacity; says when the forge cannot swap itself. |
537538 | `ui://forge/board` | `boards` | One board's panels (person admins). |
538539 | `ui://forge/forks` | `fork_tree`, `forks` | The fork tree around a repository, each row with how it follows; `forks` a row per fork with its commits ahead and behind, `kind: chain` its chain (a severed chain says so); each linked to the repository, at most 30 and one More row. |
@@ -547,6 +548,8 @@ answers
547548 | `ui://forge/reports` | `reports` (listing) | Waiting reports from outsiders: one row each, its state and who filed it, linked to the repository's reports. |
548549 | `ui://forge/usage` | `usage` | The window's figures as rows: runs, cost and tokens, by agent for a repo, or today and last 24 hours with each account's headroom. |
549550
551 **Who controls what (#11956).** The host (ChatGPT, Claude) controls the frame around a card: its size limits, border, theme variables, whether it opens a `ui://` page at all, and the tool's name and description in its picker (so a generic line such as "Find issues, pulls…" is the tool's `description`, which is for the model and is held to the size ceiling; the card cannot change it). Forge controls what is inside the frame: `structuredContent.card` (`title`, `status`, `rows` of `label`, `value`, `link`, `depth`, `actions`) and the one page that draws it (`CARD_SHELL`). A host that does not render `ui://` pages shows only the text answer, so the text always carries the full data; the card is a summary of it. Not promised: fonts, per-row colour, truncation by the host, or any widget beyond title, status, rows, links and buttons. Not tested in the live ChatGPT connector from the build sandbox; the wording was checked against the card model only.
552 **Card vocabulary.** *title*: what this is (`Pulls`, or `#7 the pull's title` for one thing, cut at 90 characters on a word with `…`; the whole title is in the text answer). *status*: the one line every row shares, counts first (`5 open · 2 red · 3 green`, `healthy · 3 running · 1 queued`). *row label*: the identity (`#7 title`, `owner/name`), linked when it has a page. *row value*: only what differs from the other rows, `·`-separated, state words in plain language (`check green`, `no check`, `approved`), never a state the status line already said. *actions*: buttons that call a tool.
550553 The Runs card (`runs`) is one row per run, newest first: glyph, kind, the issue or pull and its title, then the state as a word with how long it ran (or has been running), linked to the run page; failed runs lead the status line. The Plan card (`map`) shows one row per pillar with
551554 its ready, working and waiting counts, and the first five ready issues linked
552555 to their pages; it has no actions. The asks card (`ui://forge/asks`, drawn by `asks`) is one row per waiting decision:
src/web/studio/cards.ts +64 −15
@@ -13,7 +13,7 @@ import { timeAgo } from "../ui.ts";
1313 import { VERB_WORDS } from "../../actions/asks_words.ts";
1414 import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts";
1515
16 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "forges" | "held" | "drill" | "reports" | "usage" | "health" | "capacity";
16 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "forges" | "held" | "drill" | "reports" | "usage" | "health" | "capacity" | "forge";
1717
1818 export interface CardRow {
1919 readonly label: string;
@@ -46,7 +46,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4646
4747 export const CARD_MIME = "text/html;profile=mcp-app";
4848 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
49 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues", "held", "forges", "drill", "reports", "usage", "health", "capacity"];
49 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues", "held", "forges", "drill", "reports", "usage", "health", "capacity", "forge"];
5050
5151 /** The tool whose answer each card draws. */
5252 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -80,6 +80,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
8080 reports: "reports",
8181 usage: "usage",
8282 health: "health",
83 forge_view: "forge",
8384 capacity: "capacity",
8485 crew: "crew",
8586 };
@@ -114,6 +115,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
114115 usage: "Spend",
115116 health: "Forge health",
116117 capacity: "Capacity",
118 forge: "The forge",
117119 };
118120
119121 type Obj = Record<string, unknown>;
@@ -123,6 +125,19 @@ const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
123125 const text = (v: unknown): string =>
124126 typeof v === "string" ? v : typeof v === "number" ? String(v) : "";
125127 const LIMIT = 12;
128 /** Longest title a row carries; the whole title stays in the tool's text. */
129 export const TITLE_CHARS = 90;
130 /** One line: whitespace folded, cut at `max` on a word where it can, with an ellipsis. */
131 export function clip(v: unknown, max = TITLE_CHARS): string {
132 const s = text(v).replace(/\s+/g, " ").trim();
133 if (s.length <= max) return s;
134 const cut = s.slice(0, max - 1);
135 const at = cut.lastIndexOf(" ");
136 return `${(at > max / 2 ? cut.slice(0, at) : cut).trimEnd()}…`;
137 }
138 /** "3 red · 2 green": the counts of `words` that occur, in the order given, zero left out. */
139 const tally = (words: readonly string[], order: readonly string[]): string =>
140 order.map((w) => [w, words.filter((x) => x === w).length] as const).filter(([, n]) => n > 0).map(([w, n]) => `${n} ${w}`).join(" · ");
126141 const FEED_LIMIT = 20;
127142
128143 /** A pull's review word: the pile's `verdict`, or the counts a repository's list carries. */
@@ -486,6 +501,33 @@ export function cardFor(
486501 const ver = text(v["version"]);
487502 return { kind, title: CARD_TITLE[kind], status: `${v["ok"] ? "green" : "red"}${ver === "" ? "" : ` · ${ver}`}`, rows, actions };
488503 }
504 if (kind === "forge") {
505 // `forge_view` answers `{ counts, apps: [{ app, running, queued }], runs, health }`:
506 // the verdict first,
507 // then the apps with work going, busiest first; the quiet ones are counted, not listed.
508 const v = obj(value);
509 const c = obj(v["counts"]);
510 if (!("counts" in v)) return undefined;
511 const link = web(origin) ? { link: `${origin}/` } : {};
512 const ok = obj(v["health"])["ok"];
513 const busy = list(v["apps"]).map(obj)
514 .map((a) => ({ app: text(a["app"]), running: Number(a["running"]) || 0, queued: Number(a["queued"]) || 0 }))
515 .filter((a) => a.running + a.queued > 0)
516 .sort((a, b) => b.running + b.queued - (a.running + a.queued));
517 for (const a of busy.slice(0, LIMIT))
518 rows.push({
519 label: a.app,
520 value: [a.running > 0 ? `${a.running} running` : "", a.queued > 0 ? `${a.queued} queued` : ""].filter((w) => w !== "").join(" · "),
521 ...(web(origin) && slugOf(a.app) ? { link: `${origin}/${a.app}` } : {}),
522 });
523 if (busy.length > LIMIT) rows.push({ label: "More", value: `${busy.length - LIMIT} more busy apps not shown`, ...link });
524 const apps = Number(c["apps"]) || 0;
525 rows.push({ label: "Apps", value: `${apps} in all${busy.length === 0 ? " · none busy" : ""}`, ...link });
526 const waiting = Number(c["waiting"]) || 0;
527 if (waiting > 0) rows.push({ label: "Waiting", value: `${waiting} waiting on you`, ...link });
528 const going = [Number(c["running"]) > 0 ? `${Number(c["running"])} running` : "", Number(c["queued"]) > 0 ? `${Number(c["queued"])} queued` : ""].filter((w) => w !== "").join(" · ");
529 return { kind, title: CARD_TITLE[kind], status: `${ok === true ? "healthy" : ok === false ? "needs attention" : "health unknown"} · ${going === "" ? "crew idle" : going}`, rows, actions };
530 }
489531 if (kind === "capacity") {
490532 // `listCapacity`: `{ settings: [{ label, value, source }], egress, selfSwap }`; a plan or a change has no `settings`.
491533 const v = obj(value);
@@ -630,7 +672,7 @@ export function cardFor(
630672 rows.push({ label: "Conflicts", value: conflicts.slice(0, LIMIT).join(", ") });
631673 const preview = text(extra["preview"]);
632674 if (web(preview)) rows.push({ label: "Preview", value: preview, link: preview });
633 return { kind, title: `#${number} ${text(p["title"])}`.trim(), status, rows, actions };
675 return { kind, title: `#${number} ${clip(p["title"])}`.trim(), status, rows, actions };
634676 }
635677 if (kind === "search") {
636678 // `search` answers `{ repos, issues, code, … }`; the query comes in `extra`. One row per hit:
@@ -649,7 +691,7 @@ export function cardFor(
649691 const ext = text(r["htmlUrl"]);
650692 hits.push({
651693 label,
652 value: text(r["description"]) || `${text(r["openIssues"])} open issues`,
694 value: clip(r["description"], SAYS_WORDS) || `${text(r["openIssues"])} open issues`,
653695 ...(local ? page(text(r["owner"]), text(r["name"])) : web(ext) ? { link: ext } : {}),
654696 });
655697 }
@@ -660,7 +702,7 @@ export function cardFor(
660702 const at = obj(i["repo"]);
661703 const ext = text(i["htmlUrl"]);
662704 hits.push({
663 label: `${pull ? "pull" : "issue"} #${n} ${text(i["title"])}`.trim(),
705 label: `${pull ? "pull" : "issue"} #${n} ${clip(i["title"])}`.trim(),
664706 value: [text(i["state"]), ...(local && untrusted.has(`${text(at["owner"])}/${text(at["name"])}#${n}`) ? ["untrusted"] : [])].join(" · "),
665707 ...(local && /^[1-9]\d*$/.test(n)
666708 ? page(text(at["owner"]), text(at["name"]), `/${pull ? "pulls" : "issues"}/${n}`)
@@ -689,10 +731,12 @@ export function cardFor(
689731 const slug = text(extra["repo"]);
690732 const pillars = obj(extra["pillars"]);
691733 const waits = obj(extra["waits"]);
734 // A state every row shares is the status line's; a row keeps it only as the odd one out.
735 const mixed = new Set(items.map((i) => text(i["state"]))).size > 1;
692736 for (const i of items.slice(0, ISSUES_ROWS)) {
693737 const n = text(i["number"]);
694738 const path = `/${slug}/issues/${n}`;
695 const parts = [text(i["state"])];
739 const parts = mixed ? [text(i["state"])] : [];
696740 const pillar = text(pillars[n]);
697741 if (pillar !== "") parts.push(pillar);
698742 // A blocker here is its number; one in another repository is `owner/name#number`.
@@ -700,13 +744,16 @@ export function cardFor(
700744 if (on.length > 0) parts.push(`waits on ${on.map((x) => (x.includes("#") ? x : `#${x}`)).join(" ")}`);
701745 if (i["untrusted"] === true) parts.push("untrusted");
702746 rows.push({
703 label: `#${n} ${text(i["title"])}`.trim(),
747 label: `#${n} ${clip(i["title"])}`.trim(),
704748 value: parts.join(" · "),
705749 ...(web(origin) && slugOf(slug) && /^[1-9]\d*$/.test(n) && here(origin, path) ? { link: origin + path } : {}),
706750 });
707751 }
708752 const more = text(obj(value)["next_cursor"]) !== "" ? "+" : "";
709 status = `${items.length}${more} issue${items.length === 1 && more === "" ? "" : "s"}`;
753 const one = text(items[0]?.["state"]);
754 status = mixed
755 ? `${items.length}${more} issues · ${tally(items.map((i) => text(i["state"])), ["open", "closed"])}`
756 : `${items.length}${more} ${one === "" ? "" : `${one} `}issue${items.length === 1 && more === "" ? "" : "s"}`;
710757 return { kind, title: CARD_TITLE[kind], status, rows, actions };
711758 }
712759 if (kind === "pulls") {
@@ -715,8 +762,10 @@ export function cardFor(
715762 const pulls = list(obj(value)["pulls"]).map(obj);
716763 // The tool stops at `limit`: a full answer may be the first of more.
717764 const cut = typeof extra["limit"] === "number" && pulls.length >= extra["limit"];
718 status = `${pulls.length}${cut ? "+" : ""} ${extra["state"] === "closed" ? "closed" : "open"}`;
719765 const state = extra["state"] === "closed" ? "closed" : "open";
766 // The state is the status line's, said once; a row says only what differs between rows.
767 const checks = pulls.map((p) => checkWord(text(p["check"])));
768 status = [`${pulls.length}${cut ? "+" : ""} ${state}`, state === "open" ? tally(checks, ["red", "running", "green"]) : ""].filter((w) => w !== "").join(" · ");
720769 for (const p of pulls.slice(0, PULLS_LIMIT)) {
721770 const number = text(p["number"]);
722771 const check = text(p["check"]);
@@ -725,8 +774,8 @@ export function cardFor(
725774 const verdict = text(p["verdict"]);
726775 const review = reviewWord(verdict, Number(p["approvals"]), Number(p["changesRequested"]));
727776 rows.push({
728 label: `#${number} ${text(p["title"])}`.trim(),
729 value: `${state} · ${checkWord(check)} · ${review}`,
777 label: `#${number} ${clip(p["title"])}`.trim(),
778 value: `${checkWord(check) === "none" ? "no check" : `check ${checkWord(check)}`} · ${review}`,
730779 ...(web(origin) && slugOf(slug) && /^[1-9]\d*$/.test(number) && here(origin, path)
731780 ? { link: origin + path }
732781 : {}),
@@ -758,7 +807,7 @@ export function cardFor(
758807 : ` · ${span(ended - started)}`;
759808 const path = `/${slug}/runs/${text(r["id"])}`;
760809 rows.push({
761 label: `${RUN_GLYPH[state] ?? "·"} ${text(r["kind"])}${number === "" ? "" : ` #${number}`}${title === "" ? "" : ` ${title}`}`,
810 label: `${RUN_GLYPH[state] ?? "·"} ${text(r["kind"])}${number === "" ? "" : ` #${number}`}${title === "" ? "" : ` ${clip(title)}`}`,
762811 value: `${state}${length}`,
763812 ...(web(origin) && slugOf(slug) && RUN_ID.test(text(r["id"])) && here(origin, path)
764813 ? { link: origin + path }
@@ -848,7 +897,7 @@ export function cardFor(
848897 const base = web(origin) && /^[^/\s]+\/[^/\s]+$/.test(slug) ? `${origin}/${slug}/issues/` : "";
849898 const at = (n: string): { link?: string } => (base !== "" && /^\d+$/.test(n) ? { link: base + n } : {});
850899 status = text(v["state"]) + (v["closeReason"] ? ` · ${text(v["closeReason"])}` : "");
851 rows.push({ label: `#${number}`, value: text(v["title"]), ...at(number) });
900 rows.push({ label: "On the forge", value: `${slug} #${number}`.trim(), ...at(number) });
852901 // The title is an outsider's when the answer says so; the card says so too.
853902 if (v["untrusted"] === true) rows.push({ label: "Author", value: "untrusted: not a writer on this repository" });
854903 rows.push({ label: "Assignee", value: text(v["assignee"]) || "nobody" });
@@ -856,7 +905,7 @@ export function cardFor(
856905 if (labels.length > 0) rows.push({ label: "Labels", value: labels.join(", ") });
857906 for (const b of list(v["blockedBy"]).map(obj).slice(0, LIMIT))
858907 rows.push({ label: "Waits for", value: `#${text(b["number"])} · ${text(b["state"])}`, ...at(text(b["number"])) });
859 return { kind, title: `Issue #${number}`, status, rows, actions };
908 return { kind, title: `#${number} ${clip(v["title"])}`.trim(), status, rows, actions };
860909 }
861910 if (kind === "app") {
862911 const apps = list(value).map(obj);
@@ -1145,7 +1194,7 @@ report();
11451194 `;
11461195
11471196 const pal = (t: "light" | "dark"): string => `--c-bg:${CARD_PALETTE[t].bg};--c-fg:${CARD_PALETTE[t].fg};--c-link:${CARD_PALETTE[t].link};color-scheme:${t}`;
1148 export const CARD_CSS = `:root{${pal("light")}}@media(prefers-color-scheme:dark){:root{${pal("dark")}}}:root[data-theme=light]{${pal("light")}}:root[data-theme=dark]{${pal("dark")}}body{font:14px/1.4 var(--font-sans,system-ui,sans-serif);margin:0;padding:12px;background:var(--c-bg);color:var(--c-fg)}h2{margin:0 0 4px;font-size:16px}.status{margin:0 0 8px}dl{margin:0}dt{font-weight:600;margin-top:6px}dd{margin:0}a{color:var(--c-link);text-underline-offset:2px;display:inline-flex;align-items:center;min-height:44px}button,input{font:inherit;color:var(--c-fg);border:0;background:color-mix(in srgb,var(--c-fg) 12%,var(--c-bg));border-radius:var(--border-radius-sm,6px);min-height:44px;box-sizing:border-box}button{margin:8px 6px 0 0;padding:4px 12px}input{padding:4px 8px;width:100%;margin-top:8px}`;
1197 export const CARD_CSS = `:root{${pal("light")}}@media(prefers-color-scheme:dark){:root{${pal("dark")}}}:root[data-theme=light]{${pal("light")}}:root[data-theme=dark]{${pal("dark")}}body{font:14px/1.4 var(--font-sans,system-ui,sans-serif);margin:0;padding:12px;background:var(--c-bg);color:var(--c-fg)}h2{margin:0 0 4px;font-size:16px}.status{margin:0 0 8px}dl{margin:0}h2,dt,dd{overflow-wrap:anywhere}.status{opacity:.85}dt{font-weight:600;margin-top:10px;padding-top:8px;border-top:1px solid color-mix(in srgb,var(--c-fg) 15%,var(--c-bg))}dt:first-child{border-top:0;padding-top:0}dd{margin:0;opacity:.85}a{color:var(--c-link);text-underline-offset:2px;display:inline-flex;align-items:center;min-height:44px}button,input{font:inherit;color:var(--c-fg);border:0;background:color-mix(in srgb,var(--c-fg) 12%,var(--c-bg));border-radius:var(--border-radius-sm,6px);min-height:44px;box-sizing:border-box}button{margin:8px 6px 0 0;padding:4px 12px}input{padding:4px 8px;width:100%;margin-top:8px}`;

Principle 6: a border-top on every dt boxes each row; use the existing margin for rhythm (principle 3). Replace opacity with a muted colour token. .status is also declared twice.

11491198
11501199 export const CARD_SHELL = (kind: CardKind): string =>
11511200 html`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta name="color-scheme" content="light dark"><title>${CARD_TITLE[kind]}</title><style>${raw(CARD_CSS)}</style><div id="card"><h2>${CARD_TITLE[kind]}</h2></div><script>${raw(SHELL_JS)}</script>`.text;
test/mcp-card-vocabulary.test.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * The compact card vocabulary (#11956): status line says what the rows share, a row says what
3 * differs, a long title is cut with an ellipsis, and `forge_view` has a card.
4 */
5 import { expect, test } from "bun:test";
6 import { cardFor, clip, TITLE_CHARS, CARD_OF_TOOL } from "../src/web/studio/cards.ts";
7
8 const O = "https://forge.test";
9 const LONG = "word ".repeat(60).trim();
10 const EVIL = `<img src=x onerror=alert(1)>`;
11
12 test("clip keeps short titles, folds whitespace, and cuts long ones on a word with an ellipsis", () => {
13 expect(clip(" a \n b ")).toBe("a b");
14 expect(clip(undefined)).toBe("");
15 const c = clip(LONG);
16 expect(c.length).toBeLessThanOrEqual(TITLE_CHARS);
17 expect(c.endsWith("…")).toBe(true);
18 expect(c).not.toMatch(/\s…$/);
19 expect(clip("x".repeat(300)).length).toBe(TITLE_CHARS);
20 });
21
22 test("pulls: the status line counts checks once; rows do not repeat the state", () => {
23 const pulls = [
24 { number: 1, title: LONG, check: "failed" },
25 { number: 2, title: "b", check: "ok", verdict: "approved" },
26 { number: 3, title: "c", check: "ok" },
27 ];
28 const c = cardFor("pulls", { pulls }, O, { repo: "a/b", state: "open" })!;
29 expect(c.status).toBe("3 open · 1 red · 2 green");
30 expect(c.rows[0]!.label.length).toBeLessThanOrEqual(TITLE_CHARS + 3);
31 expect(c.rows.every((r) => !r.value.startsWith("open"))).toBe(true);
32 expect(c.rows[1]!.value).toBe("check green · approved");
33 });
34
35 test("issues: a shared state moves to the status line; a mixed list keeps it per row", () => {
36 const same = cardFor("issues", { items: [{ number: 1, title: "a", state: "open" }, { number: 2, title: "b", state: "open" }] }, O, { repo: "a/b" })!;
37 expect(same.status).toBe("2 open issues");
38 expect(same.rows.map((r) => r.value)).toEqual(["", ""]);
39 const mixed = cardFor("issues", { items: [{ number: 1, title: "a", state: "open" }, { number: 2, title: "b", state: "closed" }] }, O, { repo: "a/b" })!;
40 expect(mixed.status).toBe("2 issues · 1 open · 1 closed");
41 expect(mixed.rows.map((r) => r.value)).toEqual(["open", "closed"]);
42 const none = cardFor("issues", { items: [] }, O, { repo: "a/b" })!;
43 expect(none.status).toBe("0 issues");
44 });
45
46 test("issue: the title is the card's title, clipped, and escaping is left to the shell's textContent", () => {
47 const c = cardFor("issue", { number: 7, title: LONG, state: "open", labels: [] }, O, { repo: "a/b" })!;
48 expect(c.title.startsWith("#7 ")).toBe(true);
49 expect(c.title.endsWith("…")).toBe(true);
50 expect(c.rows[0]).toEqual({ label: "On the forge", value: "a/b #7", link: `${O}/a/b/issues/7` });
51 expect(cardFor("issue", { number: 8, title: EVIL, state: "open" }, O, { repo: "a/b" })!.title).toBe(`#8 ${EVIL}`);
52 });
53
54 test("pull and runs titles are clipped too; a missing title leaves no dangling space", () => {
55 const p = cardFor("pull", { number: 3, title: LONG, state: "open", head: "x", base: "main" }, O, { repo: "a/b" })!;
56 expect(p.title.length).toBeLessThanOrEqual(TITLE_CHARS + 3);
57 expect(cardFor("pull", { number: 3, state: "open" }, O, { repo: "a/b" })!.title).toBe("#3");
58 const r = cardFor("runs", { runs: [{ id: "x", kind: "build", state: "ok", number: 2, title: LONG }] }, O, { repo: "a/b" })!;
59 expect(r.rows[0]!.label.endsWith("…")).toBe(true);
60 });
61
62 test("forge_view has a card: verdict and load in the status, busy apps first, quiet ones counted", () => {
63 expect(CARD_OF_TOOL["forge_view"]).toBe("forge");
64 const view = {
65 counts: { apps: 5, running: 3, queued: 1, waiting: 2 },
66 apps: [
67 { app: "a/quiet", running: 0, queued: 0 },
68 { app: "a/one", running: 1, queued: 0 },
69 { app: "a/two", running: 2, queued: 1 },
70 ],
71 runs: [],
72 health: { ok: true },
73 };
74 const c = cardFor("forge_view", view, O)!;
75 expect(c.kind).toBe("forge");
76 expect(c.status).toBe("healthy · 3 running · 1 queued");
77 expect(c.rows.map((r) => r.label)).toEqual(["a/two", "a/one", "Apps", "Waiting"]);
78 expect(c.rows[0]).toEqual({ label: "a/two", value: "2 running · 1 queued", link: `${O}/a/two` });
79 expect(c.rows[2]!.value).toBe("5 in all");
80 expect(c.rows[3]!.value).toBe("2 waiting on you");
81 const idle = cardFor("forge_view", { counts: { apps: 0, running: 0, queued: 0, waiting: 0 }, apps: [], runs: [], health: { ok: false } }, "")!;
82 expect(idle.status).toBe("needs attention · crew idle");
83 expect(idle.rows).toEqual([{ label: "Apps", value: "0 in all · none busy" }]);
84 expect(cardFor("forge_view", { nope: 1 }, O)).toBeUndefined();
85 });
test/mcp-cards.test.ts +3 −3
@@ -105,7 +105,7 @@ describe("explore, pulls and feed cards", () => {
105105 expect(c.kind).toBe("pulls");
106106 const row = c.rows.find((r: any) => r.label.includes("pull of openapp"));
107107 expect(row).toBeDefined();
108 expect(row.value).toBe("open · none · no review");
108 expect(row.value).toBe("no check · no review");
109109 expect(row.link).toBe(`${servers.url}/alice/openapp/pulls/1`);
110110 expect(JSON.stringify(c)).not.toContain("hiddenapp");
111111 expect(c.rows).toHaveLength(1);
@@ -113,8 +113,8 @@ describe("explore, pulls and feed cards", () => {
113113
114114 test("a pull row says state, check and review, linked to its pull", () => {
115115 const c = cardFor("pulls", { pulls: [{ repo: "alice/game", number: 3, title: EVIL, check: "ok", verdict: "approved" }, { repo: "alice/game", number: 4, title: "t", check: "failed", verdict: "changes_requested" }] }, "https://forge.test", {})!;
116 expect(c.rows[0]).toEqual({ label: `#3 ${EVIL}`, value: "open · green · approved", link: "https://forge.test/alice/game/pulls/3" });
117 expect(c.rows[1]!.value).toBe("open · red · changes requested");
116 expect(c.rows[0]).toEqual({ label: `#3 ${EVIL}`, value: "check green · approved", link: "https://forge.test/alice/game/pulls/3" });
117 expect(c.rows[1]!.value).toBe("check red · changes requested");
118118 });
119119
120120 test("each card stops at 20 rows", () => {
test/mcp-pulls-card.test.ts +5 −5
@@ -85,8 +85,8 @@ describe("MCP pulls card", () => {
8585 expect(card.status).toBe("2 open");
8686 const [two, one] = [numbers[1]!, numbers[0]!];
8787 expect(card.rows).toEqual([
88 { label: `#${two} ${EVIL}`, value: "open · none · no review", link: `${servers.url}/alice/game/pulls/${two}` },
89 { label: `#${one} First`, value: "open · none · no review", link: `${servers.url}/alice/game/pulls/${one}` },
88 { label: `#${two} ${EVIL}`, value: "no check · no review", link: `${servers.url}/alice/game/pulls/${two}` },
89 { label: `#${one} First`, value: "no check · no review", link: `${servers.url}/alice/game/pulls/${one}` },
9090 ]);
9191 const text = JSON.parse(body.result.content[0].text);
9292 expect(text.pulls.map((p: any) => p.number)).toEqual([two, one]);
@@ -107,14 +107,14 @@ describe("MCP pulls card", () => {
107107 number: i + 1, title: `P${i + 1}`, check,
108108 }));
109109 const card = cardFor("pulls", { pulls }, "https://forge.test", { repo: "alice/game", state: "open" })!;
110 expect(card.rows.map((r) => r.value)).toEqual(["green", "red", "running", "running", "none", "cancelled"].map((w) => `open · ${w} · no review`));
110 expect(card.rows.map((r) => r.value)).toEqual(["green", "red", "running", "running", "none", "cancelled"].map((w) => w === "none" ? "no check · no review" : `check ${w} · no review`));
111111 expect(card.rows[0]!.link).toBe("https://forge.test/alice/game/pulls/1");
112112
113113 const many = Array.from({ length: 30 }, (_, i) => ({ number: i + 1, title: "t", check: "ok" }));
114114 const big = cardFor("pulls", { pulls: many }, "https://forge.test", { repo: "alice/game" })!;
115 expect(big.status).toBe("30 open");
115 expect(big.status).toBe("30 open · 30 green");
116116 const capped = cardFor("pulls", { pulls: many.slice(0, 20) }, "https://forge.test", { repo: "alice/game", limit: 20 })!;
117 expect(capped.status).toBe("20+ open");
117 expect(capped.status).toBe("20+ open · 20 green");
118118 expect(big.rows.length).toBe(20);
119119
120120 // No origin, a repo that is not `owner/name`, or a number that is not one: no link.
test/mcp-search-issues-card.test.ts +1 −1
@@ -44,6 +44,6 @@ describe("issues card", () => {
4444 }, origin, { repo: "a/b", pillars: { "5": "P1" }, waits: { "5": [2, 3] } })!;
4545 expect(card.rows[0]).toEqual({ label: "#5 t", value: "open · P1 · waits on #2 #3 · untrusted", link: "https://forge.test/a/b/issues/5" });
4646 expect(card.rows[1]!.value).toBe("closed");
47 expect(card.status).toBe("2+ issues");
47 expect(card.status).toBe("2+ issues · 1 open · 1 closed");
4848 });
4949 });
test/mcp.test.ts +3 −1
@@ -1299,6 +1299,7 @@ describe("the forge over MCP", () => {
12991299 "ui://forge/explore",
13001300 "ui://forge/feed",
13011301 "ui://forge/flow",
1302 "ui://forge/forge",

Indentation: align with the neighbouring entries.

13021303 "ui://forge/forges",
13031304 "ui://forge/forks",
13041305 "ui://forge/health",
@@ -1436,7 +1437,8 @@ describe("the forge over MCP", () => {
14361437 const card = cardFor("issue", answer, "https://forge.test", { repo: "alice/game" })!;
14371438 expect(card.kind).toBe("issue");
14381439 expect(card.status).toBe("open");
1439 expect(card.rows[0]).toEqual({ label: "#7", value: "<b>Fix</b>", link: "https://forge.test/alice/game/issues/7" });
1440 expect(card.title).toBe("#7 <b>Fix</b>");
1441 expect(card.rows[0]).toEqual({ label: "On the forge", value: "alice/game #7", link: "https://forge.test/alice/game/issues/7" });
14401442 expect(card.rows.find((r) => r.label === "Assignee")!.value).toBe("scout");
14411443 expect(card.rows.find((r) => r.label === "Labels")!.value).toBe("bug");
14421444 expect(card.rows.find((r) => r.label === "Waits for")!.link).toBe("https://forge.test/alice/game/issues/3");
test/xss-everywhere.test.ts +1 −1
@@ -208,7 +208,7 @@ describe("one hostile string, every surface", () => {
208208 expect(card.rows.map((r: { label: string }) => r.label).join("\n")).toContain(EVIL);
209209 expect(JSON.stringify(card)).not.toContain("&lt;");
210210 const issue = JSON.parse(await mcp("issue", { repo: "alice/game", number: issueNo })).result;
211 expect(issue.structuredContent.card.rows[0].value).toBe(EVIL);
211 expect(issue.structuredContent.card.title).toContain(EVIL);
212212 expect(JSON.stringify(issue.structuredContent.card)).not.toContain("&lt;");
213213 expect(JSON.parse(issue.content[0].text).title).toBe(EVIL);
214214 const shell = CARD_SHELL("feed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs