Public: anyone can read this; the crew can change it.
The terminal's About screen shows whether outsiders may direct the crew, and `o` turns it on or off after a plan, as MCP `outsiders` does #7263
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7256-the-terminal-s-about-screen-shows-whethe → main 3 commits fork 5 threads · 5 unresolved
3 files
changed +220
−3
README.md +1
| @@ -368,6 +368,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 368 | 368 | - [](docs/CREW.md) — the crew: agents, how a run works, review, live triage, the merge queue, spend, roles and invites, the sandbox, passports, and the direction a vision pull will write. |
| 369 | 369 | - [](docs/SENTINEL.md) — the forge hunting its own faults; [](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`). |
| 370 | 370 | - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. While it is on, the repository header and its admin's home say so in one line (the line links to the setting for an admin only; on the home it appears under "Show everything", not the lean apps-first view). Nothing obeys it yet. |
| 371 | - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`. | |
| 371 | 372 | - [](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h. |
| 372 | 373 | - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [](docs/FLOW.md). |
| 373 | 374 | - Under it, the panel says the crew's build yield over 24 h (runs finished, pushed, landed; `src/flow/build_yield.ts`) and the median hours to first review (`src/flow/review_latency.ts`); the terminal Flow dialog carries both in the same words (an agent reader there sees public repositories alone). `/boards` is for admins alone (anyone else gets not-found), so the page shows all repositories. |
src/tui/screens/about.ts +61 −3
| @@ -24,7 +24,8 @@ import { repoDir } from "../../git/repos.ts"; | ||
| 24 | 24 | import { forkDrift } from "../../actions/forks.ts"; |
| 25 | 25 | import { upstreamOffer, type UpstreamOffer } from "../../actions/offer_upstream.ts"; |
| 26 | 26 | import { listRefs } from "../../git/read.ts"; |
| 27 | import { updateRepoSettings } from "../../actions/repos.ts"; | |
| 27 | import { directionNote } from "../../crew/direct.ts"; | |
| 28 | import { setCrewOutsiders, updateRepoSettings } from "../../actions/repos.ts"; | |
| 28 | 29 | import { showProvenance } from "../../actions/provenance.ts"; |
| 29 | 30 | import { |
| 30 | 31 | checkLine, |
| @@ -56,7 +57,7 @@ import { style } from "../term/style.ts"; | ||
| 56 | 57 | import { truncate, wrap } from "../term/width.ts"; |
| 57 | 58 | import { badge, card } from "../ui/widgets.ts"; |
| 58 | 59 | import type { Session } from "../session.ts"; |
| 59 | import { PIN_MARK, ago, drawFacts, plural } from "./common.ts"; | |
| 60 | import { PIN_MARK, ago, confirmScreen, drawFacts, plural } from "./common.ts"; | |
| 60 | 61 | import { connectionsScreen, egressScreen } from "./egress.ts"; |
| 61 | 62 | import { newPullScreen } from "./newpull.ts"; |
| 62 | 63 | import { pullScreen } from "./pull.ts"; |
| @@ -94,11 +95,15 @@ export function aboutScreen( | ||
| 94 | 95 | // The following card's rule: an admin's, and only while it is on or to be turned on. |
| 95 | 96 | const mayFollow = (): boolean => |
| 96 | 97 | may() && repo !== undefined && repo.origin === null; |
| 98 | // Outsiders is the same person-and-admin gate; `d` (direct) never clashes | |
| 99 | // with `o`, the fork's upstream offer. | |
| 100 | const mayOutsiders = (): boolean => mayFlow(); | |
| 97 | 101 | const checking = (): boolean => |
| 98 | 102 | repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true; |
| 99 | 103 | const factRows = (): [string, string][] => [ |
| 100 | 104 | ...archivedRow(repo), |
| 101 | 105 | ...flowRow(repo), |
| 106 | ...outsidersRow(repo), | |
| 102 | 107 | ...aboutFacts(f, src, facts, ref), |
| 103 | 108 | ...trailRows(f, repo), |
| 104 | 109 | ...upstreamRows(f, repo), |
| @@ -143,7 +148,7 @@ export function aboutScreen( | ||
| 143 | 148 | }); |
| 144 | 149 | } |
| 145 | 150 | const cloneUrl = rows.find(([label]) => label.startsWith("clone"))?.[1] ?? ""; |
| 146 | return { | |
| 151 | const screen: Screen = { | |
outdated
Rename |
||
| 147 | 152 | title: "About", |
| 148 | 153 | get keys() { |
| 149 | 154 | return [ |
| @@ -160,6 +165,14 @@ export function aboutScreen( | ||
| 160 | 165 | ...(mayFlow() |
| 161 | 166 | ? [binding(["w"], repo?.flowOnly === true ? "flow only off" : "flow only on")] |
| 162 | 167 | : []), |
| 168 | ...(mayOutsiders() | |
| 169 | ? [ | |
| 170 | binding( | |
| 171 | ["d"], | |
| 172 | repo?.crewOutsiders === true ? "outsiders off" : "outsiders on", | |
| 173 | ), | |
| 174 | ] | |
| 175 | : []), | |
| 163 | 176 | ...(mayTrail() |
| 164 | 177 | ? [ |
| 165 | 178 | binding( |
| @@ -285,6 +298,41 @@ export function aboutScreen( | ||
| 285 | 298 | ); |
| 286 | 299 | return true; |
| 287 | 300 | } |
| 301 | if (e.key === "d" && repo !== undefined && mayOutsiders()) { | |
| 302 | const at = repo; | |
| 303 | const on = !at.crewOutsiders; | |
| 304 | void setCrewOutsiders(f.app, f.user, at, on, true).then((plan) => { | |
| 305 | if (!plan.ok) return ctx.toast("that could not be changed", "danger"); | |
| 306 | ctx.push( | |
| 307 | confirmScreen(ctx, screen, { | |
| 308 | title: "Outsiders", | |
| 309 | body: [ | |
| 310 | on | |
| 311 | ? "Turn on: people who cannot write here may direct the crew." | |
| 312 | : "Turn off: only people who can write here direct the crew.", | |
| 313 | ], | |
| 314 | actions: [{ label: "Confirm" }, { label: "Cancel" }], | |
| 315 | onChoose: (i) => { | |
| 316 | if (i !== 0) return; | |
| 317 | void setCrewOutsiders(f.app, f.user, at, on, false).then((done) => { | |
| 318 | if (done.ok) { | |
| 319 | repo = getRepoById(f.db, at.id) ?? at; | |
Wrap this long toast line. |
||
| 320 | rows = factRows(); | |
| 321 | ctx.toast( | |
| 322 | on | |
| 323 | ? "outsiders may direct the crew" | |
| 324 | : "outsiders may not direct the crew", | |
| 325 | "success", | |
| 326 | ); | |
| 327 | } else ctx.toast("that could not be changed", "danger"); | |
| 328 | ctx.invalidate(); | |
| 329 | }); | |
| 330 | }, | |
| 331 | }), | |
| 332 | ); | |
| 333 | }); | |
| 334 | return true; | |
| 335 | } | |
| 288 | 336 | if (e.key === "y" && cloneUrl !== "") { |
| 289 | 337 | ctx.copy(cloneUrl); |
| 290 | 338 | ctx.toast("copied the clone URL", "success"); |
| @@ -374,6 +422,7 @@ export function aboutScreen( | ||
| 374 | 422 | return true; |
| 375 | 423 | }, |
| 376 | 424 | }; |
| 425 | return screen; | |
| 377 | 426 | } |
| 378 | 427 | |
| 379 | 428 | /** |
| @@ -441,6 +490,15 @@ export function flowRow(repo: Repo | undefined): [string, string][] { | ||
| 441 | 490 | return [["Flow only", repo.flowOnly ? "on" : "off"]]; |
| 442 | 491 | } |
| 443 | 492 | |
| 493 | /** Who may direct the crew, in `OUTSIDERS_NOTE`'s words; every reader sees it. */ | |
| 494 | export function outsidersRow(repo: Repo | undefined): [string, string][] { | |
| 495 | if (repo === undefined) return []; | |
| 496 | return wrap(directionNote(repo), 50).map((line, i): [string, string] => [ | |
| 497 | i === 0 ? "Crew" : "", | |
| 498 | line, | |
| 499 | ]); | |
| 500 | } | |
| 501 | ||
| 444 | 502 | /** One line when an admin has put the repository away. */ |
| 445 | 503 | function archivedRow(repo: Repo | undefined): [string, string][] { |
| 446 | 504 | if (repo === undefined || repo.archivedAt === null) return []; |
test/tui-about-outsiders.test.ts added +158
| @@ -0,0 +1,158 @@ | ||
| 1 | /** | |
| 2 | * Outsiders at the terminal's door: the About card says who may direct the | |
| 3 | * crew; `d` plans, asks, and only then calls `setCrewOutsiders`. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { commitToBranch, forkRepo } from "../src/actions/repos.ts"; | |
| 11 | import { setRole } from "../src/actions/roles.ts"; | |
| 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { DIRECTION_NOTE, OUTSIDERS_NOTE } from "../src/crew/direct.ts"; | |
| 14 | import { addTeamMember, createOrg, createTeam, listTeams, OWNERS_TEAM } from "../src/core/orgs.ts"; | |
| 15 | import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import type { Ctx, Screen } from "../src/tui/app/app.ts"; | |
| 18 | import { aboutScreen, outsidersRow } from "../src/tui/screens/about.ts"; | |
| 19 | import { localSource } from "../src/view/local.ts"; | |
| 20 | import type { Session } from "../src/tui/session.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let founder: User; | |
| 28 | let dev: User; | |
| 29 | let bot: User; | |
| 30 | let repo: Repo; | |
| 31 | ||
| 32 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 33 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 34 | return r.value; | |
| 35 | }; | |
| 36 | const line = (): string => | |
| 37 | outsidersRow(getRepoById(app.db, repo.id)).map(([, v]) => v.trim()).join(" "); | |
| 38 | /** Poll for what the test checks; fail clearly at the deadline. */ | |
outdated
|
||
| 39 | async function until(what: string, done: () => boolean, ms = 5000): Promise<void> { | |
| 40 | const end = Date.now() + ms; | |
| 41 | while (!done()) { | |
| 42 | if (Date.now() > end) throw new Error(`timed out waiting for ${what}`); | |
| 43 | await Bun.sleep(10); | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | function card(user: User) { | |
| 48 | const pushed: Screen[] = []; | |
| 49 | const ctx = { | |
| 50 | invalidate() {}, | |
| 51 | toast() {}, | |
| 52 | push: (s: Screen) => pushed.push(s), | |
| 53 | pop() {}, | |
| 54 | copy() {}, | |
| 55 | } as unknown as Ctx; | |
| 56 | const f = { app, db: app.db, user } as unknown as Session; | |
| 57 | const src = localSource(app, getRepoById(app.db, repo.id)!, user); | |
| 58 | const screen = aboutScreen(f, ctx, {} as Screen, src, undefined, "main"); | |
| 59 | const offered = () => screen.keys.some((b) => b.keys.includes("d")); | |
| 60 | const press = (key: string) => screen.key!({ key } as never); | |
| 61 | return { pushed, offered, press }; | |
| 62 | } | |
| 63 | ||
| 64 | beforeAll(async () => { | |
| 65 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-outsiders-")); | |
| 66 | app = createApp({ dataDir, port: 0 }); | |
| 67 | const make = async (username: string) => | |
| 68 | ok( | |
| 69 | await createUser(app.db, { | |
| 70 | origin: "seed", | |
| 71 | trusted: true, | |
| 72 | username, | |
| 73 | email: `${username}@forge.local`, | |
| 74 | password: "a-good-passphrase", | |
| 75 | adminIfFirst: true, | |
| 76 | }), | |
| 77 | ); | |
| 78 | founder = await make("founder"); | |
| 79 | dev = await make("dev"); | |
| 80 | const org = ok(createOrg(app.db, "acme", founder.id)); | |
| 81 | const writers = ok(createTeam(app.db, org.id, "Writers", "write")); | |
| 82 | addTeamMember(app.db, writers, dev.id); | |
| 83 | repo = ok(await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "svc", visibility: "public" })); | |
| 84 | const agent = ok(await createAgent(app, founder, { username: "bot" })); | |
| 85 | expect(addToCrew(app, founder, repo, "bot").ok).toBe(true); | |
| 86 | const owners = listTeams(app.db, org.id).find((t) => t.name === OWNERS_TEAM)!; | |
| 87 | addTeamMember(app.db, owners, agent.user.id); | |
| 88 | bot = agent.user; | |
| 89 | }, 30_000); | |
| 90 | ||
| 91 | afterAll(async () => { | |
| 92 | app?.close(); | |
| 93 | await rm(dataDir, { recursive: true, force: true }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("the line reads off; d and confirm turn it on, and the row says so", async () => { | |
| 97 | const c = card(founder); | |
| 98 | expect(line()).toBe(DIRECTION_NOTE); | |
| 99 | expect(c.offered()).toBe(true); | |
| 100 | c.press("d"); | |
| 101 | await until("the plan", () => c.pushed.length === 1); | |
| 102 | // The plan is shown before anything changes. | |
| 103 | expect(getRepoById(app.db, repo.id)!.crewOutsiders).toBe(false); | |
| 104 | c.pushed[0]!.key!({ key: "enter" } as never); | |
| 105 | await until("outsiders on", () => getRepoById(app.db, repo.id)!.crewOutsiders); | |
| 106 | expect(line()).toBe(OUTSIDERS_NOTE); | |
| 107 | }); | |
| 108 | ||
| 109 | test("esc leaves it as it was", async () => { | |
| 110 | const c = card(founder); | |
| 111 | c.press("d"); | |
| 112 | await until("the plan", () => c.pushed.length === 1); | |
| 113 | c.pushed[0]!.key!({ key: "esc" } as never); | |
| 114 | expect(getRepoById(app.db, repo.id)!.crewOutsiders).toBe(true); | |
| 115 | }); | |
| 116 | ||
| 117 | test("a writer and an agent are not offered d and it does nothing", async () => { | |
| 118 | for (const who of [dev, bot]) { | |
| 119 | const c = card(who); | |
| 120 | expect(c.offered()).toBe(false); | |
| 121 | // Refused before anything async starts, so there is nothing to wait for. | |
| 122 | c.press("d"); | |
| 123 | expect(c.pushed).toHaveLength(0); | |
| 124 | expect(getRepoById(app.db, repo.id)!.crewOutsiders).toBe(true); | |
| 125 | } | |
| 126 | }); | |
| 127 | ||
| 128 | test("on a fork ahead of its parent d still reaches outsiders and o still opens the upstream offer", async () => { | |
| 129 | const parent = ok( | |
| 130 | await createRepo(app.db, app.reposRoot, { ownerId: founder.id, name: "up", visibility: "public" }), | |
| 131 | ); | |
| 132 | ok(await commitToBranch(app, founder, parent, { message: "one", files: [{ path: "a.txt", content: "a\n" }] })); | |
| 133 | ok(setRole(app, founder, "dev", "member")); | |
| 134 | const fork = ok(await forkRepo(app, dev, parent)); | |
| 135 | ok(await commitToBranch(app, dev, fork, { message: "two", files: [{ path: "b.txt", content: "b\n" }] })); | |
| 136 | const pushed: Screen[] = []; | |
| 137 | const ctx = { | |
| 138 | invalidate() {}, | |
| 139 | toast() {}, | |
| 140 | push: (s: Screen) => pushed.push(s), | |
| 141 | pop() {}, | |
| 142 | copy() {}, | |
| 143 | } as unknown as Ctx; | |
outdated
A fixed 500 ms sleep waits for git work ( |
||
| 144 | const f = { app, db: app.db, user: dev } as unknown as Session; | |
| 145 | const src = localSource(app, getRepoById(app.db, fork.id)!, dev); | |
| 146 | const screen = aboutScreen(f, ctx, {} as Screen, src, undefined, "main"); | |
| 147 | const has = (k: string) => screen.keys.some((b) => b.keys.includes(k)); | |
| 148 | await until("the upstream offer", () => has("o")); | |
| 149 | expect(has("o")).toBe(true); | |
| 150 | expect(has("d")).toBe(true); | |
| 151 | screen.key!({ key: "o" } as never); | |
| 152 | expect(pushed).toHaveLength(1); | |
| 153 | expect(pushed[0]!.title).not.toBe("Outsiders"); | |
| 154 | screen.key!({ key: "d" } as never); | |
| 155 | await until("the plan", () => pushed.length === 2); | |
| 156 | expect(pushed[1]!.title).toBe("Outsiders"); | |
| 157 | expect(getRepoById(app.db, fork.id)!.crewOutsiders).toBe(false); | |
| 158 | }); |
new above
offline
Losing
owhenever a fork has an upstream offer leaves the terminal short of the page. Pick a key that never clashes (item 3) and test the fork case.