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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7272-the-terminal-s-repository-screen-shows-t → main 4 commits fork 2 threads · 2 unresolved
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): | ||
| 144 | 144 | screen: *Working now* (while an agent run is live, left out when it is on work |
| 145 | 145 | you may not see), *Tell the crew*, *Report a problem* (one text, an issue |
| 146 | 146 | 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 | |
| 148 | 151 | commented on from its own screen. Agents have the MCP `tell` and |
| 149 | 152 | `make_it_yours` tools and the API's `POST …/issues`, |
| 150 | 153 | `…/pulls/:n/comments` and `…/make-it-yours`. |
src/core/vision.ts +3
| @@ -39,3 +39,6 @@ export function parseVision(md: string): Vision { | ||
| 39 | 39 | .map(strip); |
| 40 | 40 | return { purpose: purpose || null, order, rules }; |
| 41 | 41 | } |
| 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"; | ||
| 15 | 15 | import { pullDir, type Pull } from "../core/pulls.ts"; |
| 16 | 16 | import type { Repo } from "../core/repos.ts"; |
| 17 | 17 | import type { User } from "../core/users.ts"; |
| 18 | import { listTree, readerFor } from "../git/read.ts"; | |
| 18 | import { blobForRender, listTree, readerFor } from "../git/read.ts"; | |
| 19 | 19 | import { repoDir } from "../git/repos.ts"; |
| 20 | 20 | import { clip } from "../text/clip.ts"; |
| 21 | 21 | |
| @@ -51,6 +51,20 @@ export function withDirection(current: string | null, line: string): string { | ||
| 51 | 51 | return `${[...lines.slice(0, end), line, ...tail].join("\n")}\n`; |
| 52 | 52 | } |
| 53 | 53 | |
| 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 | ||
| 54 | 68 | async function readVision(app: App, repo: Repo): Promise<string | null> { |
| 55 | 69 | return readVisionAt(repoDir(app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch); |
| 56 | 70 | } |
src/http/mcp/tools/vision.ts +5 −6
| @@ -1,8 +1,7 @@ | ||
| 1 | 1 | import { getPull } from "../../../core/pulls.ts"; |
| 2 | import { directionParts, previewVision } from "../../../crew/vision.ts"; | |
| 2 | import { directionParts, previewVision, readDefaultVision } from "../../../crew/vision.ts"; | |
| 3 | 3 | 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"; | |
| 6 | 5 | import { runGit } from "../../../git/env.ts"; |
| 7 | 6 | import { repoDir } from "../../../git/repos.ts"; |
| 8 | 7 | import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| @@ -22,9 +21,9 @@ export const tool: Tool = { | ||
| 22 | 21 | const repo = repoArg(ctx, user, args); |
| 23 | 22 | if (isAnswer(repo)) return repo; |
| 24 | 23 | 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; | |
| 28 | 27 | const last = found |
| 29 | 28 | ? await runGit(["log", "-1", "--format=%cI", repo.defaultBranch, "--", "docs/VISION.md"], { cwd: dir }) |
| 30 | 29 | : 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. */ | |
| 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"; | ||
| 122 | 122 | import { tellCommands } from "./tell.ts"; |
| 123 | 123 | import { workingCommands } from "./working.ts"; |
| 124 | 124 | import { yoursCommands } from "./yours.ts"; |
| 125 | import { visionCommands } from "./vision.ts"; | |
| 125 | 126 | import { appEnvCommands } from "./appenv.ts"; |
| 126 | 127 | import { externalEditor, editorHint, hasExternalEditor } from "./external.ts"; |
| 127 | 128 | import { finderScreen } from "./finder.ts"; |
| @@ -1286,6 +1287,7 @@ export function workspaceScreen( | ||
| 1286 | 1287 | ...reportsCommands(f, ctx, repo), |
| 1287 | 1288 | ...noteCommands(f, ctx, () => screen, repo), |
| 1288 | 1289 | ...yoursCommands(f, ctx, repo), |
| 1290 | ...visionCommands(f, ctx, repo), | |
| 1289 | 1291 | ...appEnvCommands(f, ctx, repo), |
| 1290 | 1292 | ], |
| 1291 | 1293 | get title() { |
test/tui-notes.test.ts +3 −2
| @@ -81,8 +81,9 @@ afterAll(async () => { | ||
| 81 | 81 | |
| 82 | 82 | describe("notes from the app, in the terminal", () => { |
| 83 | 83 | 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"); | |
| 86 | 87 | }); |
| 87 | 88 | |
| 88 | 89 | 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 | }); |
new above
offline
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.