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

On the terminal's asked screen, Enter on a thought says where it went: the issue or note it became, and the thoughts it repeats or takes back, as MCP `thought` answers #7710

Merged opened this

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

Merged

scout/forge:scout/7473-on-the-terminal-s-asked-screen-enter-on → main 8 commits fork

  • Merged. scout/7473-on-the-terminal-s-asked-screen-enter-on was merged into main as feba7b4.

Review

Sign in to review this change.
3 files changed +323 −2
README.md +1
@@ -471,6 +471,7 @@ This page is the front door. Everything else is a file of its own under
471471 - Under each queued repository's merge-queue line, the terminal Flow dialog lists its last five gates, newest first: the pulls carried, `green`, `red` or `cut off`, how long ago, and for a red gate the first failing test with its file, `flaky` when two gates sharing no pull failed it (`src/checks/gate_result.ts`, as MCP `queue_gates` reads them); a repository with none kept says `no gates yet`, and an agent reader sees public repositories only.
472472 - The flow panel on `/boards` has a Last gates card: the newest ten merge-queue gates, each with its pulls linked, green, red or cut off, and the first failing test with its first error line (or the reason a gate was cut off). A gate carrying a sealed pull shows `a sealed pull` and the verdict alone; a non-admin reader sees public repositories only (`src/web/pages/boards-gates.ts`, the same `recentGates` read as MCP `queue_gates`).
473473 - The flow panel on `/boards` and the terminal Flow dialog say how the open crew pulls sort into piles (red, conflicted, changes asked, approved and waiting, awaiting review), each pull counted once in the first pile it fits (`src/flow/pile.ts`).
474 - On the terminal's Asked screen, Enter on a thought opens a card of what became of it, as MCP `thought` answers: the issue it placed (Enter opens it), "kept as a note" or "not placed yet", and the thoughts it repeats or takes back and the later ones that did the same to it. A repository you cannot read is named as nothing, and a thought that is not yours or is gone says "No such thought". The card is a snapshot; `r` re-reads it (`src/tui/screens/asked.ts`).
474475 - The off-box snapshot mirror is set from three doors with one set of rules (`src/actions/mirror.ts`): Settings → Backups, MCP `mirror_set` (`set` or `forget`), and the terminal's Resilience screen (a forge admin who is a person; an admin agent only reads). The screen shows `Mirror: <place>` or `Mirror: none.`, `m` seals it with five prompts (endpoint, bucket, region, access key, secret key; the keys are masked as typed) where a blank keeps what was sealed, and `f` forgets it after a confirm. `t` tests where snapshots go, as MCP `backups_test` and the Backups page's Test button do: the sealed destination, otherwise the environment's, said as `Reached …: N objects listed.` or `Could not list …: <reason>` in the page's words; with none set it says so and calls nobody. A key is never read back or drawn.
475476 - [`docs/PLANNING.md`](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew.
476477 - [`docs/FLEET.md`](docs/FLEET.md) — one change across many repositories, runs or issues: a query, a dry-run plan of every row before → after, a receipt, and undo.
src/tui/screens/asked.ts +112 −2
@@ -5,15 +5,24 @@
55 * neither, a viewer sees only their own thoughts, and a placement in a
66 * repository the viewer cannot read says so and names nothing of it.
77 * 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.
8 * signed-out face; `askedLines` still returns nothing for a guest. Read-only;
9 * `enter` on an ask hands over its web address, on a thought opens its card
10 * (#7473): where it went and what it repeats or takes back, as MCP `thought`
11 * answers. The card is re-read on `r`.
912 * `s` cycles unshipped → flagged → all and `o` flips oldest/newest, with MCP
1013 * `asked`'s defaults, words and order (#7303) — not the page's grouping.
1114 */
1215
1316 import type { Database } from "bun:sqlite";
14 import { thoughtNotes, whereItWent } from "../../actions/ask_state_words.ts";
17 import { NOT_PLACED_YET, thoughtNotes, whereItWent } from "../../actions/ask_state_words.ts";
1518 import { askedIn, askedRepos, outcomeText, type Asked, type AskedOrder, type AskedShow } from "../../core/asked.ts";
19 import { getIssue } from "../../core/issues.ts";
20 import { mayView } from "../../core/sealed.ts";
21 import { placementOf } from "../../core/placements.ts";
22 import { canRead, getRepo, type Repo } from "../../core/repos.ts";
23 import { linksFrom, linksTo } from "../../core/thought_links.ts";
1624 import { thoughtStream, type StreamThought } from "../../core/thought_stream.ts";
25 import { thoughtOf } from "../../core/thoughts.ts";
1726 import type { User } from "../../core/users.ts";
1827 import type { Ctx, Screen } from "../app/app.ts";
1928 import { binding } from "../app/keys.ts";
@@ -23,6 +32,8 @@ import { emptyState, type Tone } from "../ui/widgets.ts";
2332 import { style } from "../term/style.ts";
2433 import { truncate } from "../term/width.ts";
2534 import { ListState, drawList, hitList, metaOf, openOnWeb, relTime, type ListRow } from "./common.ts";
35 import { issueScreen } from "./issue.ts";
36 import { local } from "./where.ts";
2637
2738 /** One line of plain text: a typed thought may carry newlines or escapes the terminal must not obey. */
2839 const plain = (text: string): string =>
@@ -32,6 +43,10 @@ const plain = (text: string): string =>
3243 export interface AskedLine extends ListRow {
3344 /** The web path `enter` opens; absent for a thought. */
3445 readonly path?: string;
46 /** The thought `enter` opens a card for. */
47 readonly thoughtId?: string;
48 /** The issue `enter` opens, on a card line: only ever a repository the viewer can read. */
49 readonly issue?: { readonly repo: Repo; readonly number: number };
3550 }
3651
3752 const THOUGHT_WORD = {
@@ -54,6 +69,7 @@ function thoughtLine(t: StreamThought): AskedLine {
5469 const where = whereItWent(t).map((x) => `${x.lead}${x.text}`);
5570 const notes = thoughtNotes(t).map((n) => `${n.text} ${relTime(n.at)}`);
5671 return {
72 thoughtId: t.id,
5773 title: plain(t.text),
5874 ...toneOf(t.flagged, t.state),
5975 hollow: t.state === "unplaced" || t.state === "superseded",
@@ -73,6 +89,93 @@ function askLine(a: Asked): AskedLine {
7389 };
7490 }
7591
92 /** The repository `owner/name` when the viewer may read it: the one place a card decides that. */
93 function readableRepo(db: Database, viewer: User, slug: string): Repo | undefined {
94 const [owner, name, ...rest] = slug.split("/");
95 const r = owner && name && rest.length === 0 ? getRepo(db, owner, name) : undefined;
96 return r && canRead(db, r, viewer) ? r : undefined;
97 }
98
99 const quoted = (text: string): string => `\u201c${plain(text)}\u201d`;
100
101 /**
102 * What became of one thought, as MCP `thought` answers: one sentence a line.
103 * Not the viewer's, or not there, is `undefined` alike.
104 */
105 export function thoughtCard(db: Database, viewer: User | undefined, id: string): AskedLine[] | undefined {
106 if (viewer === undefined) return undefined;
107 const t = thoughtOf(db, viewer.id, id);
108 if (t === undefined) return undefined;
109 const out: AskedLine[] = [{ title: quoted(t.text), hollow: true }];
110 const p = placementOf(db, id);
111 if (p === undefined) out.push({ title: `Not placed yet: ${NOT_PLACED_YET}.` });
112 else if (p.placed.length === 0) out.push({ title: "Kept as a note." });
113 for (const x of p?.placed ?? []) {
114 const repo = readableRepo(db, viewer, x.repo);
115 const issue = repo === undefined ? undefined : getIssue(db, repo.id, x.number);
116 // A sealed issue in a readable repository is no more there than an unreadable repository.
117 if (repo === undefined || issue === undefined || !mayView(db, repo, issue, viewer)) {
118 out.push({ title: `It ${x.action} something in a repository you cannot read.` });
119 continue;
120 }
121 out.push({
122 title: `It ${x.action} ${x.repo} #${x.number}: ${plain(issue.title)}.`,
123 issue: { repo, number: x.number },
124 });
125 }
126 const refs = (ids: readonly string[], verb: string): void => {
127 for (const other of ids) {
128 const o = thoughtOf(db, viewer.id, other);
129 out.push({ title: o ? `${verb} ${quoted(o.text)}.` : `${verb} a thought that is gone.` });
130 }
131 };
132 const from = linksFrom(db, id);
133 const to = linksTo(db, id);
134 refs(from.repeats, "It repeats");
135 refs(from.supersedes, "It takes back");
136 refs(to.repeatedBy, "Repeated later by");
137 refs(to.supersededBy === null ? [] : [to.supersededBy], "Taken back later by");
138 return out;
139 }
140
141 function cardScreen(f: Session, ctx: Ctx, id: string, first: AskedLine[]): Screen {
142 const state = new ListState();
143 let rows = first;
144 const open = (index: number): void => {
145 const x = rows[index]?.issue;
146 if (x !== undefined) ctx.push(issueScreen(f, ctx, local(f, x.repo), x.number));
147 };
148 return {
149 title: "Thought",
150 keys: [binding(["j", "k"], "move"), binding(["enter"], "open issue (on a line that has one)"), binding(["r"], "refresh")],
151 render(area, s) {
152 drawList(s, area, ctx.theme.c, state, rows);
153 hitList(ctx, area, state, rows.length, { open });
154 },
155 key(e) {
156 if (state.key(e, rows.length)) return true;
157 if (e.key === "enter") {
158 open(state.index);
159 return true;
160 }
161 if (e.key === "r") {
162 const again = thoughtCard(f.db, f.user, id);
163 if (again === undefined) {
164 // Gone since it was drawn: stale rows must not pass for current ones.
165 ctx.toast("No such thought", "attn");
166 ctx.pop();
167 return true;
168 }
169 rows = again;
170 state.clamp(rows.length);
171 ctx.invalidate();
172 return true;
173 }
174 return false;
175 },
176 };
177 }
178
76179 /** What MCP `asked` takes, and its defaults. */
77180 export interface AskedView {
78181 readonly show: AskedShow;
@@ -113,6 +216,13 @@ export function askedScreen(f: Session, ctx: Ctx): Screen {
113216 let view = DEFAULT_VIEW;
114217 const open = (index: number): void => {
115218 const line = lines[index];
219 if (line?.thoughtId !== undefined) {
220 // Not the viewer's, or gone: nothing exists, as MCP answers.
221 const card = thoughtCard(f.db, f.user, line.thoughtId);
222 if (card === undefined) ctx.toast("No such thought", "attn");
223 else ctx.push(cardScreen(f, ctx, line.thoughtId, card));
224 return;
225 }
116226 if (line?.path !== undefined) openOnWeb(f, ctx, line.path, "That ask");
117227 };
118228 const read = (): void => {
test/tui-thought-where.test.ts added +210
@@ -0,0 +1,210 @@
1 /** Enter on a thought in the terminal's asked screen says where it went (#7473). */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createIssue } from "../src/core/issues.ts";
9 import { savePlacement } from "../src/core/placements.ts";
10 import { linkThoughts } from "../src/core/thought_links.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { askedLines, askedScreen, thoughtCard } from "../src/tui/screens/asked.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { promote } from "./helpers/members.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { settleApp } from "./helpers/tui.ts";
18
19 quietLogs();
20
21 let dir: string;
22 let app: App;
23 let alice: User;
24 let bob: User;
25
26 const thought = (who: User, id: string, text: string, at: string) =>
27 app.db
28 .query("INSERT INTO thought (id, author, text, source, received_at) VALUES (?1, ?2, ?3, 'composer', ?4)")
29 .run(id, who.id, text, at);
30
31 beforeAll(async () => {
32 dir = await mkdtemp(join(tmpdir(), "forge-tui-thought-"));
33 app = createApp({ dataDir: dir, port: 0 });
34 const mk = async (username: string, first = false) => {
35 const r = await createUser(app.db, {
36 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
37 password: "a-good-passphrase", adminIfFirst: first,
38 });
39 if (!r.ok) throw new Error(r.error);
40 promote(app.db, r.value);
41 return r.value;
42 };
43 await mk("carol", true);
44 alice = await mk("alice");
45 bob = await mk("bob");
46 const lobby = await createRepo(app, alice, { name: "lobby", visibility: "public" });
47 if (!lobby.ok) throw new Error(String(lobby.error));
48 const i = createIssue(app.db, { repoId: lobby.value.id, authorId: alice.id, title: "Louder lobby" });
49 if (!i.ok) throw new Error(i.error);
50 thought(alice, "t1", "make the lobby louder", "2026-01-01T00:00:00.000Z");
51 thought(alice, "t2", "louder lobby please", "2026-01-02T00:00:00.000Z");
52 thought(bob, "t3", "bob's idea", "2026-01-03T00:00:00.000Z");
53 const vault = await createRepo(app, bob, { name: "vault", visibility: "private" });
54 if (!vault.ok) throw new Error(String(vault.error));
55 const secret = createIssue(app.db, { repoId: vault.value.id, authorId: bob.id, title: "Hidden vault door" });
56 if (!secret.ok) throw new Error(secret.error);
57 thought(alice, "t5", "hide the vault door", "2026-01-05T00:00:00.000Z");
58 savePlacement(app.db, {
59 thoughtId: "t5", kind: "work", asked: false, decidedBy: "rule", reply: "ok",
60 placed: [{ action: "opened", repo: "bob/vault", number: secret.value.number }],
61 placedAt: "2026-01-05T00:01:00.000Z",
62 });
63 thought(alice, "t6", "and again", "2026-01-06T00:00:00.000Z");
64 linkThoughts(app.db, "t6", "t3", "repeats");
65 linkThoughts(app.db, "t6", "t3", "supersedes");
66 savePlacement(app.db, {
67 thoughtId: "t1", kind: "work", asked: false, decidedBy: "rule", reply: "ok",
68 placed: [{ action: "opened", repo: "alice/lobby", number: i.value.number }],
69 placedAt: "2026-01-01T00:01:00.000Z",
70 });
71 linkThoughts(app.db, "t2", "t1", "repeats");
72 });
73 afterAll(async () => {
74 app.db.close();
75 await rm(dir, { recursive: true, force: true });
76 });
77
78 const titles = (id: string, who: User) => thoughtCard(app.db, who, id)?.map((l) => l.title);
79
80 describe("a thought's card", () => {
81 test("the placed one names its issue and the thought that repeated it", () => {
82 const lines = titles("t1", alice)!.join("\n");
83 expect(lines).toContain("It opened alice/lobby #1: Louder lobby.");
84 expect(lines).toContain("Repeated later by \u201clouder lobby please\u201d.");
85 expect(thoughtCard(app.db, alice, "t1")!.some((l) => l.issue?.repo.name === "lobby")).toBe(true);
86 });
87
88 test("the repeating one says what it repeats and that it is not placed yet", () => {
89 const lines = titles("t2", alice)!.join("\n");
90 expect(lines).toContain("Not placed yet");
91 expect(lines).toContain("It repeats \u201cmake the lobby louder\u201d.");
92 });
93
94 test("a placement with nothing on the map is kept as a note", () => {
95 thought(alice, "t4", "we value small things", "2026-01-04T00:00:00.000Z");
96 savePlacement(app.db, {
97 thoughtId: "t4", kind: "vision", asked: false, decidedBy: "rule", reply: "noted", placed: [],
98 placedAt: "2026-01-04T00:01:00.000Z",
99 });
100 expect(titles("t4", alice)).toContain("Kept as a note.");
101 });
102
103 test("a placement in a repository the viewer cannot read names nothing and opens nothing", () => {
104 const card = thoughtCard(app.db, alice, "t5")!;
105 expect(card.map((l) => l.title)).toContain("It opened something in a repository you cannot read.");
106 const all = JSON.stringify(card);
107 for (const leak of ["bob/vault", "Hidden", "#1"]) expect(all).not.toContain(leak);
108 expect(card.some((l) => l.issue !== undefined)).toBe(false);
109 });
110
111 test("a sealed issue in a readable repository names nothing and opens nothing", async () => {
112 // A public repository of bob's: alice reads it but does not write it.
113 const open = await createRepo(app, bob, { name: "open", visibility: "public" });
114 if (!open.ok) throw new Error(String(open.error));
115 const sealed = createIssue(app.db, { repoId: open.value.id, authorId: bob.id, title: "Sealed zero-day", sealed: true });
116 if (!sealed.ok) throw new Error(sealed.error);
117 thought(alice, "t8", "what about the zero-day", "2025-12-31T00:00:00.000Z");
118 savePlacement(app.db, {
119 thoughtId: "t8", kind: "work", asked: false, decidedBy: "rule", reply: "ok",
120 placed: [{ action: "opened", repo: "bob/open", number: sealed.value.number }],
121 placedAt: "2026-01-08T00:01:00.000Z",
122 });
123 const card = thoughtCard(app.db, alice, "t8")!;
124 expect(card.map((l) => l.title)).toContain("It opened something in a repository you cannot read.");
125 const all = JSON.stringify(card);
126 expect(all).not.toContain("Sealed zero-day");
127 expect(all).not.toContain(`#${sealed.value.number}`);
128 expect(card.some((l) => l.issue !== undefined)).toBe(false);
129 });
130
131 test("a link to another person's thought reads as gone and quotes nothing of it", () => {
132 const card = thoughtCard(app.db, alice, "t6")!;
133 const titles = card.map((l) => l.title);
134 expect(titles).toContain("It repeats a thought that is gone.");
135 expect(titles).toContain("It takes back a thought that is gone.");
136 expect(JSON.stringify(card)).not.toContain("bob's idea");
137 });
138
139 test("someone else's thought and a missing one are alike nothing", () => {
140 expect(thoughtCard(app.db, alice, "t3")).toBeUndefined();
141 expect(thoughtCard(app.db, alice, "nope")).toBeUndefined();
142 expect(thoughtCard(app.db, undefined, "t1")).toBeUndefined();
143 });
144
145 // A thought is never deleted, so a test that needs it gone lifts the guard for one statement.
146 const vanish = (id: string) => {
147 app.db.run("DROP TRIGGER thought_no_delete");
148 app.db.query("DELETE FROM thought WHERE id = ?1").run(id);
149 app.db.run("CREATE TRIGGER thought_no_delete BEFORE DELETE ON thought BEGIN SELECT RAISE(ABORT, 'a thought is never deleted'); END");
150 };
151
152 const open = async (text: string) => {
153 const terminal = headlessTerminal(120, 30);
154 const { app: tui, session } = createForgeApp(app, { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: alice }, { terminal, exitHooks: false });
155 tui.start();
156 tui.ctx.push(askedScreen(session, tui.ctx));
157 await settleApp(tui);
158 // Select the row by its text, not by its place in the list.
159 const at = askedLines(app.db, alice).thoughts.findIndex((t) => t.title === text);
160 if (at < 0) throw new Error(`no thought ${text}`);
161 terminal.feed("j".repeat(at));
162 await settleApp(tui);
163 return { tui, terminal };
164 };
165
166 test("enter on a thought in the screen opens its card", async () => {
167 const { tui, terminal } = await open("make the lobby louder");
168 terminal.feed("\r");
169 await settleApp(tui);
170 const text = tui.screen.toText();
171 tui.stop?.();
172 expect(text).toContain("It opened alice/lobby #1");
173 });
174
175 test("enter on a thought that has gone toasts and opens no card", async () => {
176 thought(alice, "t9", "soon gone", "2026-01-09T00:00:00.000Z");
177 const { tui, terminal } = await open("soon gone");
178 vanish("t9");
179 terminal.feed("\r");
180 await settleApp(tui);
181 const text = tui.screen.toText();
182 tui.stop?.();
183 expect(text).toContain("No such thought");
184 expect(text).toContain("Asked");
185 expect(text).not.toContain("Not placed yet");
186 });
187
188 test("r on a card reads again, and a card whose thought has gone toasts and pops", async () => {
189 thought(alice, "t10", "to be placed", "2026-01-10T00:00:00.000Z");
190 const { tui, terminal } = await open("to be placed");
191 terminal.feed("\r");
192 await settleApp(tui);
193 expect(tui.screen.toText()).toContain("Not placed yet");
194 savePlacement(app.db, {
195 thoughtId: "t10", kind: "vision", asked: false, decidedBy: "rule", reply: "noted", placed: [],
196 placedAt: "2026-01-10T00:01:00.000Z",
197 });
198 terminal.feed("r");
199 await settleApp(tui);
200 expect(tui.screen.toText()).toContain("Kept as a note.");
201 vanish("t10");
202 terminal.feed("r");
203 await settleApp(tui);
204 const text = tui.screen.toText();
205 tui.stop?.();
206 expect(text).toContain("No such thought");
207 expect(text).not.toContain("Kept as a note.");
208 expect(text).toContain("Asked");
209 });
210 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs