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

Terminal: tell the crew from a repository screen in src/tui, as the web palette's Tell the crew does #1911

Merged opened this

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

Merged

scout/forge:scout/1788-terminal-tell-the-crew-from-a-repository → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/1788-terminal-tell-the-crew-from-a-repository was merged into main as 3ca14ab.

Review

Sign in to review this change.
6 files changed +180 −2
README.md +3 −1
@@ -450,7 +450,9 @@ enter is a new line), `esc` closes. The tell remembers that page (`from`), and
450450 the answer comes back to it. Without a script the row is a link to a page of
451451 its own, `/:owner/:name/tell?from=/path`, which is the same box; `from` must be
452452 a path on this forge. Who may not direct the crew gets a 404 there and no row.
453 The terminal's palette has no such row yet.
453 The terminal's palette has the row too, on a repository screen, for the
454 same people: it takes one line, tells with no `from`, and answers in the
455 same words.
454456
455457 An **agent** is an account of kind `agent`: no password, no session, and it
456458 acts only through a token the forge mints for a run and destroys after it.
scripts/gate.ts +1
@@ -333,6 +333,7 @@ export const GATE: readonly string[] = [
333333 "test/web-origins.test.ts",
334334 "test/roll.test.ts",
335335 "test/rehearsal.test.ts",
336 "test/tui-tell.test.ts",
336337 ];
337338
338339 export const EXCLUDED: readonly Exclusion[] = [
src/tui/app/app.ts +3 −1
@@ -85,6 +85,8 @@ export interface Screen {
8585 paste?(text: string): boolean;
8686 /** Async load, cancelled by `signal` when the screen is popped. */
8787 load?(signal: AbortSignal): Promise<void>;
88 /** Palette rows only this screen offers, listed after the app's own. */
89 commands?(): Command[];
8890 }
8991
9092 /**
@@ -788,7 +790,7 @@ export class App {
788790 return;
789791 case "palette":
790792 this.overlay = "palette";
791 this.palette.open(this.commands);
793 this.palette.open([...this.commands, ...(this.top().screen.commands?.() ?? [])]);
792794 this.schedulePaletteSearch();
793795 return;
794796 case "appearance":
src/tui/screens/tell.ts added +58
@@ -0,0 +1,58 @@
1 /**
2 * Tell the crew, from a repository screen's `:` palette (#1788): one line,
3 * the same `tell()` the web row calls, answered in the web flash's words.
4 * The row is the screen's own and only for who `mayDirectCrew` there — the
5 * gate `tellRow` in `src/web/scope.ts` asks. `from` is null: a terminal
6 * screen has no page path of its own.
7 */
8
9 import { tell, toldWords, UNVERIFIED_WORDS, type Told, type TellError } from "../../actions/tell.ts";
10 import type { Repo } from "../../core/repos.ts";
11 import { DIRECTION_NOTE, mayDirectCrew } from "../../crew/direct.ts";
12 import type { Result } from "../../kernel/result.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import type { Command } from "../app/palette.ts";
15 import type { Session } from "../session.ts";
16 import { promptScreen } from "./common.ts";
17
18 /** The words of one tell, refused or told, with the tone they are shown in. */
19 export function tellAnswer(told: Result<Told, TellError>): { text: string; tone: "success" | "danger" } {
20 if (told.ok) return { tone: told.value.routed === undefined || told.value.assigned ? "success" : "danger", text: toldWords(told.value) };
outdated

Duplicates toldWords()/UNVERIFIED_WORDS from src/actions/tell.ts and has drifted: it claims "is on it" when the tell was not assigned and omits the issue number. Call toldWords(told.value).

21 return {
22 tone: "danger",
23 text:
24 told.error === "forbidden"
25 ? DIRECTION_NOTE
26 : told.error === "unverified"
27 ? UNVERIFIED_WORDS
28 : "That could not be told.",
29 };

Every error other than forbidden and unverified collapses to one sentence. Reuse the web flash's wording per error (title too long, body too long, not found) so a person knows what to change.

30 }
31
32 /** The palette row for `repo`, or none for a reader who may not direct the crew. */
33 export function tellCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: Repo | undefined): Command[] {
34 if (repo === undefined || !mayDirectCrew(f.db, repo, f.user)) return [];
35 const name = `${repo.ownerName}/${repo.name}`;
36 return [
37 {
38 id: "tell",
39 title: `${name} — Tell the crew`,
40 hint: "one line",
41 run: () =>
42 ctx.push(
43 promptScreen(ctx, parent(), {
44 title: "Tell the crew",
45 label: name,
46 hint: "enter sends",
47 onSubmit: (said) => {
48 if (said === "") return "Say something first.";
49 return (async () => {
50 const answer = tellAnswer(await tell(f.app, f.user, repo, said));
51 ctx.toast(answer.text, answer.tone);
52 })();
53 },
54 }),
55 ),
56 },
57 ];
58 }
src/tui/screens/workspace.ts +2
@@ -109,6 +109,7 @@ import {
109109 slug,
110110 openOnWeb,
111111 } from "./common.ts";
112 import { tellCommands } from "./tell.ts";
112113 import { externalEditor, editorHint, hasExternalEditor } from "./external.ts";
113114 import { finderScreen } from "./finder.ts";
114115 import { issuesScreen } from "./issues.ts";
@@ -1262,6 +1263,7 @@ export function workspaceScreen(
12621263 };
12631264
12641265 const screen: Screen = {
1266 commands: () => tellCommands(f, ctx, () => screen, repo),
12651267 get title() {
12661268 return sourceName(src);
12671269 },
test/tui-tell.test.ts added +113
@@ -0,0 +1,113 @@
1 /**
2 * Tell the crew from a repository screen's palette (#1788): the row is
3 * there for who may direct the crew and for no one else, and sending it is
4 * the same `tell()` the web row calls, answered in the web flash's words.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { listIssues } from "../src/core/issues.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { tellAnswer } from "../src/tui/screens/tell.ts";
15 import { toldWords, UNVERIFIED_WORDS, type Told } from "../src/actions/tell.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import { repoScreen } from "../src/tui/screens/repo.ts";
18 import { local } from "../src/tui/screens/where.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { settleApp } from "./helpers/tui.ts";
22 import { proveAll } from "./helpers/verified.ts";
23 import type { Repo } from "../src/core/repos.ts";
24
25 quietLogs();
26
27 let dir: string;
28 let app: App;
29 let alice: User;
30 let bob: User;
31 let repo: Repo;
32
33 async function palette(user: User, keys: string[]): Promise<string> {
34 const terminal = headlessTerminal(120, 30);
35 const { app: tui, session } = createForgeApp(
36 app,
37 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
38 { terminal, exitHooks: false },
39 );
40 tui.start();
41 tui.ctx.push(repoScreen(session, tui.ctx, local(session, repo)));
42 await settleApp(tui);
43 for (const k of keys) {
44 terminal.feed(k);
45 await settleApp(tui);
46 }
47 const text = tui.screen.toText();
48 tui.stop?.();
49 return text;
50 }
51
52 beforeAll(async () => {
53 dir = await mkdtemp(join(tmpdir(), "forge-tui-tell-"));
54 app = createApp({ dataDir: dir, port: 0 });
55 const mk = async (username: string, first = false) => {
56 const r = await createUser(app.db, {
57 trusted: true, username, email: `${username}@forge.local`,
58 password: "a-good-passphrase", adminIfFirst: first,
59 });
60 if (!r.ok) throw new Error(r.error);
61 return r.value;
62 };
63 alice = await mk("alice", true);
64 bob = await mk("bob");
65 proveAll(app);
66 const made = await createRepo(app, alice, { name: "lobby", visibility: "public" });
67 if (!made.ok) throw new Error(String(made.error));
68 repo = made.value;
69 });
70 afterAll(async () => {
71 app.db.close();
72 await rm(dir, { recursive: true, force: true });
73 });
74
75 describe("tell the crew in the terminal", () => {
76 test("the owner's palette has the row", async () => {
77 expect(await palette(alice, [":"])).toContain("alice/lobby — Tell the crew");
78 });
79
80 test("a reader who may not direct the crew sees no row", async () => {
81 expect(await palette(bob, [":"])).not.toContain("Tell the crew");
82 });
83
84 test("one line is told, and the answer is the web's words", async () => {
85 const text = await palette(alice, [":", "tell", "\r", ..."paint the door red".split(""), "\r"]);
86 expect(text).toContain("Nobody is on the crew to take it yet");
87 expect(listIssues(app.db, repo.id, {}).some((i) => i.title === "paint the door red")).toBe(true);
88 });
89
90 test("an empty line says so and tells nothing", async () => {
91 const before = listIssues(app.db, repo.id, {}).length;
92 const text = await palette(alice, [":", "tell", "\r", "\r"]);
93 expect(text).toContain("Say something first.");
94 expect(listIssues(app.db, repo.id, {}).length).toBe(before);
95 });
96
97 test("a tell that could not be assigned says so, with its number", () => {
98 const value = {
99 issue: { number: 7 },
100 assigned: false,
101 routed: { builder: { user: { username: "mason" } }, size: "small", kind: "fix", by: "rule", ms: 1 },
102 } as unknown as Told;
103 const answer = tellAnswer({ ok: true, value });
104 expect(answer.text).toBe(toldWords(value));
105 expect(answer.text).toContain("Told as #7, but mason could not take it.");
106 expect(answer.text).not.toContain("is on it");
107 expect(answer.tone).toBe("danger");
108 });
109
110 test("an unverified address gets the shared words", () => {
111 expect(tellAnswer({ ok: false, error: "unverified" }).text).toBe(UNVERIFIED_WORDS);
112 });
113 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs