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

Terminal: a door to /asked and the thought stream #1899

Merged opened this

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

Merged

scout/forge:scout/1755-terminal-a-door-to-asked-and-the-thought → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/1755-terminal-a-door-to-asked-and-the-thought was merged into main as 0607bff.

Review

Sign in to review this change.
5 files changed +321 −0
README.md +6
@@ -87,6 +87,12 @@ sees `Reduced protection: …` in one line, under the filter on the terminal's
8787 first screen and in the web footer; nobody else does, and a fully protected
8888 forge shows nothing.
8989
90 What you asked for: `Ctrl+K`, then **Asked**, is `/asked` in the terminal — your
91 thoughts, then the issues you can write to that are not shipped, oldest first
92 (`j`/`k` move, `enter` gives the issue's web address, `r` refreshes). It is read
93 only, shows a guest nothing, and names nothing of a placement in a repository
94 you cannot read.
95
9096 The family across forges: `:` → Family in the terminal, `/family` on the web,
9197 `GET /api/v1/family` and the MCP `family` tool all give the same view. The
9298 forge that sent this one is shown to anyone; the forges it sent, only to a
scripts/gate.ts +1
@@ -403,6 +403,7 @@ export const EXCLUDED: readonly Exclusion[] = [
403403 issue: 261,
404404 },
405405 { file: "test/tui-app.test.ts", reason: "not yet admitted", issue: 261 },
406 { file: "test/tui-asked.test.ts", reason: "not yet admitted", issue: 261 },
406407 { file: "test/tui-apps.test.ts", reason: "not yet admitted", issue: 261 },
407408 { file: "test/tui-code.test.ts", reason: "not yet admitted", issue: 261 },
408409 { file: "test/tui-editor.test.ts", reason: "not yet admitted", issue: 261 },
src/tui/forge.ts +9
@@ -28,6 +28,7 @@ import {
2828 openOnWeb,
2929 runAsync,
3030 } from "./screens/common.ts";
31 import { askedScreen } from "./screens/asked.ts";
3132 import { familyScreen } from "./screens/family.ts";
3233 import { familyLines, familyView } from "../spawn/family.ts";
3334 import { issueScreen } from "./screens/issue.ts";
@@ -167,6 +168,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
167168 ctx.push(signInScreen(session, ctx, () => ctx.invalidate())),
168169 },
169170 );
171 // What you asked for and what became of it, as /asked says it: a signed-in person's.
172 if (session.signedIn)
173 commands.push({
174 id: "asked",
175 title: "Asked",
176 hint: "your thoughts and what became of them",
177 run: () => ctx.push(askedScreen(session, ctx)),
178 });
170179 if (session.signedIn)
171180 commands.push(
172181 {
src/tui/screens/asked.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * `/asked` in the terminal (#1755): the owner's thoughts, then what they
3 * asked for in issues, from the same `thoughtStream` and `askedIn` reads the
4 * page makes — so the gate is core's and not this screen's. A guest has
5 * neither, a viewer sees only their own thoughts, and a placement in a
6 * repository the viewer cannot read says so and names nothing of it.
7 * The palette offers the door only when signed in, so the screen has no
8 * signed-out face; `askedLines` still returns nothing for a guest. Read-only; `enter` on an ask hands over its web address.
9 */
10
11 import type { Database } from "bun:sqlite";
12 import { askedIn, askedRepos, type Asked } from "../../core/asked.ts";
13 import { thoughtStream, type StreamThought } from "../../core/thought_stream.ts";
14 import type { User } from "../../core/users.ts";
15 import type { Ctx, Screen } from "../app/app.ts";
16 import { binding } from "../app/keys.ts";
17 import type { Session } from "../session.ts";
18 import { split, fill, len } from "../ui/layout.ts";
19 import { emptyState, type Tone } from "../ui/widgets.ts";
20 import { style } from "../term/style.ts";
21 import { truncate } from "../term/width.ts";
22 import { ListState, drawList, hitList, metaOf, openOnWeb, relTime, type ListRow } from "./common.ts";
23
24 /** One line of plain text: a typed thought may carry newlines or escapes the terminal must not obey. */
25 const plain = (text: string): string =>
26 // eslint-disable-next-line no-control-regex
27 text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
28
29 export interface AskedLine extends ListRow {
30 /** The web path `enter` opens; absent for a thought. */
31 readonly path?: string;
32 }
33
34 const THOUGHT_WORD = {
35 unplaced: "heard",
36 placed: "placed",
37 building: "building",
38 shipped: "shipped",
39 superseded: "superseded",
40 } as const;
41
42 /** Red is for nothing built, green for shipped, amber for under way. */
43 function toneOf(flagged: boolean, state: string): { tone?: Tone } {
44 if (flagged) return { tone: "danger" };
45 if (state === "shipped") return { tone: "success" };
46 return state === "building" ? { tone: "attn" } : {};
47 }
48
49 function thoughtLine(t: StreamThought): AskedLine {
50 const where = t.placed.map((x) =>
51 x.readable ? `${x.action} ${x.repo} #${x.number}` : `${x.action} in a repository you cannot read`,
52 );
53 return {
54 title: plain(t.text),
55 ...toneOf(t.flagged, t.state),
56 hollow: t.state === "unplaced" || t.state === "superseded",
outdated

The ask rows and path are untested. Add a test with a real issue: title, address, unshipped filter.

57 secondary: plain(where.length > 0 ? where.join(" · ") : (t.reply ?? "the planner has not placed it yet")),
58 meta: metaOf(t.flagged ? "nothing built" : THOUGHT_WORD[t.state], relTime(t.receivedAt)),
59 };
60 }
61
62 function askLine(a: Asked): AskedLine {
63 const word =
64 a.doneBy.length > 0 ? "done on main" : a.flagged ? "nothing built" : a.state;
65 return {
66 title: plain(a.issue.title),
67 ...toneOf(a.flagged, a.state),
68 secondary: `${a.repo.ownerName}/${a.repo.name} #${a.issue.number} · ${plain(a.askedBy)}`,
69 meta: metaOf(word, relTime(a.askedAt)),
70 path: `/${a.repo.ownerName}/${a.repo.name}/issues/${a.issue.number}`,
71 };
72 }
73
74 /** The thoughts, then the asks, as rows. A guest has none of either. */
75 export function askedLines(db: Database, viewer: User | undefined): { thoughts: AskedLine[]; asks: AskedLine[] } {
76 if (viewer === undefined) return { thoughts: [], asks: [] };
77 const repos = askedRepos(db, viewer, null);
78 return {
79 thoughts: thoughtStream(db, viewer, { limit: 50 }).map(thoughtLine),
80 asks: askedIn(db, repos, { show: "unshipped", order: "oldest" }).map(askLine),
81 };
82 }
83
84 export function askedScreen(f: Session, ctx: Ctx): Screen {
85 const state = new ListState();
86 let lines: AskedLine[] = [];
87 let thoughtCount = 0;
88 const open = (index: number): void => {
89 const line = lines[index];
90 if (line?.path !== undefined) openOnWeb(f, ctx, line.path, "That ask");
91 };
92 const read = (): void => {
93 const got = askedLines(f.db, f.user);
94 lines = [...got.thoughts, ...got.asks];
95 thoughtCount = got.thoughts.length;
96 state.clamp(lines.length);
97 };
98 return {
outdated

Unreachable for guests, since the palette does not offer the door to them. Drop the branch or document why it stays.

99 title: "Asked",
100 keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["r"], "refresh")],
101 load: async () => read(),
102 render(area, s) {
103 const c = ctx.theme.c;
104 const [head, body] = split(area, "v", [len(1), fill(1)]);
105 const sentence = `${thoughtCount} thoughts · ${lines.length - thoughtCount} asks not shipped`;
106 s.text(head!.x, head!.y, truncate(sentence, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
107 if (lines.length === 0) {
108 emptyState(s, body!, c, {
109 title: "Nothing asked yet",
110 body: "What you tell the crew shows up here, with what became of it.",
111 });
112 return;
113 }
114 drawList(s, body!, c, state, lines);
115 hitList(ctx, body!, state, lines.length, { open });
116 },
117 key(e) {
118 if (state.key(e, lines.length)) return true;
119 if (e.key === "enter") {
120 open(state.index);
121 return true;
122 }
123 if (e.key === "r") {
124 read();
125 ctx.invalidate();
126 return true;
127 }
128 return false;
129 },
130 };
131 }
test/tui-asked.test.ts added +174
@@ -0,0 +1,174 @@
1 /**
2 * /asked in the terminal (#1755): the screen reads the page's own rows, so a
3 * guest sees nothing, a viewer sees only their own thoughts, and a placement
4 * in a repository they cannot read names nothing.
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 { createIssue, setIssueState } from "../src/core/issues.ts";
13 import { savePlacement } from "../src/core/placements.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
16 import { askedLines, askedScreen } from "../src/tui/screens/asked.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp } from "./helpers/tui.ts";
20
21 quietLogs();
22
23 let dir: string;
24 let app: App;
25 let alice: User;
26 let bob: User;
27 let carol: User;
28
29 const identity = (user?: User) => ({
30 sshUsername: user?.username ?? "git",
31 remoteAddress: "127.0.0.1",
32 fingerprint: "SHA256:test",
33 user,
34 });
35
36 const thought = (who: User, id: string, text: string) =>
37 app.db
38 .query("INSERT INTO thought (id, author, text, source, received_at) VALUES (?1, ?2, ?3, 'composer', ?4)")
39 .run(id, who.id, text, new Date().toISOString());
40
41 async function screenText(user?: User, keys = ""): Promise<string> {
42 const terminal = headlessTerminal(120, 30);
43 const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false });
44 tui.start();
45 tui.ctx.push(askedScreen(session, tui.ctx));
46 await settleApp(tui);
47 if (keys !== "") {
48 terminal.feed(keys);
49 await settleApp(tui);
50 }
51 const text = tui.screen.toText();
52 tui.stop?.();
53 return text;
54 }
55
56 const ask = (repoId: string, who: User, title: string, at: string) => {
57 const r = createIssue(app.db, { repoId, authorId: who.id, title });
58 if (!r.ok) throw new Error(r.error);
59 app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2").run(at, r.value.id);
60 return r.value;
61 };
62
63 beforeAll(async () => {
64 dir = await mkdtemp(join(tmpdir(), "forge-tui-asked-"));
65 app = createApp({ dataDir: dir, port: 0 });
66 const mk = async (username: string, first = false) => {
67 const r = await createUser(app.db, {
68 trusted: true, username, email: `${username}@forge.local`,
69 password: "a-good-passphrase", adminIfFirst: first,
70 });
71 if (!r.ok) throw new Error(r.error);
72 return r.value;
73 };
74 carol = await mk("carol", true);
75 alice = await mk("alice");
76 bob = await mk("bob");
77 const lobby = await createRepo(app, alice, { name: "lobby", visibility: "public" });
78 if (!lobby.ok) throw new Error(String(lobby.error));
79 const secret = await createRepo(app, bob, { name: "vault", visibility: "private" });
80 if (!secret.ok) throw new Error(String(secret.error));
81 const now = Date.now();
82 const iso = (daysAgo: number) => new Date(now - daysAgo * 86_400_000).toISOString();
83 ask(lobby.value.id, alice, "Newer ask for the lobby", iso(1));
84 ask(lobby.value.id, alice, "Older ask for the lobby", iso(2));
85 setIssueState(app.db, ask(lobby.value.id, alice, "Already shipped ask", iso(3)).id, "closed");
86 ask(secret.value.id, bob, "Secret ask in the vault", iso(1));
87 thought(alice, "t1", "make the lobby louder");
88 thought(alice, "t2", "hide the vault door");
89 thought(bob, "t3", "bob's private idea");
90 savePlacement(app.db, {
91 thoughtId: "t2", kind: "work", asked: false, decidedBy: "rule", reply: "ok",
92 placed: [{ action: "opened", repo: "bob/vault", number: 1 }],
93 placedAt: new Date().toISOString(),
94 });
95 });
96 afterAll(async () => {
97 app.db.close();
98 await rm(dir, { recursive: true, force: true });
99 });
100
101 describe("the asked screen", () => {
102 test("a signed-in user sees their own thoughts and no one else's", async () => {
103 const text = await screenText(alice);
104 expect(text).toContain("make the lobby louder");
105 expect(text).toContain("hide the vault door");
106 expect(text).not.toContain("bob's private idea");
107 });
108
109 test("a placement in an unreadable repository shows no slug or state", async () => {
110 const text = await screenText(alice);
111 expect(text).toContain("opened in a repository you cannot read");
112 expect(text).not.toContain("bob/vault");
113 });
114
115 test("a guest sees nothing", async () => {
116 const text = await screenText();
117 expect(text).toContain("Nothing asked yet");
118 expect(text).not.toContain("make the lobby louder");
119 expect(text).not.toContain("bob's private idea");
120 });
121
122 test("asks are unshipped, oldest first, with their address, and an unreadable repository's is absent", () => {
123 const { asks } = askedLines(app.db, alice);
124 expect(asks.map((a) => a.title)).toEqual(["Older ask for the lobby", "Newer ask for the lobby"]);
125 expect(asks[0]!.secondary).toContain("alice/lobby #");
126 expect(asks[0]!.secondary).toContain("alice");
127 expect(asks[0]!.path).toMatch(/^\/alice\/lobby\/issues\/\d+$/);
128 expect(JSON.stringify(askedLines(app.db, alice))).not.toContain("Secret ask");
129 });
130
131 test("the screen shows the ask and its address, never the unreadable one", async () => {
132 const text = await screenText(alice);
133 expect(text).toContain("Older ask for the lobby");
134 expect(text).toContain("alice/lobby #");
135 expect(text).not.toContain("Already shipped ask");
136 expect(text).not.toContain("Secret ask");
137 });
138
139 test("an admin sees every ask, a guest none", () => {
140 const titles = askedLines(app.db, carol).asks.map((a) => a.title);
141 expect(titles).toContain("Secret ask in the vault");
142 expect(titles).toContain("Older ask for the lobby");
143 expect(askedLines(app.db, undefined)).toEqual({ thoughts: [], asks: [] });
144 });
145
146 test("enter on an ask hands over its web address", async () => {
147 const { asks, thoughts } = askedLines(app.db, alice);
148 const down = "j".repeat(thoughts.length);
149 const text = await screenText(alice, `${down}\r`);
150 expect(text).toContain(asks[0]!.path!);
151 });
152
153 test("r reads again, so a new ask appears", async () => {
154 const terminal = headlessTerminal(120, 30);
155 const { app: tui, session } = createForgeApp(app, identity(alice), { terminal, exitHooks: false });
156 tui.start();
157 tui.ctx.push(askedScreen(session, tui.ctx));
158 await settleApp(tui);
159 const lobby = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'lobby'").get()!;
160 ask(lobby.id, alice, "Asked while watching", new Date().toISOString());
161 expect(tui.screen.toText()).not.toContain("Asked while watching");
162 terminal.feed("r");
163 await settleApp(tui);
164 expect(tui.screen.toText()).toContain("Asked while watching");
165 tui.stop?.();
166 });
167
168 test("the palette offers the door to the signed in only", () => {
169 const ctx = {} as never;
170 const ids = (u?: User) => forgeCommands(new Session(app, identity(u)))(ctx).map((c) => c.id);
171 expect(ids(alice)).toContain("asked");
172 expect(ids()).not.toContain("asked");
173 });
174 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs