Public: anyone can read this; the crew can change it.
The terminal's issues list opens the repository's plan with `m`: working, ready now, waiting on what, and parked twice, as MCP `map` reads it #7481
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7472-the-terminal-s-issues-list-opens-the-rep → main 2 commits fork
4 files
changed +328
−0
README.md +2
| @@ -54,6 +54,8 @@ A hold an agent placed does not stand forever: the tick releases one still stand | ||
| 54 | 54 | |
| 55 | 55 | In the terminal, `T` on a shipped pull's screen takes it back, as the pull page's button does: a one-line confirm names the pull, Yes opens the pull that undoes it (`revertPull`, `src/actions/revert.ts`) and shows it, No changes nothing. The key appears only to a person who can write a local repository (never an agent, a reader, a guest or a mirror), and refusals read as the page's do (`takeBackWords`). `T` also opens an app preview's terminal on an open pull; a shipped pull has no live preview, so the two never meet. |
| 56 | 56 | |
| 57 | In the terminal, `m` on a local repository's issue list opens the plan screen (`src/tui/screens/plan.ts`): Working, Ready now, Waiting and Parked twice, each headed with its count. It reads what MCP `map` reads (`repoGraph` under the reader's sealed scope, `pillarView`, `mapHeldFor`, the runs in flight), so a sealed issue the reader may not see is left out. Enter opens the issue, `r` reads again, and the headings are skipped over. It is not offered on the pulls list or on a live browse of another forge. | |
| 58 | ||
| 57 | 59 | A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three rounds) has "Give back to the crew": one press assigns the pull, and the open issue it closes if a person holds it, to the agent that opened it, writes "Given back to the crew by @who", and the card leaves; the next tick starts its run. It is for a writer of the repository, and a pull a person opened, or by an agent gone from the crew, gets no button. In the terminal, `b` on a handed-over row of the On you pane does the same (`src/actions/give_back.ts`). The repository's planner can do it by comment: one that starts "Planner, one more round" on a handed-over crew pull emits the assignment to its builder, which starts the run (the pull is already the builder's, so nothing changes hands). It is the planner's alone; a reader's, the reviewer's or any other agent's does nothing. It is an agent's word, not a director's, so it does not end the hand-over: the card stays on `/asks` and the pull still counts as handed over during the round. |
| 58 | 60 | |
| 59 | 61 | Home opens with "Since you were here · 2 days": the pulls that landed in public repositories since you last loaded `/` (1 hour to 7 days back, a day on a first visit); the terminal's home shows the same lines, and MCP `feed` answers them as `digest`. See `docs/features/since-you-were-here.md`. |
src/tui/screens/issues.ts +6
| @@ -43,6 +43,7 @@ import { | ||
| 43 | 43 | } from "./common.ts"; |
| 44 | 44 | import { issueScreen } from "./issue.ts"; |
| 45 | 45 | import { newPullScreen } from "./newpull.ts"; |
| 46 | import { planScreen } from "./plan.ts"; | |
| 46 | 47 | import { pullScreen } from "./pull.ts"; |
| 47 | 48 | import { verdictsForPulls, type Verdict } from "./verdicts.ts"; |
| 48 | 49 | import { |
| @@ -262,6 +263,7 @@ export function issuesScreen( | ||
| 262 | 263 | ...(stops() > 0 ? [binding(["P"], "pillar")] : []), |
| 263 | 264 | binding(["a"], mine ? "all authors" : "only mine"), |
| 264 | 265 | ...(mayWrite() ? [binding(["c"], `new ${noun}`)] : []), |
| 266 | ...(kind === "issue" && repo !== undefined ? [binding(["m"], "plan")] : []), | |
| 265 | 267 | binding(["/"], "search"), |
| 266 | 268 | binding(["r"], "refresh"), |
| 267 | 269 | ]; |
| @@ -410,6 +412,10 @@ export function issuesScreen( | ||
| 410 | 412 | ); |
| 411 | 413 | return true; |
| 412 | 414 | } |
| 415 | if (e.key === "m" && kind === "issue" && repo !== undefined) { | |
| 416 | ctx.push(planScreen(f, ctx, repo)); | |
| 417 | return true; | |
| 418 | } | |
| 413 | 419 | if (e.key === "enter") { |
| 414 | 420 | openAt(state.index); |
| 415 | 421 | return true; |
src/tui/screens/plan.ts added +143
| @@ -0,0 +1,143 @@ | ||
| 1 | /** | |
| 2 | * The repository's plan in one screen: who is working, what is ready now, | |
| 3 | * what waits on what, and what the crew parked twice. The reads are MCP | |
| 4 | * `map`'s own (`repoGraph` under the reader's `sealedScope`, `pillarView`, | |
| 5 | * `mapHeldFor`, `listActiveRuns`), so a sealed issue the reader may not see | |
| 6 | * is left out here as it is there. | |
| 7 | */ | |
| 8 | ||
| 9 | import { repoGraph, readyNow } from "../../core/dependencies.ts"; | |
| 10 | import { pillarView } from "../../core/pillars.ts"; | |
| 11 | import { listActiveRuns } from "../../core/runs.ts"; | |
| 12 | import type { Repo } from "../../core/repos.ts"; | |
| 13 | import { sealedScope } from "../../core/sealed.ts"; | |
| 14 | import { mapHeldFor } from "../../crew/held.ts"; | |
| 15 | import { marksFor } from "../../web/pages/map.ts"; | |
| 16 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 17 | import { binding } from "../app/keys.ts"; | |
| 18 | import type { Session } from "../session.ts"; | |
| 19 | import { ListState, drawList, hitList, live, runAsync, type ListRow } from "./common.ts"; | |
| 20 | import { issueScreen } from "./issue.ts"; | |
| 21 | import { local } from "./where.ts"; | |
| 22 | ||
| 23 | /** A line of the plan: a group's heading (no `number`) or one issue. */ | |
| 24 | export interface PlanLine { | |
| 25 | readonly text: string; | |
| 26 | readonly number?: number; | |
| 27 | } | |
| 28 | ||
| 29 | export function readPlan(f: Session, repo: Repo): PlanLine[] { | |
| 30 | const scope = sealedScope(f.db, repo, f.user); | |
| 31 | const graph = repoGraph(f.db, repo.id, { ...scope }); | |
| 32 | const pillars = pillarView(f.db, repo); | |
| 33 | const marks = marksFor(f.db, repo.id, graph, listActiveRuns(f.db, [repo.id])); | |
| 34 | const held = mapHeldFor(f.app, repo, graph, scope); | |
| 35 | const byId = new Map(graph.issues.map((i) => [i.id, i])); | |
| 36 | const open = (id: string): boolean => byId.get(id)?.state === "open"; | |
| 37 | ||
| 38 | const working = graph.issues.filter((i) => i.state === "open" && marks.crew.has(i.id)); | |
| 39 | const twice = new Set(marks.twice.map((t) => t.number)); | |
| 40 | // Parked twice is the planner's: it is listed once, under its own heading. | |
| 41 | const ready = readyNow(graph).filter( | |
| 42 | (r) => !marks.crew.has(r.issue.id) && !twice.has(r.issue.number), | |
| 43 | ); | |
| 44 | const readyIds = new Set(ready.map((r) => r.issue.id)); | |
| 45 | const waits = new Map<string, number[]>(); | |
| 46 | for (const e of graph.edges) { | |
| 47 | const blocker = byId.get(e.blockedById); | |
| 48 | if (blocker === undefined || !open(e.blockedById)) continue; | |
| 49 | waits.set(e.issueId, [...(waits.get(e.issueId) ?? []), blocker.number]); | |
| 50 | } | |
| 51 | const waiting = graph.issues.filter( | |
| 52 | (i) => open(i.id) && !marks.crew.has(i.id) && !readyIds.has(i.id) && waits.has(i.id), | |
| 53 | ); | |
| 54 | const hold = (n: number): string => { | |
| 55 | const h = held.holds.get(n); | |
| 56 | return h === undefined ? "" : ` (${h.detail})`; | |
| 57 | }; | |
| 58 | ||
| 59 | const lines: PlanLine[] = [{ text: `Working ${working.length}` }]; | |
| 60 | for (const i of working) | |
| 61 | lines.push({ | |
| 62 | text: `#${i.number} ${i.title} — ${i.assigneeName ?? "crew"}`, | |
| 63 | number: i.number, | |
| 64 | }); | |
| 65 | lines.push({ text: `Ready now ${ready.length}` }); | |
| 66 | for (const { issue: i } of ready) | |
| 67 | lines.push({ | |
| 68 | text: `#${i.number} ${i.title} — ${pillars?.of.get(i.id) ?? "no pillar"}${hold(i.number)}`, | |
| 69 | number: i.number, | |
| 70 | }); | |
| 71 | lines.push({ text: `Waiting ${waiting.length}` }); | |
| 72 | for (const i of waiting) | |
| 73 | lines.push({ | |
| 74 | text: `#${i.number} ${i.title} — waits on ${waits | |
| 75 | .get(i.id)! | |
| 76 | .slice() | |
| 77 | .sort((a, b) => a - b) | |
| 78 | .map((n) => `#${n}`) | |
| 79 | .join(", ")}${hold(i.number)}`, | |
| 80 | number: i.number, | |
| 81 | }); | |
| 82 | lines.push({ text: `Parked twice ${marks.twice.length}` }); | |
| 83 | for (const t of marks.twice) | |
| 84 | lines.push({ text: `#${t.number} ${t.title} — ${t.reasons.join("; ")}`, number: t.number }); | |
| 85 | return lines; | |
| 86 | } | |
| 87 | ||
| 88 | export function planScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 89 | const state = new ListState(); | |
| 90 | let lines: PlanLine[] = []; | |
| 91 | /** Headings are not rows to stand on: the cursor moves on to the next issue, `dir` first. */ | |
| 92 | const settle = (dir: 1 | -1): void => { | |
| 93 | const at = (i: number): boolean => lines[i]?.number !== undefined; | |
| 94 | for (const d of [dir, -dir]) { | |
| 95 | for (let i = state.index; i >= 0 && i < lines.length; i += d) | |
| 96 | if (at(i)) { | |
| 97 | state.index = i; | |
| 98 | return; | |
| 99 | } | |
| 100 | } | |
| 101 | }; | |
| 102 | const reload = (): void => { | |
| 103 | lines = readPlan(f, repo); | |
| 104 | state.clamp(lines.length); | |
| 105 | settle(1); | |
| 106 | ctx.invalidate(); | |
| 107 | }; | |
| 108 | const openAt = (index: number): void => { | |
| 109 | const number = lines[index]?.number; | |
| 110 | if (number !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), number)); | |
| 111 | }; | |
| 112 | return { | |
| 113 | title: "Plan", | |
| 114 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["r"], "refresh")], | |
| 115 | load(signal) { | |
| 116 | live(f, signal, { repoId: repo.id }, () => runAsync(ctx, async () => reload())); | |
| 117 | reload(); | |
| 118 | return Promise.resolve(); | |
| 119 | }, | |
| 120 | render(area, s) { | |
| 121 | const c = ctx.theme.c; | |
| 122 | const rows: ListRow[] = lines.map((l) => ({ title: l.text })); | |
| 123 | drawList(s, area, c, state, rows); | |
| 124 | hitList(ctx, area, state, rows.length, { open: openAt }); | |
| 125 | }, | |
| 126 | key(e) { | |
| 127 | const before = state.index; | |
| 128 | if (state.key(e, lines.length)) { | |
| 129 | settle(state.index >= before ? 1 : -1); | |
| 130 | return true; | |
| 131 | } | |
| 132 | if (e.key === "enter") { | |
| 133 | openAt(state.index); | |
| 134 | return true; | |
| 135 | } | |
| 136 | if (e.key === "r") { | |
| 137 | reload(); | |
| 138 | return true; | |
| 139 | } | |
| 140 | return false; | |
| 141 | }, | |
| 142 | }; | |
| 143 | } |
test/tui-plan.test.ts added +177
| @@ -0,0 +1,177 @@ | ||
| 1 | /** `m` on the issues list opens the plan: working, ready now, waiting, parked twice; Enter opens the issue. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { openIssue } from "../src/actions/issues.ts"; | |
| 8 | import { createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 10 | import { addDependency } from "../src/core/dependencies.ts"; | |
| 11 | import { addComment, createIssue, type Issue } from "../src/core/issues.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import { HOT_PULLS, rememberPullFiles } from "../src/crew/hot_files.ts"; | |
| 14 | import { claimRun, createRun } from "../src/core/runs.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { issuesScreen } from "../src/tui/screens/issues.ts"; | |
| 18 | import { local } from "../src/tui/screens/where.ts"; | |
| 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 20 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | import { settleApp } from "./helpers/tui.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | let dir: string; | |
| 27 | let forge: Forge; | |
| 28 | let alice: User; | |
| 29 | let repo: Repo; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | dir = await mkdtemp(join(tmpdir(), "forge-tui-plan-")); | |
| 33 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 34 | const a = await seedAdmin(forge, { | |
| 35 | username: "alice", | |
| 36 | email: "[email protected]", | |
| 37 | password: "a-long-passphrase", | |
| 38 | }); | |
| 39 | if (!a.ok) throw new Error("signup failed"); | |
| 40 | alice = a.value; | |
| 41 | const r = await createRepo(forge, alice, { name: "plan", visibility: "public" }); | |
| 42 | if (!r.ok) throw new Error("repo failed"); | |
| 43 | repo = r.value; | |
| 44 | }); | |
| 45 | afterAll(async () => { | |
| 46 | forge.close?.(); | |
| 47 | await rm(dir, { recursive: true, force: true }); | |
| 48 | }); | |
| 49 | ||
| 50 | const make = async (title: string): Promise<Issue> => { | |
| 51 | const r = await openIssue(forge, alice, repo, { title, body: "x" }); | |
| 52 | if (!r.ok) throw new Error(`issue failed: ${r.error}`); | |
| 53 | return r.value; | |
| 54 | }; | |
| 55 | ||
| 56 | const open = async (user: User, where: Repo = repo, kind: "issue" | "pull" = "issue") => { | |
| 57 | const { app, session } = createForgeApp( | |
| 58 | forge, | |
| 59 | { sshUsername: user.username, remoteAddress: "127.0.0.1", user }, | |
| 60 | { terminal: headlessTerminal(120, 30), exitHooks: false }, | |
| 61 | ); | |
| 62 | app.start(); | |
| 63 | await settleApp(app); | |
| 64 | app.push(issuesScreen(session, app.ctx, local(session, where), kind)); | |
| 65 | await settleApp(app); | |
| 66 | const press = async (key: string): Promise<void> => { | |
| 67 | app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 68 | await settleApp(app); | |
| 69 | }; | |
| 70 | return { app, press, text: () => app.screen.toText() }; | |
| 71 | }; | |
| 72 | ||
| 73 | test("m shows the plan in four groups, Enter opens an issue, a sealed issue stays out of a reader's", async () => { | |
| 74 | const ready = await make("Ready thing"); | |
| 75 | const waiting = await make("Waiting thing"); | |
| 76 | const running = await make("Running thing"); | |
| 77 | addDependency(forge.db, waiting, ready, alice.id); | |
| 78 | expect( | |
| 79 | claimRun( | |
| 80 | forge.db, | |
| 81 | createRun(forge.db, { repoId: repo.id, kind: "agent", issueId: running.id, actorId: alice.id, reason: "assigned" }).id, | |
| 82 | ), | |
| 83 | ).toBeDefined(); | |
| 84 | const sealed = createIssue(forge.db, { repoId: repo.id, authorId: alice.id, title: "Secret report", sealed: true }); | |
| 85 | if (!sealed.ok) throw new Error(String(sealed.error)); | |
| 86 | ||
| 87 | const h = await open(alice); | |
| 88 | expect(h.text()).toContain("m plan"); | |
| 89 | await h.press("m"); | |
| 90 | const t = h.text(); | |
| 91 | expect(t).toContain("Working 1"); | |
| 92 | expect(t).toContain("Ready now"); | |
| 93 | expect(t).toContain("Waiting 1"); | |
| 94 | expect(t).toContain("Parked twice 0"); | |
| 95 | expect(t).toContain(`#${running.number} Running thing`); | |
| 96 | expect(t).toContain(`#${waiting.number} Waiting thing — waits on #${ready.number}`); | |
| 97 | expect(t).toContain(`#${ready.number} Ready thing`); | |
| 98 | ||
| 99 | // The cursor starts on the first issue row, not on the "Working" heading. | |
| 100 | await h.press("enter"); | |
| 101 | expect(h.app.screen.toText()).toContain("Running thing"); | |
| 102 | expect(h.app.screen.toText()).not.toContain("Ready now"); | |
| 103 | h.app.stop(); | |
| 104 | ||
| 105 | const carol = await createUser(forge.db, { | |
| 106 | origin: "seed", | |
| 107 | trusted: true, | |
| 108 | username: "carol", | |
| 109 | email: "[email protected]", | |
| 110 | password: "a-good-passphrase", | |
| 111 | }); | |
| 112 | if (!carol.ok) throw new Error(String(carol.error)); | |
| 113 | const mine = await open(alice); | |
| 114 | await mine.press("m"); | |
| 115 | expect(mine.text()).toContain("Secret report"); | |
| 116 | mine.app.stop(); | |
| 117 | const theirs = await open(carol.value); | |
| 118 | await theirs.press("m"); | |
| 119 | expect(theirs.text()).toContain("Ready thing"); | |
| 120 | expect(theirs.text()).not.toContain("Secret report"); | |
| 121 | theirs.app.stop(); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a parked-twice issue has its row with every reason; a held ready issue says what holds it; an empty plan shows four empty groups", async () => { | |
| 125 | const r = await createRepo(forge, alice, { name: "held", visibility: "public" }); | |
| 126 | if (!r.ok) throw new Error("repo failed"); | |
| 127 | const where = r.value; | |
| 128 | const add = (title: string, body = ""): Issue => { | |
| 129 | const made = createIssue(forge.db, { repoId: where.id, authorId: alice.id, title, body }); | |
| 130 | if (!made.ok) throw new Error(String(made.error)); | |
| 131 | return made.value; | |
| 132 | }; | |
| 133 | ||
| 134 | const empty = await open(alice, where); | |
| 135 | await empty.press("m"); | |
| 136 | for (const heading of ["Working 0", "Ready now 0", "Waiting 0", "Parked twice 0"]) | |
| 137 | expect(empty.text()).toContain(heading); | |
| 138 | expect(empty.text()).not.toContain("Reading…"); | |
| 139 | empty.app.stop(); | |
| 140 | ||
| 141 | const hot = add("Add a flag to src/hot.ts", ""); | |
| 142 | const stuck = add("Stuck thing"); | |
| 143 | for (const why of ["the tests would not build", "the diff kept conflicting"]) | |
| 144 | addComment(forge.db, { issueId: stuck.id, authorId: alice.id, body: `Parked by the crew: ${why}.` }); | |
| 145 | rememberPullFiles( | |
| 146 | where.id, | |
| 147 | Array.from({ length: HOT_PULLS }, (_, i) => ({ number: i + 1, files: ["src/hot.ts"] })), | |
| 148 | ); | |
| 149 | try { | |
| 150 | const h = await open(alice, where); | |
| 151 | await h.press("m"); | |
| 152 | const lines = h.text().split("\n"); | |
| 153 | const row = (needle: string): string => lines.find((l) => l.includes(needle)) ?? ""; | |
| 154 | expect(h.text()).toContain("Parked twice 1"); | |
| 155 | expect(row(`#${stuck.number} Stuck thing`)).toContain("the tests would not build; the diff kept conflicting"); | |
| 156 | expect(h.text()).toContain("Ready now 1"); | |
| 157 | expect(row(`#${hot.number} Add a flag`)).toContain("src/hot.ts is in"); | |
| 158 | // Enter on the first row lands on an issue, never on a heading. | |
| 159 | await h.press("enter"); | |
| 160 | expect(h.text()).toContain("Add a flag to src/hot.ts"); | |
| 161 | expect(h.text()).not.toContain("Parked twice"); | |
| 162 | h.app.stop(); | |
| 163 | } finally { | |
| 164 | rememberPullFiles(where.id, []); | |
| 165 | } | |
| 166 | }); | |
| 167 | ||
| 168 | test("m is offered on the issues list and not on the pulls list", async () => { | |
| 169 | const issues = await open(alice, repo, "issue"); | |
| 170 | expect(issues.text()).toContain("m plan"); | |
| 171 | issues.app.stop(); | |
| 172 | const pulls = await open(alice, repo, "pull"); | |
| 173 | expect(pulls.text()).not.toContain("m plan"); | |
| 174 | await pulls.press("m"); | |
| 175 | expect(pulls.text()).not.toContain("Parked twice"); | |
| 176 | pulls.app.stop(); | |
| 177 | }); |
new above
offline