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

The terminal lists your last bulk actions, and `u` undoes the selected one while it is inside its day #7945

Merged opened this

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

Merged

torque/forge:torque/7937-the-terminal-lists-your-last-bulk-action → main 5 commits fork

  • Merged. torque/7937-the-terminal-lists-your-last-bulk-action was merged into main as 7ab32e2.

Review

Sign in to review this change.
7 files changed +415 −3
docs/CREW.md +2
@@ -345,6 +345,8 @@ one the provider rests hands its runs to the other until the reset.
345345
346346 **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting, revoking and using a link are events (`invite.minted`, `invite.revoked`, `invite.used`; the last names the invite and the account it let in, never the token). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one; its top line says who may sign up, and `s`, offered only to that admin, picks open, invite or closed, shows from → to and changes it after a confirm, as MCP `signup` does). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link, and the `invites` tool (`{action: "list"|"revoke", id?}`, the same person admin and admin token, for both actions) lists them without links or revokes one. While signup is `invite`, the link opens the door once: `/signup?invite=…` (the form and the passkey door) or `invite` in the `POST /api/v1/users` body. In the terminal, with signup = invite the create-account screen has a fourth, masked **Invite** field that takes the token (not the whole link); a blank one is asked for, and a bad one is refused with the same sentence as `/signup`. The account and the invite's use are one write, so two sign-ups with one link make one account; a used, revoked or expired link, or any link while signup is `closed`, makes none. While signup is `open`, a link is not spent.
347347
348 **Bulk actions in the terminal.** A person (never an agent) presses Ctrl+K and picks **Bulk actions** (`src/tui/screens/bulk.ts`): their own last 30 bulk actions, newest first, as `GET /api/v1/fleet/ops` lists them, each with its action, how many rows changed, how many were refused, whether `u` still undoes it, and when. `j`/`k` move; `u` undoes the selected one while it is inside its day, through `undoFleetOp` (`undoAsksBulk` for a bulk action on asks), and toasts the outcome; an undone, expired or non-reversible receipt, or an undo itself, says why and changes nothing. Enter opens the operation's rows: each row's before → after, and each refusal's reason. Another person's operations are never listed. An agent is not offered the screen and, reached another way, sees "Not available".
349
348350 **Spend ceilings.** Apart from the grant, a forge admin who is a person may set any account's — a person's, an agent's or an organization's — daily (UTC) and per-run ceilings in dollars: on the account's page (`/settings/accounts/<name>`, an organization's too, by that address; an agent and an admin's own account have no such page), the terminal's **Model ceiling** command, `POST /api/v1/admin/accounts/:name/ceiling` (`{"dailyUsd": 20, "perRunUsd": 5}`) or the MCP `model_ceiling` tool; each change is in the audit. Both empty on the page, `none` in the terminal (its prompt shows the current ceilings, and an empty answer changes nothing), or `null` for both on the API and MCP takes them back. An organization's page is linked from its own page for an admin. An account has no ceiling until one is set, so this forge's own crew is never stopped by a default nobody chose. A run on an account with a ceiling does not start when the day's is spent or the per-run one is too small, is capped at the smaller of the per-run ceiling and what is left of the day, and stops mid-run when the day's is spent.
349351 **Sandbox.** An agent's shell is held to a policy — the network is the forge
350352 and the package registry, writes stay in the clone, the forge's data and the
docs/FLEET.md +3 −1
@@ -74,7 +74,9 @@ one row per selected target: the value before, the value asked for, and
7474 `ok`, `failed`, `refused` or `unchanged` with the reason. One
7575 `fleet.operated` event — for its actor alone, since it names repositories
7676 across the forge — carries the counts. `GET /api/v1/fleet/ops` lists your
77 operations; `GET /api/v1/fleet/ops/:id` is one receipt.
77 operations; `GET /api/v1/fleet/ops/:id` is one receipt. In the terminal,
78 Ctrl+K → **Bulk actions** lists the same receipts and `u` undoes one
79 inside its day (`docs/CREW.md` says how).
7880
7981 Undo is a new operation that writes each `ok` row's value from before,
8082 only where the row still holds what the operation left; a row someone
src/tui/screens/bulk.ts added +171
@@ -0,0 +1,171 @@
1 /**
2 * Your bulk actions in the terminal (#7937): the operations you took, newest
3 * first, as `listFleetOps` has them — action, rows changed, rows refused,
4 * when. `u` undoes the selected one while it is inside its day, through
5 * `undoFleetOp` (`undoAsksBulk` for one on asks); an undone, expired or
6 * undo receipt says why and changes nothing. `enter` opens its rows: each
7 * one's before → after and each refusal's reason. The screen is a person's:
8 * an agent may preview a mass change, never apply or undo one.
9 */
10
11 import { getFleetOp, listFleetOps, undoFleetOp, UNDO_WINDOW_MS, isAsksOperation } from "../../actions/fleet.ts";
12 import { undoable, undoAsksBulk } from "../../actions/asks_bulk.ts";
13 import type { Operation } from "../../core/fleet_ops.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.ts";
16 import type { Session } from "../session.ts";
17 import { style } from "../term/style.ts";
18 import { truncate } from "../term/width.ts";
19 import { fill, len, split } from "../ui/layout.ts";
20 import { emptyState } from "../ui/widgets.ts";
21 import { ListState, ago, drawList, plural, runAsync, type ListRow } from "./common.ts";
22 import type { PaletteEntry } from "./entries.ts";
23
24 const LIMIT = 30;
25
26 const clean = (s: string): string => s.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, "").trim();
27 const show = (v: unknown): string => clean(v === null || v === undefined ? "—" : Array.isArray(v) ? v.join(", ") : String(v));
28
29 /** Why `u` does nothing on a receipt, in words; empty when it undoes. */
30 export function whyNot(op: Operation, now = Date.now()): string {
31 if (undoable(op, now)) return "";
32 if (op.undoOf !== null) return "this is an undo, and is not undone again";
33 if (op.undoneBy !== null) return "this operation was undone already";
34 if (!op.reversible) return `${clean(op.verb)} cannot be undone`;
35 if (now - Date.parse(op.createdAt) > UNDO_WINDOW_MS)
36 return "an undo is kept for a day, and this one's day is over";
37 return "this operation cannot be undone";
38 }
39
40 /** Whether a session is a person's: a bulk action is theirs to take and undo, never an agent's. */
41 export const isPerson = (session: Session): boolean =>
42 session.user !== undefined && session.user.kind === "user";
43
44 /** How long the list and its counts are kept before they are read again. */
45 const KEEP_MS = 2000;
46
47 function stateOf(op: Operation): string {
48 if (op.undoOf !== null) return "undo";
49 if (op.undoneBy !== null) return "undone";
50 return undoable(op) ? "u undoes" : "final";
51 }
52
53 /** One operation's rows: label · before → after, or the reason a row was refused. */
54 function rowsScreen(f: Session, ctx: Ctx, op: Operation): Screen {
55 const state = new ListState();
56 const full = getFleetOp(f.app, f.user, op.id);
57 const rows = full.ok ? full.value.rows : [];
58 return {
59 title: "Bulk action rows",
60 keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")],
61 render(area, s) {
62 const c = ctx.theme.c;
63 const [head, body] = split(area, "v", [len(1), fill(1)]);
64 s.text(head!.x, head!.y, truncate(`${clean(op.verb)} · ${clean(op.query)}`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
65 if (rows.length === 0) {
66 emptyState(s, body!, c, { title: "No rows kept." });
67 return;
68 }
69 const list: ListRow[] = rows.map((r) => ({
70 title: clean(r.label),
71 secondary: r.outcome === "ok" ? `${show(r.before)} → ${show(r.after)}` : `${r.outcome}: ${clean(r.reason)}`,
72 tone: r.outcome === "ok" ? "success" : "danger",
73 }));
74 drawList(s, body!, c, state, list);
75 },
76 key(e) {
77 if (e.ctrl || e.alt) return false;
78 if (e.key === "esc" || e.key === "q") {
79 ctx.pop();
80 return true;
81 }
82 return state.key(e, rows.length);
83 },
84 };
85 }
86
87 export function bulkScreen(f: Session, ctx: Ctx): Screen {
88 const state = new ListState();
89 const person = (): boolean => isPerson(f);
90 // The list and its counts are read once and kept, not on every frame and key.
91 let cache: { at: number; items: { op: Operation; done: number; refused: number }[] } | undefined;
92 const items = (): { op: Operation; done: number; refused: number }[] => {
93 if (!person()) return [];
94 if (cache !== undefined && Date.now() - cache.at < KEEP_MS) return cache.items;
95 const r = listFleetOps(f.app, f.user, LIMIT);
96 const read = (r.ok ? r.value : []).map((op) => {
97 const full = getFleetOp(f.app, f.user, op.id);
98 const rows = full.ok ? full.value.rows : [];
99 const done = rows.filter((x) => x.outcome === "ok").length;
100 return { op, done, refused: rows.length - done };
101 });
102 cache = { at: Date.now(), items: read };
103 return read;
104 };
105
106 const undo = (op: Operation): void => {
107 const why = whyNot(op);
108 if (why !== "") {
109 ctx.toast(why, "attn");
110 return;
111 }
112 runAsync(ctx, async () => {
113 const done = isAsksOperation(op)
114 ? await undoAsksBulk(f.app, f.user, op.id)
115 : await undoFleetOp(f.app, f.user, op.id);
116 cache = undefined;
117 ctx.toast(done.ok ? "undone" : done.error.message, done.ok ? "success" : "danger");
118 ctx.invalidate();
119 });
120 };
121
122 const screen: Screen = {
123 title: "Bulk actions",
124 keys: [
125 binding(["j", "k"], "move"),
126 binding(["u"], "undo the selected one, inside its day"),
127 binding(["enter"], "its rows"),
128 ],
129 render(area, s) {
130 const c = ctx.theme.c;
131 const [head, body] = split(area, "v", [len(1), fill(1)]);
132 s.text(head!.x, head!.y, truncate("your bulk actions, newest first", head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
133 const all = items();
134 if (all.length === 0) {
135 emptyState(s, body!, c, {
136 title: person() ? "No bulk actions yet" : "Not available",
137 body: person() ? "Archive or dismiss several at once and it is listed here." : "A bulk action is a person's call.",
138 });
139 return;
140 }
141 const rows: ListRow[] = all.map(({ op, done, refused }) => ({
142 title: `${clean(op.verb)} · ${clean(op.query)}`,
143 secondary: `${plural(done, "row")} changed, ${refused} refused`,
144 meta: `${stateOf(op)} · ${ago(op.createdAt)}`,
145 }));
146 drawList(s, body!, c, state, rows);
147 },
148 key(e) {
149 const all = items();
150 if (state.key(e, all.length)) return true;
151 if (e.ctrl || e.alt || !person()) return false;
152 const op = all[state.index]?.op;
153 if (op === undefined) return false;
154 if (e.key === "u") undo(op);
155 else if (e.key === "enter") ctx.push(rowsScreen(f, ctx, op));
156 else return false;
157 return true;
158 },
159 };
160 return screen;
161 }
162
163 /** Your bulk actions: a person's, never an agent's. */
164 export const entry: PaletteEntry = {
165 id: "bulk",
166 title: "Bulk actions",
167 hint: "your last bulk actions; u undoes one inside its day",
168 after: ["invites", "teams", "limits"],
169 offered: isPerson,
170 run: (session, ctx) => ctx.push(bulkScreen(session, ctx)),
171 };
src/tui/screens/entries.ts +2
@@ -12,6 +12,7 @@ import { entry as access } from "./access.ts";
1212 import { entry as agents } from "./agents.ts";
1313 import { entry as audit } from "./audit.ts";
1414 import { entry as boards } from "./boards.ts";
15 import { entry as bulk } from "./bulk.ts";
1516 import { entry as cloud } from "./cloud.ts";
1617 import { entry as crewHandover } from "./crewhandover.ts";
1718 import { entry as crewSplits } from "./crewsplits.ts";
@@ -69,6 +70,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
6970 teams,
7071 agents,
7172 invites,
73 bulk,
7274 subscriptions,
7375 cloud,
7476 domains,
test/palette-roster.test.ts +2
@@ -100,6 +100,7 @@ test("a site-admin person is offered the whole roster", () => {
100100 ["teams", "Crew teams", "each team's charter, dial and members"],
101101 ["agents", "Crew agents", "each agent's model, budget and brief"],
102102 ["invites", "Invite links", "make a single-use link, see which are open, revoke one"],
103 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
103104 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
104105 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
105106 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
@@ -191,6 +192,7 @@ test("a member person is offered the signed-in roster without the admin entries"
191192 ["model", "Model budget", "what your crew may spend, and add your own subscription"],
192193 ["can", "What I can do", "your role and what it allows here"],
193194 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
195 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
194196 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
195197 ["family", "Family", "the forge that sent this one, and the forges it sent"],
196198 ["chain", "Chain", "the forges this forge's code descends from, root first"],
test/tui-bulk.test.ts added +230
@@ -0,0 +1,230 @@
1 /** The terminal lists your bulk actions, and `u` undoes the selected one inside its day (#7937). */
2
3 import { afterAll, beforeAll, describe, 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 { applyAsksBulk } from "../src/actions/asks_bulk.ts";
8 import { applyFleetIds, undoFleetOp, UNDO_WINDOW_MS } from "../src/actions/fleet.ts";
9 import { openIssue } from "../src/actions/issues.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App as Forge } from "../src/app.ts";
12 import type { Operation } from "../src/core/fleet_ops.ts";
13 import { getIssueById, setAssignee } from "../src/core/issues.ts";
14 import { getRepo } from "../src/core/repos.ts";
15 import { createUser, getUserByName, type User } from "../src/core/users.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import { bulkScreen, entry, whyNot } from "../src/tui/screens/bulk.ts";
18 import { Session } from "../src/tui/session.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { settleApp, settleUntil } from "./helpers/tui.ts";
22
23 quietLogs();
24
25 describe("bulk actions in the terminal", () => {
26 let dataDir: string;
27 let forge: Forge;
28 let alice: User;
29 let bob: User;
30
31 function boot(user: User) {
32 const terminal = headlessTerminal(140, 32);
33 const { app } = createForgeApp(
34 forge,
35 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
36 { terminal, exitHooks: false },
37 );
38 app.start();
39 const key = (k: string, ctrl = false) =>
40 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
41 return {
42 app,
43 text: () => app.screen.toText(),
44 press: async (...keys: string[]) => {
45 keys.forEach((k) => key(k));
46 await settleApp(app);
47 },
48 push: async (user2: User) => {
49 app.push(bulkScreen(new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: user2 }), app.ctx));
50 await settleApp(app);
51 },
52 open: async () => {
53 key("k", true);
54 await settleApp(app);
55 for (const ch of "Bulk actions") key(ch);
56 key("enter");
57 await settleApp(app);
58 },
59 };
60 }
61 const archived = (name: string): boolean => getRepo(forge.db, "alice", name)?.archivedAt != null;
62
63 beforeAll(async () => {
64 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-bulk-"));
65 forge = createApp({ dataDir, port: 0 });
66 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
67 const made = await createUser(forge.db, {
68 origin: "seed",
69 trusted: true,
70 username,
71 email: `${username}@forge.local`,
72 password: "a-good-passphrase",
73 adminIfFirst,
74 });
75 if (!made.ok) throw new Error("user");
76 }
77 alice = getUserByName(forge.db, "alice")!;
78 bob = getUserByName(forge.db, "bob")!;
79 const made = await createRepo(forge, alice, { name: "solo", visibility: "public" });
80 if (!made.ok) throw new Error("repo");
81 });
82
83 afterAll(async () => {
84 forge.close();
85 await rm(dataDir, { recursive: true, force: true });
86 });
87
88 test("an archive, then u wakes the repository; a second u is refused; bob never sees it", async () => {
89 const op = await applyFleetIds(forge, alice, "repo", [getRepo(forge.db, "alice", "solo")!.id], "archive alice/solo", { verb: "archive", value: true });
90 if (!op.ok) throw new Error(op.error.message);
91 expect(archived("solo")).toBe(true);
92
93 const other = boot(bob);
94 try {
95 await other.open();
96 expect(other.text()).toContain("No bulk actions yet");
97 expect(other.text()).not.toContain("archive");
98 } finally {
99 other.app.stop();
100 }
101
102 const h = boot(alice);
103 try {
104 await h.open();
105 expect(h.text()).toContain("archive");
106 expect(h.text()).toContain("1 row changed, 0 refused");
107 expect(h.text()).toContain("u undoes");
108 await h.press("u");
109 await settleUntil(h.app, "undone");
110 expect(archived("solo")).toBe(false);
111 // The undo is a receipt of its own and heads the list; the original is below it.
112 await h.press("j", "u");
113 expect(h.text()).toContain("undone already");
114 expect(archived("solo")).toBe(false);
115 // Enter opens the rows: the receipt's before and after.
116 await h.press("enter");
117 expect(h.text()).toContain("alice/solo");
118 expect(h.text()).toContain("→");
119 } finally {
120 h.app.stop();
121 }
122 });
123
124 const archiveOne = async (name: string) => {
125 const made = await createRepo(forge, alice, { name, visibility: "public" });
126 if (!made.ok) throw new Error("repo");
127 const op = await applyFleetIds(forge, alice, "repo", [made.value.id], `archive alice/${name}`, { verb: "archive", value: true });
128 if (!op.ok) throw new Error(op.error.message);
129 return op.value;
130 };
131
132 test("whyNot says why each kind of receipt is not undone", () => {
133 const base: Operation = {
134 id: "x", actorId: "a", actorName: "alice", query: "q", kind: "repo", verb: "archive",
135 value: true, reversible: true, undoOf: null, undoneBy: null, createdAt: new Date().toISOString(), rows: [],
136 };
137 expect(whyNot(base)).toBe("");
138 expect(whyNot({ ...base, undoneBy: "y" })).toContain("undone already");
139 expect(whyNot({ ...base, undoOf: "y" })).toContain("is an undo");
140 expect(whyNot({ ...base, reversible: false })).toContain("cannot be undone");
141 const old = new Date(Date.now() - UNDO_WINDOW_MS - 60_000).toISOString();
142 expect(whyNot({ ...base, createdAt: old })).toContain("day is over");
143 });
144
145 test("an expired receipt is refused on u and nothing changes", async () => {
146 const op = await archiveOne("old");
147 const old = new Date(Date.now() - UNDO_WINDOW_MS - 60_000).toISOString();
148 forge.db.query("UPDATE fleet_operation SET created_at = ?1 WHERE id = ?2").run(old, op.id);
149 const h = boot(alice);
150 try {
151 await h.open();
152 await h.press("u");
153 expect(h.text()).toContain("day is over");
154 expect(getRepo(forge.db, "alice", "old")?.archivedAt).not.toBeNull();
155 } finally {
156 h.app.stop();
157 }
158 });
159
160 test("u on an undo receipt does nothing", async () => {
161 const op = await archiveOne("again");
162 const undone = await undoFleetOp(forge, alice, op.id);
163 if (!undone.ok) throw new Error(undone.error.message);
164 const h = boot(alice);
165 try {
166 await h.open();
167 await h.press("u");
168 expect(h.text()).toContain("is an undo");
169 } finally {
170 h.app.stop();
171 }
172 });
173
174 test("u undoes a bulk action on asks", async () => {
175 const made = await createRepo(forge, alice, { name: "asky", visibility: "public" });
176 if (!made.ok) throw new Error("repo");
177 const issue = openIssue(forge, alice, made.value, { title: "Close me", body: "" });
178 if (!issue.ok) throw new Error(String(issue.error));
179 setAssignee(forge.db, issue.value.id, alice.id);
180 const done = await applyAsksBulk(forge, alice, "close", [`assigned:${issue.value.id}`], "done");
181 if (!done.ok) throw new Error(done.error.message);
182 expect(getIssueById(forge.db, issue.value.id)!.state).toBe("closed");
183 const h = boot(alice);
184 try {
185 await h.open();
186 expect(h.text()).toContain("close");
187 await h.press("u");
188 await settleUntil(h.app, "undone");
189 expect(getIssueById(forge.db, issue.value.id)!.state).toBe("open");
190 } finally {
191 h.app.stop();
192 }
193 });
194
195 test("a failed undo shows the action's own words and changes nothing", async () => {
196 const op = await archiveOne("raced");
197 const h = boot(alice);
198 try {
199 await h.open();
200 // Listed as undoable, then undone elsewhere before u is pressed: the list is kept for a moment.
201 const first = await undoFleetOp(forge, alice, op.id);
202 if (!first.ok) throw new Error(first.error.message);
203 await h.press("u");
204 await settleUntil(h.app, "undone already");
205 expect(archived("raced")).toBe(false);
206 } finally {
207 h.app.stop();
208 }
209 });
210
211 test("another person cannot undo it, and an agent is not offered the screen", async () => {
212 const op = await archiveOne("mine");
213 const refused = await undoFleetOp(forge, bob, op.id);
214 expect(refused.ok).toBe(false);
215 expect(archived("mine")).toBe(true);
216
217 const agent = { ...alice, kind: "agent" as const };
218 const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: agent });
219 expect(entry.offered(session)).toBe(false);
220 expect(entry.offered(new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice }))).toBe(true);
221 const h = boot(alice);
222 try {
223 await h.push(agent);
224 expect(h.text()).toContain("Not available");
225 expect(h.text()).not.toContain("archive");
226 } finally {
227 h.app.stop();
228 }
229 });
230 });
test/tui-make-it-yours.test.ts +5 −2
@@ -14,7 +14,7 @@ import { local } from "../src/tui/screens/where.ts";
1414 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1515 import { quietLogs } from "./helpers/globals.ts";
1616 import { promote } from "./helpers/members.ts";
17 import { settleApp } from "./helpers/tui.ts";
17 import { settleApp, settleUntil } from "./helpers/tui.ts";
1818 import { proveAll } from "./helpers/verified.ts";
1919
2020 quietLogs();
@@ -154,7 +154,10 @@ test("a private original is never copied public: refused in the action's words",
154154 const h = await open(alice, made.value.repo); // a forge admin may read it
155155 await h.press("Y");
156156 await h.press("tab");
157 const text = await h.press("enter");
157 await h.press("enter");
158 // The refusal is the action's; under a loaded shard it may land a turn after the key settles.
159 await settleUntil(h.app, "a copy of a private repository stays private");
160 const text = h.app.screen.toText();
158161 expect(text).toContain("a copy of a private repository stays private");
159162 expect(getRepo(forge.db, "alice", "secret-plot")).toBeUndefined();
160163 h.app.stop();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs