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

Merged opened this

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

Merged

torque/forge:torque/7416-the-terminal-s-answers-screen-tells-the → main 2 commits fork 2 threads · 2 unresolved

  • Merged. torque/7416-the-terminal-s-answers-screen-tells-the was merged into main as 57ee8eb.

Review

Sign in to review this change.
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
3333 last finished run's summary, else that it is working or waiting). The region
3434 updates live from `GET /:owner/:name/app/answers` (owner only; 404 for anyone
3535 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).
3738
3839 An **agent** is an account of kind `agent`: no password, no session, and it
3940 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 @@
22 * Your tells on an app, and what the crew said back — the page's own rows
33 * (`answersFor`), behind the page's own gate (`canSeeAnswers`): the owner of
44 * 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.
68 */
79
810 import { answersFor, canSeeAnswers } from "../../web/studio/answers.ts";
911 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";
1014 import type { Repo } from "../../core/repos.ts";
1115 import type { Ctx, Screen } from "../app/app.ts";
1216 import { binding } from "../app/keys.ts";
@@ -15,7 +19,8 @@ import { fill, len, split } from "../ui/layout.ts";
1519 import { emptyState } from "../ui/widgets.ts";
1620 import { style } from "../term/style.ts";
1721 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";
1924
2025 /** One line of plain text: a tell or an answer may carry escapes the terminal must not obey. */
2126 const plain = (text: string): string =>
@@ -53,9 +58,30 @@ export function answersScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5358 const line = lines[index];
5459 if (line !== undefined) openOnWeb(f, ctx, line.path, "That tell");
5560 };
56 return {
61 const more = (): void => {
62 if (!canSeeAnswers(f.db, repo, f.user)) return;

This guard is fine. Note that the palette hides its tell from anyone mayDirectCrew refuses, but here someone who can see the answers without directing the crew still gets the prompt, and only hears DIRECTION_NOTE after typing. That matches the issue (refusals answer in the tell's words), so it is not a blocker.

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 = {
5783 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")],
5985 load: async () => read(),
6086 render(area, s) {
6187 const c = ctx.theme.c;
@@ -83,6 +109,10 @@ export function answersScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
83109 open(state.index);
84110 return true;
85111 }
112 if (e.key === "t") {
113 more();
114 return true;
115 }
86116 if (e.key === "r") {
87117 read();
88118 ctx.invalidate();
@@ -91,4 +121,5 @@ export function answersScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
91121 return false;
92122 },
93123 };
124 return screen;
94125 }
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 \r, when the prompt has already closed either way, and tell() refuses bob by itself. So the test passes with the canSeeAnswers guard in more() deleted. Drive ["t"] alone and assert Tell the crew is absent: that fails without the guard.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs