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

The terminal's repository screen shows the app's vision: its purpose and the pillars in order, from docs/VISION.md, as MCP `vision` does #7279

Merged opened this

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

Merged

torque/forge:torque/7272-the-terminal-s-repository-screen-shows-t → main 4 commits fork 2 threads · 2 unresolved

  • Merged. torque/7272-the-terminal-s-repository-screen-shows-t was merged into main as 28850b4.

Review

Sign in to review this change.
8 files changed +289 −10
docs/APPS.md +4 −1
@@ -144,7 +144,10 @@ Under the box, four starter sentences are plain links (no script):
144144 screen: *Working now* (while an agent run is live, left out when it is on work
145145 you may not see), *Tell the crew*, *Report a problem* (one text, an issue
146146 titled by its first line, through `openIssueThrough`) and *Make it yours*
147 (the fork screen, for those who cannot write the repository); a pull is
147 (the fork screen, for those who cannot write the repository), and *Vision*
148 (docs/VISION.md on the default branch: the purpose, each pillar numbered,
149 the rules, through the one read MCP `vision` makes; when there is none the
150 terminal says so in one line of its own, where MCP answers an empty `order`); a pull is
148151 commented on from its own screen. Agents have the MCP `tell` and
149152 `make_it_yours` tools and the API's `POST …/issues`,
150153 `…/pulls/:n/comments` and `…/make-it-yours`.
src/core/vision.ts +3
@@ -39,3 +39,6 @@ export function parseVision(md: string): Vision {
3939 .map(strip);
4040 return { purpose: purpose || null, order, rules };
4141 }
42
43 /** What a door says when the default branch has no docs/VISION.md: one line, never an empty screen. */
44 export const NO_VISION_LINE = "No docs/VISION.md on the default branch: nothing says what this repository is for yet.";
src/crew/vision.ts +15 −1
@@ -15,7 +15,7 @@ import { editFiles } from "../actions/contents.ts";
1515 import { pullDir, type Pull } from "../core/pulls.ts";
1616 import type { Repo } from "../core/repos.ts";
1717 import type { User } from "../core/users.ts";
18 import { listTree, readerFor } from "../git/read.ts";
18 import { blobForRender, listTree, readerFor } from "../git/read.ts";
1919 import { repoDir } from "../git/repos.ts";
2020 import { clip } from "../text/clip.ts";
2121
@@ -51,6 +51,20 @@ export function withDirection(current: string | null, line: string): string {
5151 return `${[...lines.slice(0, end), line, ...tail].join("\n")}\n`;
5252 }
5353
54 /** The document's cap: a bigger VISION.md is read as none. */
55 const VISION_MAX_BYTES = 1_000_000;
56
57 /**
58 * docs/VISION.md on the default branch, parsed; null when it is absent, not
59 * text or over the cap. The one read MCP `vision` and the terminal's Vision
60 * screen both make, so the cap and the fallback cannot drift apart.
61 */
62 export async function readDefaultVision(reposRoot: string, repo: Repo): Promise<Vision | null> {
63 const dir = repoDir(reposRoot, repo.ownerName, repo.name);
64 const blob = await blobForRender(dir, repo.defaultBranch, VISION_PATH, VISION_MAX_BYTES);
65 return blob.kind === "text" ? parseVision(new TextDecoder().decode(blob.body)) : null;
66 }
67
5468 async function readVision(app: App, repo: Repo): Promise<string | null> {
5569 return readVisionAt(repoDir(app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch);
5670 }
src/http/mcp/tools/vision.ts +5 −6
@@ -1,8 +1,7 @@
11 import { getPull } from "../../../core/pulls.ts";
2 import { directionParts, previewVision } from "../../../crew/vision.ts";
2 import { directionParts, previewVision, readDefaultVision } from "../../../crew/vision.ts";
33 import { canAdmin } from "../../../core/repos.ts";
4 import { NO_VISION, parseVision } from "../../../core/vision.ts";
5 import { blobForRender } from "../../../git/read.ts";
4 import { NO_VISION } from "../../../core/vision.ts";
65 import { runGit } from "../../../git/env.ts";
76 import { repoDir } from "../../../git/repos.ts";
87 import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
@@ -22,9 +21,9 @@ export const tool: Tool = {
2221 const repo = repoArg(ctx, user, args);
2322 if (isAnswer(repo)) return repo;
2423 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
25 const blob = await blobForRender(dir, repo.defaultBranch, "docs/VISION.md", 1_000_000);
26 const found = blob.kind === "text";
27 const vision = found ? parseVision(new TextDecoder().decode(blob.body)) : NO_VISION;
24 const read = await readDefaultVision(ctx.app.reposRoot, repo);
25 const found = read !== null;
26 const vision = read ?? NO_VISION;
2827 const last = found
2928 ? await runGit(["log", "-1", "--format=%cI", repo.defaultBranch, "--", "docs/VISION.md"], { cwd: dir })
3029 : undefined;
src/tui/screens/vision.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * The Vision command, a repository's (#7272): what the repository is for,
3 * read from docs/VISION.md on its default branch with the same `parseVision`
4 * MCP `vision` answers with — the purpose, then each pillar of the order of
5 * work numbered, then the rules of how it is built. Wrapped to the width at
6 * render, never cut mid-word. A repository without the document says so in
7 * one line (the terminal's own words; MCP `vision` answers with an empty
8 * order). Read-only; offered to, and read for, only someone who may read it.
9 */
10
11 import { canRead, type Repo } from "../../core/repos.ts";
12 import { NO_VISION, NO_VISION_LINE, type Vision } from "../../core/vision.ts";
13 import { readDefaultVision } from "../../crew/vision.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.ts";
16 import type { Command } from "../app/palette.ts";
17 import { BOLD, style } from "../term/style.ts";
18 import { displayWidth, wrap } from "../term/width.ts";
19 import type { Session } from "../session.ts";
20 import { Scroll, drawLines, hitScroll } from "./common.ts";
21
22 /** What a viewer who may not read the repository sees: what the page's 404 says, nothing of the document. */
23 export const NOT_FOUND_LINE = "No such repository, or you may not read it.";
24 const HIDDEN: Vision = { purpose: NOT_FOUND_LINE, order: [], rules: [] };
25
26 export interface VisionLine {
27 readonly text: string;
28 readonly head: boolean;
29 }
30
31 /** `text` wrapped to `width`, the first line led by `lead` and the rest indented under it. */
outdated

Same read as src/http/mcp/tools/vision.ts (blobForRender + 1_000_000 + NO_VISION). Share one function so the cap and fallback cannot drift.

32 function hanging(lead: string, text: string, width: number): string[] {
33 const indent = displayWidth(lead);
34 const pad = " ".repeat(indent);
35 return wrap(text, Math.max(1, width - indent)).map((l, i) => (i === 0 ? lead : pad) + l);
36 }
37
38 /** The screen's lines at `width` columns: purpose, numbered pillars, rules. */
39 export function visionLines(v: Vision, width: number): VisionLine[] {
40 const body = (text: string): VisionLine => ({ text, head: false });
41 const head = (text: string): VisionLine => ({ text, head: true });
42 if (v.purpose === null && v.order.length === 0 && v.rules.length === 0)
43 return wrap(NO_VISION_LINE, width).map(body);
44 const out: VisionLine[] = [];
45 if (v.purpose !== null) out.push(...wrap(v.purpose, width).map(body));
46 if (v.order.length > 0) {
47 if (out.length > 0) out.push(body(""));
48 out.push(head("Order of work"));
49 for (const p of v.order) {
50 const lead = `${p.n}. `;
51 const text = p.text === "" ? p.name : `${p.name}. ${p.text}`;
52 out.push(...hanging(lead, text, width).map(body));
53 }
54 }
55 if (v.rules.length > 0) {
56 if (out.length > 0) out.push(body(""));
57 out.push(head("How we build"));
58 for (const r of v.rules) out.push(...hanging("- ", r, width).map(body));
59 }
60 return out;
61 }
62
63 export function visionScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
64 const scroll = new Scroll();
65 let vision: Vision | undefined;
66 let lines: VisionLine[] = [];
67 return {
68 title: "Vision",
69 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
70 async load() {
71 // Gated here as well as on the command: the screen is exported, and a
72 // viewer who may not read the repository is shown nothing of it.
73 vision = canRead(f.db, repo, f.user) ? ((await readDefaultVision(f.app.reposRoot, repo)) ?? NO_VISION) : HIDDEN;
74 ctx.invalidate();
75 },
76 render(area, s) {
77 const c = ctx.theme.c;
outdated

The screen itself never checks canRead; only visionCommands does. Gate the read here too so a direct push of this screen cannot show a private repository's VISION.md.

78 // One column for the scrollbar `drawLines` keeps, one for breathing room.
79 const width = Math.max(1, area.w - 2);
80 lines = vision === undefined ? [{ text: "Reading…", head: false }] : visionLines(vision, width);
81 const spans = lines.map((l) => [{ text: l.text, style: l.head ? style(c.accent, c.bg, BOLD) : style(c.fg, c.bg) }]);
82 drawLines(s, area, c, scroll, spans);
83 hitScroll(ctx, area, scroll, lines.length);
84 },
85 key(e) {
86 if (scroll.key(e, lines.length)) return true;
87 if (e.key === "esc" || e.key === "q") {
88 ctx.pop();
89 return true;
90 }
91 return false;
92 },
93 };
94 }
95
96 /** The palette's door on a repository screen, for whoever may read it. */
97 export function visionCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
98 if (repo === undefined || !canRead(f.db, repo, f.user)) return [];
99 return [
100 {
101 id: "vision",
102 title: `${repo.ownerName}/${repo.name} — Vision`,
103 hint: "what it is for, the order of work",
104 run: () => ctx.push(visionScreen(f, ctx, repo)),
105 },
106 ];
107 }
src/tui/screens/workspace.ts +2
@@ -122,6 +122,7 @@ import { reportsCommands } from "./reports.ts";
122122 import { tellCommands } from "./tell.ts";
123123 import { workingCommands } from "./working.ts";
124124 import { yoursCommands } from "./yours.ts";
125 import { visionCommands } from "./vision.ts";
125126 import { appEnvCommands } from "./appenv.ts";
126127 import { externalEditor, editorHint, hasExternalEditor } from "./external.ts";
127128 import { finderScreen } from "./finder.ts";
@@ -1286,6 +1287,7 @@ export function workspaceScreen(
12861287 ...reportsCommands(f, ctx, repo),
12871288 ...noteCommands(f, ctx, () => screen, repo),
12881289 ...yoursCommands(f, ctx, repo),
1290 ...visionCommands(f, ctx, repo),
12891291 ...appEnvCommands(f, ctx, repo),
12901292 ],
12911293 get title() {
test/tui-notes.test.ts +3 −2
@@ -81,8 +81,9 @@ afterAll(async () => {
8181
8282 describe("notes from the app, in the terminal", () => {
8383 test("only an app's repository has the rows", async () => {
84 expect(await palette(bob, notes, [":"])).toContain("alice/notes — Tell the builders");
85 expect(await palette(bob, plain, [":"])).not.toContain("Tell the builders");
84 // Filtered, so the row's presence does not depend on how many others the palette lists.
85 expect(await palette(bob, notes, [":", "Tell the"])).toContain("alice/notes — Tell the builders");
86 expect(await palette(bob, plain, [":", "Tell the"])).not.toContain("Tell the builders");
8687 });
8788
8889 test("a note files a from-app issue as the person", async () => {
test/tui-vision.test.ts added +150
@@ -0,0 +1,150 @@
1 /** The terminal's Vision screen (#7272): the purpose, the pillars in order and the rules from docs/VISION.md; one line without it; only for readers. */
2 import { afterAll, beforeAll, 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 { commitToBranch, createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import type { Repo } from "../src/core/repos.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { NO_VISION, NO_VISION_LINE } from "../src/core/vision.ts";
11 import { readDefaultVision } from "../src/crew/vision.ts";
12 import { createForgeApp, Session } from "../src/tui/forge.ts";
13 import type { Ctx } from "../src/tui/app/app.ts";
14 import { repoScreen } from "../src/tui/screens/repo.ts";
15 import { local } from "../src/tui/screens/where.ts";
16 import { NOT_FOUND_LINE, visionCommands, visionLines, visionScreen } from "../src/tui/screens/vision.ts";
17 import { displayWidth } from "../src/tui/term/width.ts";
18 import { headlessTerminal } from "../src/tui/term/terminal.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { settleApp } from "./helpers/tui.ts";
21
22 quietLogs();
23
24 const VISION = `# Lab
25
26 A forge where agents build and people decide what lands.
27
28 ## Order of work
29
30 1. **Story.** The purpose is the first thing a door says.
31 2. **Reach.** Every function is reachable from the feed in two taps, through the MCP, and by a test that proves it.
32 3. **Polish.** Small and quiet.
33
34 ## How we build
35
36 - Small modules named for one concept.
37 - Every input validated at the boundary.
38 `;
39
40 let dir: string;
41 let app: App;
42 let withVision: Repo;
43 let without: Repo;
44 let secret: Repo;
45 const guest = { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test" };
46
47 beforeAll(async () => {
48 dir = await mkdtemp(join(tmpdir(), "forge-tui-vision-"));
49 app = createApp({ dataDir: dir, port: 0 });
50 const made = await createUser(app.db, {
51 origin: "seed",
52 trusted: true,
53 username: "alice",
54 email: "[email protected]",
55 password: "a-good-passphrase",
56 adminIfFirst: true,
57 });
58 if (!made.ok) throw new Error(made.error);
59 const mk = async (name: string, visibility: "public" | "private", files: { path: string; content: string }[]) => {
60 const repo = await createRepo(app, made.value, { name, visibility });
61 if (!repo.ok) throw new Error(JSON.stringify(repo.error));
62 const done = await commitToBranch(app, made.value, repo.value, { message: "start", files });
63 if (!done.ok) throw new Error(JSON.stringify(done.error));
64 return repo.value;
65 };
66 withVision = await mk("lab", "public", [{ path: "docs/VISION.md", content: VISION }]);
67 without = await mk("plain", "public", [{ path: "README.md", content: "# plain\n" }]);
68 secret = await mk("secret", "private", [{ path: "docs/VISION.md", content: VISION }]);
69 }, 60_000);
70
71 afterAll(async () => {
72 app.close();
73 await rm(dir, { recursive: true, force: true });
74 });
75
76 async function screenText(repo: Repo, cols = 100): Promise<string> {
77 const terminal = headlessTerminal(cols, 30);
78 const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false });
79 tui.start();
80 tui.ctx.push(visionScreen(session, tui.ctx, repo));
81 await settleApp(tui);
82 const text = tui.screen.toText();
83 tui.stop?.();
84 return text;
85 }
86
87 test("a repository with a VISION.md shows its purpose, then its pillars in order, then its rules", async () => {
88 const text = await screenText(withVision);
89 const at = (s: string) => text.indexOf(s);
90 expect(at("A forge where agents build")).toBeGreaterThan(-1);
91 expect(at("1. Story. The purpose is the first thing a door says.")).toBeGreaterThan(at("A forge where agents build"));
92 expect(at("2. Reach.")).toBeGreaterThan(at("1. Story."));
93 expect(at("3. Polish. Small and quiet.")).toBeGreaterThan(at("2. Reach."));
94 expect(at("- Small modules named for one concept.")).toBeGreaterThan(at("3. Polish."));
95 expect(at("- Every input validated at the boundary.")).toBeGreaterThan(at("- Small modules"));
96 });
97
98 test("a repository without the document says so in one line", async () => {
99 const lines = visionLines((await readDefaultVision(app.reposRoot, without)) ?? NO_VISION, 200);
100 expect(lines.map((l) => l.text)).toEqual([NO_VISION_LINE]);
101 expect(await screenText(without, 120)).toContain(NO_VISION_LINE);
102 expect(await readDefaultVision(app.reposRoot, without)).toBeNull();
103 });
104
105 test("the screen opened directly on a private repository shows a stranger none of its VISION.md", async () => {
106 const text = await screenText(secret, 120);
107 expect(text).toContain(NOT_FOUND_LINE);
108 expect(text).not.toContain("A forge where agents build");
109 expect(text).not.toContain("Story");
110 expect(text).not.toContain("Reach");
111 expect(text).not.toContain("Small modules");
112 expect(text).not.toContain(NO_VISION_LINE);
113 });
114
115 test("long text wraps to the width and no word is cut", async () => {
116 const vision = (await readDefaultVision(app.reposRoot, withVision))!;
117 const width = 30;
118 const lines = visionLines(vision, width);
119 for (const l of lines) expect(displayWidth(l.text)).toBeLessThanOrEqual(width);
120 const words = (s: string) => s.split(/\s+/).filter((w) => w !== "");
121 const wide = visionLines(vision, 1000).flatMap((l) => words(l.text));
122 expect(lines.flatMap((l) => words(l.text))).toEqual(wide);
123 // A pillar's continuation lines hang under its text, not under its number.
124 const reach = lines.findIndex((l) => l.text.startsWith("2. Reach."));
125 expect(lines[reach + 1]!.text.startsWith(" ")).toBe(true);
126 });
127
128 test("the command is offered to a reader and not on a private repository the viewer cannot read", () => {
129 const ctx = {} as Ctx;
130 const stranger = new Session(app, guest);
131 expect(visionCommands(stranger, ctx, withVision).map((c) => c.id)).toEqual(["vision"]);
132 expect(visionCommands(stranger, ctx, secret)).toEqual([]);
133 expect(visionCommands(stranger, ctx, undefined)).toEqual([]);
134 });
135
136 test("the repository screen's commands carry Vision, and running it opens the screen", async () => {
137 const terminal = headlessTerminal(100, 30);
138 const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false });
139 tui.start();
140 const screen = repoScreen(session, tui.ctx, local(session, withVision));
141 tui.ctx.push(screen);
142 await settleApp(tui);
143 const command = screen.commands?.().find((c) => c.id === "vision");
144 expect(command?.title).toBe("alice/lab — Vision");
145 command!.run();
146 await settleApp(tui);
147 const text = tui.screen.toText();
148 tui.stop?.();
149 expect(text).toContain("1. Story.");
150 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs