Public: anyone can read this; the crew can change it.
The terminal's crew hand-overs screen opens a parked-twice issue with Enter, and `r` reads the list again #7420
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7415-the-terminal-s-crew-hand-overs-screen-op → main 2 commits fork 2 threads · 2 unresolved
3 files
changed +165
−8
README.md +3 −3
| @@ -36,9 +36,9 @@ terminal's palette as "Crew scorecard per agent"; both are a forge admin's only. | ||
| 36 | 36 | Settings → Crew also shows, each only when it has something, a "Handed to the |
| 37 | 37 | planner" card (issues the crew parked twice: repo, issue link, when last |
| 38 | 38 | parked, why) and a "Restore drill" card (the last drill's pass or fail and |
| 39 | when). The terminal's palette has the same as "Crew hand-overs", a read-only | |
| 40 | list of the same rows (repo#number, title, reasons; it does not open the | |
| 41 | issue — use the link on the page). Both are a forge admin's only. | |
| 39 | when). The terminal's palette has the same as "Crew hand-overs", a list of the | |
| 40 | same rows (repo#number, title, reasons): Enter opens a parked row's issue and | |
| 41 | `r` reads the list again. Both are a forge admin's only. | |
| 42 | 42 | |
| 43 | 43 | `/boards` carries a Held files card for anyone who administers a repository (a forge admin sees every repository's): the holds of the repositories you administer, a form to hold another path, Release on each row, and "Showing 200 of N" when the list is cut. The user menu links Boards to such a person. In the terminal, a bare line on the Held files prompt lists the same holds. |
| 44 | 44 |
src/tui/screens/crewhandover.ts +34 −5
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * (`crewHandover`) — each section only when it has something to say. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { getRepo } from "../../core/repos.ts"; | |
| 7 | 8 | import { crewHandover } from "../../crew/handed.ts"; |
| 8 | 9 | import type { Ctx, Screen } from "../app/app.ts"; |
| 9 | 10 | import { binding } from "../app/keys.ts"; |
| @@ -11,14 +12,21 @@ import { style } from "../term/style.ts"; | ||
| 11 | 12 | import { truncate } from "../term/width.ts"; |
| 12 | 13 | import type { Session } from "../session.ts"; |
| 13 | 14 | import { fill, len, split } from "../ui/layout.ts"; |
| 15 | import { issueScreen } from "./issue.ts"; | |
| 16 | import { local } from "./where.ts"; | |
| 14 | 17 | import { ListState, drawList, type ListRow } from "./common.ts"; |
| 15 | 18 | |
| 16 | 19 | export function crewHandoverScreen(f: Session, ctx: Ctx): Screen { |
| 17 | 20 | const state = new ListState(); |
| 18 | 21 | const allowed = f.user?.isAdmin === true; |
| 19 | // Read once when the screen opens, not per frame. | |
| 20 | const { parked, drill } = allowed ? crewHandover(f.db) : { parked: [], drill: undefined }; | |
| 21 | const rows: ListRow[] = [ | |
| 22 | let parked: ReturnType<typeof crewHandover>["parked"] = []; | |
| 23 | let drill: ReturnType<typeof crewHandover>["drill"]; | |
| 24 | const read = (): void => { | |
| 25 | ({ parked, drill } = allowed ? crewHandover(f.db) : { parked: [], drill: undefined }); | |
| 26 | }; | |
| 27 | // Read when the screen opens and on `r`, not per frame. | |
| 28 | read(); | |
| 29 | const build = (): ListRow[] => [ | |
| 22 | 30 | ...parked.map((r) => ({ |
| 23 | 31 | title: `Handed to the planner: ${r.repo}#${r.number} ${r.title}`, |
| 24 | 32 | secondary: `parked ${r.at.slice(0, 16).replace("T", " ")} UTC`, |
| @@ -34,9 +42,17 @@ export function crewHandoverScreen(f: Session, ctx: Ctx): Screen { | ||
| 34 | 42 | }, |
| 35 | 43 | ]), |
| 36 | 44 | ]; |
| 45 | let rows = build(); | |
| 46 | const open = (): void => { | |
| 47 | const p = parked[state.index]; | |
| 48 | if (!allowed || p === undefined) return; | |
| 49 | const [owner, name] = p.repo.split("/"); | |
| 50 | const repo = owner === undefined || name === undefined ? undefined : getRepo(f.db, owner, name); | |
| 51 | if (repo !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), p.number)); | |
| 52 | }; | |
| 37 | 53 | return { |
| 38 | 54 | title: "Crew hand-overs", |
| 39 | keys: [binding(["j", "k"], "move cursor")], | |
| 55 | keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "open"), binding(["r"], "refresh")], | |
| 40 | 56 | render(area, s) { |
| 41 | 57 | const c = ctx.theme.c; |
| 42 | 58 | const [head, list] = split(area, "v", [len(1), fill(1)]); |
| @@ -48,6 +64,19 @@ export function crewHandoverScreen(f: Session, ctx: Ctx): Screen { | ||
| 48 | 64 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 49 | 65 | drawList(s, list!, c, state, rows); |
| 50 | 66 | }, |
| 51 | key: (e) => state.key(e, rows.length), | |
| 67 | key(e) { | |
| 68 | if (e.ctrl || e.alt) return state.key(e, rows.length); | |
| 69 | if (e.key === "enter") { | |
| 70 | open(); | |
| 71 | return true; | |
| 72 | } | |
| 73 | if (e.key === "r") { | |
| 74 | read(); | |
| 75 | rows = build(); | |
| 76 | state.clamp(rows.length); | |
| 77 | return true; | |
| 78 | } | |
| 79 | return state.key(e, rows.length); | |
| 80 | }, | |
| 52 | 81 | }; |
| 53 | 82 | } |
test/tui-crewhandover-open.test.ts added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | /** The crew hand-overs screen opens a parked-twice issue with Enter and reads again on `r`. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 4 | import type { Ctx } from "../src/tui/app/app.ts"; | |
| 5 | import { crewHandoverScreen } from "../src/tui/screens/crewhandover.ts"; | |
| 6 | import type { Session } from "../src/tui/session.ts"; | |
| 7 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 8 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 9 | import { engine, tick, type Engine } from "./helpers/parks-engine.ts"; | |
| 10 | import { quietLogs } from "./helpers/globals.ts"; | |
| 11 | import { settleApp } from "./helpers/tui.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | let e: Engine; | |
| 16 | ||
| 17 | function boot(username: string) { | |
| 18 | const user = getUserByName(e.app.db, username)!; | |
| 19 | const { app } = createForgeApp( | |
| 20 | e.app, | |
| 21 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 22 | { terminal: headlessTerminal(140, 32), exitHooks: false }, | |
| 23 | ); | |
| 24 | app.start(); | |
| 25 | const key = async (k: string, ctrl = false) => { | |
| 26 | app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 27 | await settleApp(app); | |
| 28 | }; | |
| 29 | return { app, key, text: () => app.screen.toText() }; | |
| 30 | } | |
| 31 | ||
| 32 | async function crewScreen(username: string) { | |
| 33 | const t = boot(username); | |
| 34 | await t.key("k", true); | |
| 35 | for (const ch of "Crew hand-overs") await t.key(ch); | |
| 36 | await t.key("enter"); | |
| 37 | return t; | |
| 38 | } | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | e = await engine("forge-handover-open-"); | |
| 42 | const made = await createUser(e.app.db, { | |
| 43 | origin: "seed", | |
| 44 | trusted: true, | |
| 45 | username: "bob", | |
| 46 | email: "[email protected]", | |
| 47 | password: "a-good-passphrase", | |
| 48 | adminIfFirst: false, | |
| 49 | }); | |
| 50 | if (!made.ok) throw new Error(made.error); | |
| 51 | }); | |
| 52 | afterAll(() => e.close()); | |
| 53 | ||
| 54 | test("Enter opens the parked issue, r reads again, a non-admin gets nothing", async () => { | |
| 55 | const t = await crewScreen("alice"); | |
| 56 | expect(t.text()).toContain("Nothing handed to the planner"); | |
| 57 | await t.key("enter"); | |
| 58 | expect(t.app.screen.toText()).toContain("Crew hand-overs"); | |
| 59 | ||
| 60 | const work = e.issue("Scene graph"); | |
| 61 | expect(e.ended(work, "no changes pushed")).toBe(true); | |
| 62 | await tick(); | |
| 63 | expect(e.ended(work, "nothing to propose")).toBe(true); | |
| 64 | await tick(); | |
| 65 | expect(t.text()).not.toContain("Handed to the planner: "); | |
| 66 | await t.key("r"); | |
| 67 | expect(t.text()).toContain("Handed to the planner: alice/engine#" + work.number); | |
| 68 | expect(t.text()).toMatch(/enter\s+open/); | |
| 69 | expect(t.text()).toMatch(/r\s+refresh/); | |
| 70 | ||
| 71 | await t.key("enter"); | |
| 72 | expect(t.text()).toContain("Scene graph"); | |
| 73 | expect(t.text()).not.toContain("Handed to the planner: "); | |
| 74 | t.app.stop(); | |
| 75 | ||
| 76 | // Bob has no palette command for it; built directly, the screen has no rows to open. | |
| 77 | const b = await crewScreen("bob"); | |
| 78 | expect(b.text()).not.toContain("Crew hand-overs"); | |
| 79 | b.app.stop(); | |
| 80 | const pushed: unknown[] = []; | |
| 81 | const screen = crewHandoverScreen( | |
| 82 | { db: e.app.db, app: e.app, user: getUserByName(e.app.db, "bob")! } as unknown as Session, | |
| 83 | { push: (s: unknown) => pushed.push(s) } as unknown as Ctx, | |
| 84 | ); | |
| 85 | screen.key({ type: "key", key: "enter", ctrl: false, alt: false, shift: false }); | |
| 86 | expect(pushed).toEqual([]); | |
| 87 | }); | |
Add cases for Enter on the drill row (pushes nothing) and for r clamping the cursor after the list shrinks. |
||
| 88 | ||
| 89 | test("Enter on the drill row opens nothing, and r clamps the cursor when the list shrinks", async () => { | |
| 90 | const { setIssueState } = await import("../src/actions/issues.ts"); | |
| 91 | const { recordDrill } = await import("../src/snapshot/drills.ts"); | |
| 92 | const keep = e.issue("Keeper"); | |
| 93 | const gone = e.issue("Goner"); | |
| 94 | for (const w of [keep, gone]) { | |
| 95 | expect(e.ended(w, "a")).toBe(true); | |
| 96 | await tick(); | |
| 97 | expect(e.ended(w, "b")).toBe(true); | |
| 98 | await tick(); | |
| 99 | } | |
| 100 | recordDrill(e.app.db, { | |
| 101 | at: new Date().toISOString(), | |
| 102 | snapshotId: "s1", | |
| 103 | ms: 10, | |
| 104 | counts: { users: 1, repositories: 1, issues: 1, pulls: 0, apps: 0 }, | |
| 105 | health: "ok", | |
| 106 | verdict: "green", | |
| 107 | failedStep: null, | |
| 108 | phases: [], | |
| 109 | budgetMs: 0, | |
| 110 | reason: "x", | |
| 111 | }); | |
| 112 | const t = await crewScreen("alice"); | |
| 113 | const rows = (t.text().match(/Handed to the planner: /g) ?? []).length; | |
| 114 | expect(rows).toBeGreaterThanOrEqual(2); | |
| 115 | // Cursor to the last row, the drill: Enter stays on this screen. | |
| 116 | await t.key("G"); | |
| 117 | await t.key("enter"); | |
| 118 | expect(t.text()).toContain("Restore drill: passed"); | |
| 119 | expect(t.text()).toContain("Handed to the planner: "); | |
| 120 | // Close the issue the cursor row stands past, then r: the cursor lands on a row that exists. | |
| 121 | setIssueState(e.app, e.alice, e.fresh(), gone, "closed"); | |
| 122 | await t.key("r"); | |
| 123 | expect((t.text().match(/Handed to the planner: /g) ?? []).length).toBe(rows - 1); | |
| 124 | await t.key("k"); | |
| 125 | await t.key("enter"); | |
| 126 | expect(t.text()).not.toContain("Restore drill: passed"); | |
| 127 | t.app.stop(); | |
| 128 | }); |
new above
offline
Unrequested behaviour change with no test that fails without it. Revert it, or test it with an admin agent session.