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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1788-terminal-tell-the-crew-from-a-repository → main 2 commits fork 2 threads · 2 unresolved
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 | ||
| 450 | 450 | the answer comes back to it. Without a script the row is a link to a page of |
| 451 | 451 | its own, `/:owner/:name/tell?from=/path`, which is the same box; `from` must be |
| 452 | 452 | 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. | |
| 454 | 456 | |
| 455 | 457 | An **agent** is an account of kind `agent`: no password, no session, and it |
| 456 | 458 | 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[] = [ | ||
| 333 | 333 | "test/web-origins.test.ts", |
| 334 | 334 | "test/roll.test.ts", |
| 335 | 335 | "test/rehearsal.test.ts", |
| 336 | "test/tui-tell.test.ts", | |
| 336 | 337 | ]; |
| 337 | 338 | |
| 338 | 339 | export const EXCLUDED: readonly Exclusion[] = [ |
src/tui/app/app.ts +3 −1
| @@ -85,6 +85,8 @@ export interface Screen { | ||
| 85 | 85 | paste?(text: string): boolean; |
| 86 | 86 | /** Async load, cancelled by `signal` when the screen is popped. */ |
| 87 | 87 | load?(signal: AbortSignal): Promise<void>; |
| 88 | /** Palette rows only this screen offers, listed after the app's own. */ | |
| 89 | commands?(): Command[]; | |
| 88 | 90 | } |
| 89 | 91 | |
| 90 | 92 | /** |
| @@ -788,7 +790,7 @@ export class App { | ||
| 788 | 790 | return; |
| 789 | 791 | case "palette": |
| 790 | 792 | this.overlay = "palette"; |
| 791 | this.palette.open(this.commands); | |
| 793 | this.palette.open([...this.commands, ...(this.top().screen.commands?.() ?? [])]); | |
| 792 | 794 | this.schedulePaletteSearch(); |
| 793 | 795 | return; |
| 794 | 796 | 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) }; | |
| 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 { | ||
| 109 | 109 | slug, |
| 110 | 110 | openOnWeb, |
| 111 | 111 | } from "./common.ts"; |
| 112 | import { tellCommands } from "./tell.ts"; | |
| 112 | 113 | import { externalEditor, editorHint, hasExternalEditor } from "./external.ts"; |
| 113 | 114 | import { finderScreen } from "./finder.ts"; |
| 114 | 115 | import { issuesScreen } from "./issues.ts"; |
| @@ -1262,6 +1263,7 @@ export function workspaceScreen( | ||
| 1262 | 1263 | }; |
| 1263 | 1264 | |
| 1264 | 1265 | const screen: Screen = { |
| 1266 | commands: () => tellCommands(f, ctx, () => screen, repo), | |
| 1265 | 1267 | get title() { |
| 1266 | 1268 | return sourceName(src); |
| 1267 | 1269 | }, |
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 | }); |
new above
offline
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).