Public: anyone can read this; the crew can change it.
The terminal's answers screen tells the crew more about the same app with `t`, without going back to the palette #7425
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7416-the-terminal-s-answers-screen-tells-the → main 2 commits fork 2 threads · 2 unresolved
3 files
changed +177
−5
docs/CREW.md +2 −1
| @@ -33,7 +33,8 @@ box, newest first, each with what the crew said back (its last comment, else its | ||
| 33 | 33 | last finished run's summary, else that it is working or waiting). The region |
| 34 | 34 | updates live from `GET /:owner/:name/app/answers` (owner only; 404 for anyone |
| 35 | 35 | else). In the terminal, `a` in the repository screen opens *Your tells* with the |
| 36 | same rows behind the same gate. | |
| 36 | same rows behind the same gate, and `t` there tells the crew more about the same | |
| 37 | app, told from the tell under the cursor (its page goes in as the new tell's page). | |
| 37 | 38 | |
| 38 | 39 | An **agent** is an account of kind `agent`: no password, no session, and it |
| 39 | 40 | acts only through a token the forge mints for a run and destroys after it. |
src/tui/screens/answers.ts +35 −4
| @@ -2,11 +2,15 @@ | ||
| 2 | 2 | * Your tells on an app, and what the crew said back — the page's own rows |
| 3 | 3 | * (`answersFor`), behind the page's own gate (`canSeeAnswers`): the owner of |
| 4 | 4 | * an app sees their tells; anyone else gets an empty screen, never a name. |
| 5 | * Read-only; `enter` opens the tell on the web, `r` reads again. | |
| 5 | * `enter` opens the tell on the web, `r` reads again, and `t` tells the crew | |
| 6 | * more (#7416): one line through the same `tell()` and `tellAnswer` words the | |
| 7 | * palette's tell uses, told from the row's page when the cursor is on one. | |
| 6 | 8 | */ |
| 7 | 9 | |
| 8 | 10 | import { answersFor, canSeeAnswers } from "../../web/studio/answers.ts"; |
| 9 | 11 | import { CREW_WORKING } from "../../actions/ask_state_words.ts"; |
| 12 | import { tell } from "../../actions/tell.ts"; | |
| 13 | import { SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; | |
| 10 | 14 | import type { Repo } from "../../core/repos.ts"; |
| 11 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 16 | import { binding } from "../app/keys.ts"; |
| @@ -15,7 +19,8 @@ import { fill, len, split } from "../ui/layout.ts"; | ||
| 15 | 19 | import { emptyState } from "../ui/widgets.ts"; |
| 16 | 20 | import { style } from "../term/style.ts"; |
| 17 | 21 | import { truncate } from "../term/width.ts"; |
| 18 | import { ListState, drawList, hitList, metaOf, openOnWeb, relTime, type ListRow } from "./common.ts"; | |
| 22 | import { ListState, drawList, hitList, metaOf, openOnWeb, promptScreen, relTime, type ListRow } from "./common.ts"; | |
| 23 | import { tellAnswer } from "./tell.ts"; | |
| 19 | 24 | |
| 20 | 25 | /** One line of plain text: a tell or an answer may carry escapes the terminal must not obey. */ |
| 21 | 26 | const plain = (text: string): string => |
| @@ -53,9 +58,30 @@ export function answersScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 53 | 58 | const line = lines[index]; |
| 54 | 59 | if (line !== undefined) openOnWeb(f, ctx, line.path, "That tell"); |
| 55 | 60 | }; |
| 56 | return { | |
| 61 | const more = (): void => { | |
| 62 | if (!canSeeAnswers(f.db, repo, f.user)) return; | |
| 63 | // The row's page goes in as the tell's `from`, as a toolbar tell's does. | |
| 64 | const from = lines[state.index]?.path ?? null; | |
| 65 | ctx.push( | |
| 66 | promptScreen(ctx, screen, { | |
| 67 | title: "Tell the crew", | |
| 68 | label: from ?? `${repo.ownerName}/${repo.name}`, | |
| 69 | hint: "enter sends", | |
| 70 | onSubmit: (said) => { | |
| 71 | if (said === "") return SAY_SOMETHING_WORDS; | |
| 72 | return (async () => { | |
| 73 | const answer = tellAnswer(await tell(f.app, f.user, repo, said, 1, from)); | |
| 74 | ctx.toast(answer.text, answer.tone); | |
| 75 | read(); | |
| 76 | ctx.invalidate(); | |
| 77 | })(); | |
| 78 | }, | |
| 79 | }), | |
| 80 | ); | |
| 81 | }; | |
| 82 | const screen: Screen = { | |
| 57 | 83 | title: "Your tells", |
| 58 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["r"], "refresh")], | |
| 84 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["t"], "tell"), binding(["r"], "refresh")], | |
| 59 | 85 | load: async () => read(), |
| 60 | 86 | render(area, s) { |
| 61 | 87 | const c = ctx.theme.c; |
| @@ -83,6 +109,10 @@ export function answersScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 83 | 109 | open(state.index); |
| 84 | 110 | return true; |
| 85 | 111 | } |
| 112 | if (e.key === "t") { | |
| 113 | more(); | |
| 114 | return true; | |
| 115 | } | |
| 86 | 116 | if (e.key === "r") { |
| 87 | 117 | read(); |
| 88 | 118 | ctx.invalidate(); |
| @@ -91,4 +121,5 @@ export function answersScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 91 | 121 | return false; |
| 92 | 122 | }, |
| 93 | 123 | }; |
| 124 | return screen; | |
| 94 | 125 | } |
test/tui-answers-tell.test.ts added +140
| @@ -0,0 +1,140 @@ | ||
| 1 | /** | |
| 2 | * The terminal's answers screen tells the crew more with `t` (#7416): the | |
| 3 | * row's page goes in as the tell's page, the new tell joins the list, the | |
| 4 | * screen's own gate stays, and a refused tell answers in the tell'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 { enableApp } from "../src/actions/apps.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { tell, UNVERIFIED_WORDS } from "../src/actions/tell.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { listIssues } from "../src/core/issues.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 18 | import { answerLines, answersScreen } from "../src/tui/screens/answers.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 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | let dir = ""; | |
| 27 | let app: App; | |
| 28 | let alice: User; | |
| 29 | let bob: User; | |
| 30 | let repo: Repo; | |
| 31 | ||
| 32 | const issues = () => listIssues(app.db, repo.id, { state: "all", limit: 50 }); | |
| 33 | ||
| 34 | /** The answers screen for `user`, fed `keys`; the text after each step. */ | |
| 35 | async function drive(user: User, keys: string[]): Promise<{ text: string; count: number }> { | |
| 36 | const terminal = headlessTerminal(120, 30); | |
| 37 | const { app: tui, session } = createForgeApp( | |
| 38 | app, | |
| 39 | { sshUsername: user.username, remoteAddress: "127.0.0.1", user }, | |
| 40 | { terminal, exitHooks: false }, | |
| 41 | ); | |
| 42 | tui.start(); | |
| 43 | await settleApp(tui); | |
| 44 | tui.push(answersScreen(session, tui.ctx, repo)); | |
| 45 | await settleApp(tui); | |
| 46 | for (const k of keys) { | |
| 47 | terminal.feed(k); | |
| 48 | await settleApp(tui); | |
| 49 | } | |
| 50 | const text = tui.screen.toText(); | |
| 51 | const count = answerLines(session, repo).length; | |
| 52 | tui.stop(); | |
| 53 | return { text, count }; | |
| 54 | } | |
| 55 | ||
| 56 | beforeAll(async () => { | |
| 57 | dir = await mkdtemp(join(tmpdir(), "forge-tui-answers-tell-")); | |
| 58 | app = createApp({ dataDir: dir, port: 0 }); | |
| 59 | const mk = async (username: string, first = false) => { | |
| 60 | const r = await createUser(app.db, { | |
| 61 | origin: "seed", | |
| 62 | trusted: true, | |
| 63 | username, | |
| 64 | email: `${username}@forge.local`, | |
| 65 | password: "a-good-passphrase", | |
| 66 | adminIfFirst: first, | |
| 67 | }); | |
| 68 | if (!r.ok) throw new Error(r.error); | |
| 69 | return r.value; | |
| 70 | }; | |
| 71 | alice = await mk("alice", true); | |
| 72 | bob = await mk("bob"); | |
| 73 | proveAll(app); | |
| 74 | const made = await createRepo(app, alice, { name: "notes", visibility: "public" }); | |
| 75 | if (!made.ok) throw new Error(String(made.error)); | |
| 76 | repo = made.value; | |
| 77 | const seeded = await commitToBranch(app, alice, repo, { | |
| 78 | message: "Start", | |
| 79 | files: [ | |
| 80 | { path: "package.json", content: '{"name":"notes","scripts":{"start":"bun run server.ts"}}\n' }, | |
| 81 | { path: "server.ts", content: "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n" }, | |
| 82 | ], | |
| 83 | }); | |
| 84 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 85 | const enabled = await enableApp(app, alice, repo, { slug: "notes" }); | |
| 86 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 87 | const first = await tell(app, alice, repo, "Add a footer."); | |
| 88 | if (!first.ok) throw new Error(first.error); | |
| 89 | }); | |
| 90 | ||
| 91 | afterAll(async () => { | |
| 92 | app.close(); | |
| 93 | await rm(dir, { recursive: true, force: true }); | |
| 94 | }); | |
| 95 | ||
| 96 | describe("t on the answers screen", () => { | |
| 97 | test("the key hints name it", async () => { | |
| 98 | expect((await drive(alice, [])).text).toMatch(/\bt +tell\b/); | |
| 99 | }); | |
| 100 | ||
| 101 | test("tells from the row's page, and the new tell is in the list when the screen comes back", async () => { | |
| 102 | const row = issues().find((i) => i.title === "Add a footer.")!; | |
| 103 | const before = issues().length; | |
| 104 | const seen = await drive(alice, ["t", ..."Make it blue too.", "\r"]); | |
| 105 | const after = issues(); | |
| 106 | expect(after.length).toBe(before + 1); | |
| 107 | const told = after.find((i) => i.title === "Make it blue too.")!; | |
| 108 | expect(told).toBeDefined(); | |
| 109 | const page = `/alice/notes/issues/${row.number}`; | |
| 110 | expect(told.body).toContain(`Told from [${page}](${page})`); | |
| 111 | expect(seen.text).toContain("Make it blue too."); | |
| 112 | expect(seen.count).toBe(2); | |
| 113 | }); | |
| 114 | ||
| 115 | test("an unverified owner hears the tell's own words and opens nothing", async () => { | |
| 116 | const before = issues().length; | |
| 117 | app.db.query("UPDATE email_check SET verified_at = NULL").run(); | |
| 118 | try { | |
| 119 | const seen = await drive(alice, ["t", ..."Unseen.", "\r"]); | |
| 120 | expect(seen.text).toContain(UNVERIFIED_WORDS.slice(0, 30)); | |
| 121 | } finally { | |
| 122 | proveAll(app); | |
| 123 | } | |
| 124 | expect(issues().length).toBe(before); | |
| 125 | }); | |
| 126 | ||
| 127 | test("the owner's `t` opens the prompt", async () => { | |
| 128 | expect((await drive(alice, ["t"])).text).toContain("Tell the crew"); | |
| 129 | }); | |
| 130 | ||
outdated
This assertion runs after |
||
| 131 | test("a stranger behind the screen's gate gets no prompt", async () => { | |
| 132 | expect((await drive(bob, ["t"])).text).not.toContain("Tell the crew"); | |
| 133 | }); | |
| 134 | ||
| 135 | test("a stranger's `t` and words open nothing", async () => { | |
| 136 | const before = issues().length; | |
| 137 | await drive(bob, ["t", ..."Sneak in.", "\r"]); | |
| 138 | expect(issues().length).toBe(before); | |
| 139 | }); | |
| 140 | }); |
new above
offline
This guard is fine. Note that the palette hides its tell from anyone
mayDirectCrewrefuses, but here someone who can see the answers without directing the crew still gets the prompt, and only hearsDIRECTION_NOTEafter typing. That matches the issue (refusals answer in the tell's words), so it is not a blocker.