Public: anyone can read this; the crew can change it.
The terminal's Vision screen shows the repository's direction under its order of work, as MCP `vision` answers it, and Enter opens the direction issue #7484
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7476-the-terminal-s-vision-screen-shows-the-r → main 3 commits fork 2 threads · 2 unresolved
3 files
changed +211
−10
docs/APPS.md +4 −1
| @@ -152,7 +152,10 @@ Under the box, four starter sentences are plain links (no script): | ||
| 152 | 152 | (the fork screen, for those who cannot write the repository), and *Vision* |
| 153 | 153 | (docs/VISION.md on the default branch: the purpose, each pillar numbered, |
| 154 | 154 | the rules, through the one read MCP `vision` makes; when there is none the |
| 155 | terminal says so in one line of its own, where MCP answers an empty `order`); a pull is | |
| 155 | terminal says so in one line of its own, where MCP answers an empty `order`; | |
| 156 | admins also see, under the rules, the open direction issue (`#N title`) and | |
| 157 | its parts, read as MCP `vision` reads `directionParts`, and Enter opens the | |
| 158 | issue; with no open direction issue nothing extra is drawn); a pull is | |
| 156 | 159 | commented on from its own screen. Agents have the MCP `tell` and |
| 157 | 160 | `make_it_yours` tools and the API's `POST …/issues`, |
| 158 | 161 | `…/pulls/:n/comments` and `…/make-it-yours`. |
src/tui/screens/vision.ts +46 −9
| @@ -5,12 +5,16 @@ | ||
| 5 | 5 | * work numbered, then the rules of how it is built. Wrapped to the width at |
| 6 | 6 | * render, never cut mid-word. A repository without the document says so in |
| 7 | 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. | |
| 8 | * order). Under the rules, for whoever may read the repository's settings, | |
| 9 | * the open direction issue (`#N title`) and its parts, as MCP `vision` | |
| 10 | * answers `directionParts`; Enter opens the issue. Read-only; offered to, | |
| 11 | * and read for, only someone who may read it. | |
| 9 | 12 | */ |
| 10 | 13 | |
| 11 | import { canRead, type Repo } from "../../core/repos.ts"; | |
| 14 | import { canAdmin, canRead, getRepoById, type Repo } from "../../core/repos.ts"; | |
| 15 | import { getIssueById } from "../../core/issues.ts"; | |
| 12 | 16 | import { NO_VISION, NO_VISION_LINE, type Vision } from "../../core/vision.ts"; |
| 13 | import { readDefaultVision } from "../../crew/vision.ts"; | |
| 17 | import { directionParts, readDefaultVision, type DirectionPart } from "../../crew/vision.ts"; | |
| 14 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 19 | import { binding } from "../app/keys.ts"; |
| 16 | 20 | import type { Command } from "../app/palette.ts"; |
| @@ -18,11 +22,20 @@ import { BOLD, style } from "../term/style.ts"; | ||
| 18 | 22 | import { displayWidth, wrap } from "../term/width.ts"; |
| 19 | 23 | import type { Session } from "../session.ts"; |
| 20 | 24 | import { Scroll, drawLines, hitScroll } from "./common.ts"; |
| 25 | import { issueScreen } from "./issue.ts"; | |
| 26 | import { local } from "./where.ts"; | |
| 21 | 27 | |
| 22 | 28 | /** What a viewer who may not read the repository sees: what the page's 404 says, nothing of the document. */ |
| 23 | 29 | export const NOT_FOUND_LINE = "No such repository, or you may not read it."; |
| 24 | 30 | const HIDDEN: Vision = { purpose: NOT_FOUND_LINE, order: [], rules: [] }; |
| 25 | 31 | |
| 32 | /** The open direction issue and the parts of the direction, as MCP `vision` reads them. */ | |
| 33 | export interface Direction { | |
| 34 | readonly number: number; | |
| 35 | readonly title: string; | |
| 36 | readonly parts: readonly DirectionPart[]; | |
| 37 | } | |
| 38 | ||
| 26 | 39 | export interface VisionLine { |
| 27 | 40 | readonly text: string; |
| 28 | 41 | readonly head: boolean; |
| @@ -36,12 +49,12 @@ function hanging(lead: string, text: string, width: number): string[] { | ||
| 36 | 49 | } |
| 37 | 50 | |
| 38 | 51 | /** The screen's lines at `width` columns: purpose, numbered pillars, rules. */ |
| 39 | export function visionLines(v: Vision, width: number): VisionLine[] { | |
| 52 | export function visionLines(v: Vision, width: number, direction?: Direction): VisionLine[] { | |
| 40 | 53 | const body = (text: string): VisionLine => ({ text, head: false }); |
| 41 | 54 | 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[] = []; | |
| 55 | const empty = v.purpose === null && v.order.length === 0 && v.rules.length === 0; | |
| 56 | if (empty && direction === undefined) return wrap(NO_VISION_LINE, width).map(body); | |
| 57 | const out: VisionLine[] = empty ? wrap(NO_VISION_LINE, width).map(body) : []; | |
| 45 | 58 | if (v.purpose !== null) out.push(...wrap(v.purpose, width).map(body)); |
| 46 | 59 | if (v.order.length > 0) { |
| 47 | 60 | if (out.length > 0) out.push(body("")); |
| @@ -57,33 +70,57 @@ export function visionLines(v: Vision, width: number): VisionLine[] { | ||
| 57 | 70 | out.push(head("How we build")); |
| 58 | 71 | for (const r of v.rules) out.push(...hanging("- ", r, width).map(body)); |
| 59 | 72 | } |
| 73 | if (direction !== undefined) { | |
| 74 | if (out.length > 0) out.push(body("")); | |
| 75 | out.push(head("Direction")); | |
| 76 | out.push(...hanging(`#${direction.number} `, direction.title, width).map(body)); | |
| 77 | for (const p of direction.parts) { | |
| 78 | out.push(body("")); | |
| 79 | out.push(...wrap(p.text, width).map(body)); | |
| 80 | } | |
| 81 | } | |
| 60 | 82 | return out; |
| 61 | 83 | } |
| 62 | 84 | |
| 85 | /** The open direction issue and its parts, for someone who may read the settings; undefined otherwise. */ | |
| 86 | function readDirection(f: Session, repo: Repo): Direction | undefined { | |
| 87 | const fresh = getRepoById(f.db, repo.id) ?? repo; | |
| 88 | if (!canAdmin(f.db, fresh, f.user) || fresh.directionIssueId === null) return undefined; | |
| 89 | const issue = getIssueById(f.db, fresh.directionIssueId); | |
| 90 | if (!issue || issue.state !== "open") return undefined; | |
| 91 | return { number: issue.number, title: issue.title, parts: directionParts(fresh.autonomyDirection) }; | |
| 92 | } | |
| 93 | ||
| 63 | 94 | export function visionScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 64 | 95 | const scroll = new Scroll(); |
| 65 | 96 | let vision: Vision | undefined; |
| 97 | let direction: Direction | undefined; | |
| 66 | 98 | let lines: VisionLine[] = []; |
| 67 | 99 | return { |
| 68 | 100 | title: "Vision", |
| 69 | keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")], | |
| 101 | keys: [binding(["j", "k"], "scroll"), binding(["enter"], "open direction"), binding(["esc", "q"], "back")], | |
The footer now offers |
||
| 70 | 102 | async load() { |
| 71 | 103 | // Gated here as well as on the command: the screen is exported, and a |
| 72 | 104 | // viewer who may not read the repository is shown nothing of it. |
| 73 | 105 | vision = canRead(f.db, repo, f.user) ? ((await readDefaultVision(f.app.reposRoot, repo)) ?? NO_VISION) : HIDDEN; |
| 106 | direction = vision === HIDDEN ? undefined : readDirection(f, repo); | |
| 74 | 107 | ctx.invalidate(); |
| 75 | 108 | }, |
| 76 | 109 | render(area, s) { |
| 77 | 110 | const c = ctx.theme.c; |
| 78 | 111 | // One column for the scrollbar `drawLines` keeps, one for breathing room. |
| 79 | 112 | const width = Math.max(1, area.w - 2); |
| 80 | lines = vision === undefined ? [{ text: "Reading…", head: false }] : visionLines(vision, width); | |
| 113 | lines = vision === undefined ? [{ text: "Reading…", head: false }] : visionLines(vision, width, direction); | |
| 81 | 114 | const spans = lines.map((l) => [{ text: l.text, style: l.head ? style(c.accent, c.bg, BOLD) : style(c.fg, c.bg) }]); |
| 82 | 115 | drawLines(s, area, c, scroll, spans); |
| 83 | 116 | hitScroll(ctx, area, scroll, lines.length); |
| 84 | 117 | }, |
| 85 | 118 | key(e) { |
| 86 | 119 | if (scroll.key(e, lines.length)) return true; |
| 120 | if (e.key === "enter" && direction !== undefined) { | |
| 121 | ctx.push(issueScreen(f, ctx, local(f, repo), direction.number)); | |
| 122 | return true; | |
| 123 | } | |
| 87 | 124 | if (e.key === "esc" || e.key === "q") { |
| 88 | 125 | ctx.pop(); |
| 89 | 126 | return true; |
test/tui-vision-direction.test.ts added +161
| @@ -0,0 +1,161 @@ | ||
| 1 | /** The terminal's Vision screen under its rules: the open direction issue and its parts, as MCP `vision` reads them; Enter opens the issue. */ | |
| 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 { ensureDirectionIssue } from "../src/crew/autonomy.ts"; | |
| 9 | import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import type { User } from "../src/core/users.ts"; | |
| 12 | import { BLOCK_END, BLOCK_START } from "../src/crew/vision.ts"; | |
| 13 | import { setIssueState } from "../src/actions/issues.ts"; | |
| 14 | import { NO_VISION_LINE } from "../src/core/vision.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import { visionScreen } from "../src/tui/screens/vision.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 steered: Repo; | |
| 27 | let plain: Repo; | |
| 28 | let bare: Repo; | |
| 29 | let closed: Repo; | |
| 30 | let bob: User; | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dir = await mkdtemp(join(tmpdir(), "forge-tui-vision-dir-")); | |
| 34 | app = createApp({ dataDir: dir, port: 0 }); | |
| 35 | const made = await createUser(app.db, { | |
| 36 | origin: "seed", | |
| 37 | trusted: true, | |
| 38 | username: "alice", | |
| 39 | email: "[email protected]", | |
| 40 | password: "a-good-passphrase", | |
| 41 | adminIfFirst: true, | |
| 42 | }); | |
| 43 | if (!made.ok) throw new Error(made.error); | |
| 44 | alice = made.value; | |
| 45 | const bobMade = await createUser(app.db, { | |
| 46 | origin: "seed", | |
| 47 | trusted: true, | |
| 48 | username: "bob", | |
| 49 | email: "[email protected]", | |
| 50 | password: "a-good-passphrase", | |
| 51 | adminIfFirst: false, | |
| 52 | }); | |
| 53 | if (!bobMade.ok) throw new Error(bobMade.error); | |
| 54 | bob = bobMade.value; | |
| 55 | const mk = async (name: string, vision = true) => { | |
| 56 | const repo = await createRepo(app, alice, { name, visibility: "public" }); | |
| 57 | if (!repo.ok) throw new Error(JSON.stringify(repo.error)); | |
| 58 | const done = await commitToBranch(app, alice, repo.value, { | |
| 59 | message: "start", | |
| 60 | files: [ | |
| 61 | vision | |
| 62 | ? { path: "docs/VISION.md", content: "# Lab\n\nA forge.\n\n## Order of work\n\n1. **Story.** First.\n" } | |
| 63 | : { path: "README.md", content: "# bare\n" }, | |
| 64 | ], | |
| 65 | }); | |
| 66 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 67 | return repo.value; | |
| 68 | }; | |
| 69 | steered = await mk("steered"); | |
| 70 | plain = await mk("plain"); | |
| 71 | bare = await mk("bare", false); | |
| 72 | closed = await mk("closed"); | |
| 73 | const direction = `Alpha owner words.\n\n${BLOCK_START}\nBeta vision block\n${BLOCK_END}\n\nGamma more owner words.`; | |
| 74 | const steer = (repo: Repo) => { | |
| 75 | const set = updateRepoSettings(app.db, repo, { autonomyDirection: direction }); | |
| 76 | const issue = ensureDirectionIssue(app, alice, set); | |
| 77 | if (!issue) throw new Error("no direction issue"); | |
| 78 | return { repo: getRepoById(app.db, set.id)!, issue }; | |
| 79 | }; | |
| 80 | steered = steer(steered).repo; | |
| 81 | bare = steer(bare).repo; | |
| 82 | const shut = steer(closed); | |
| 83 | closed = shut.repo; | |
| 84 | setIssueState(app, alice, closed, shut.issue, "closed"); | |
| 85 | }, 60_000); | |
| 86 | ||
| 87 | afterAll(async () => { | |
| 88 | app.close(); | |
| 89 | await rm(dir, { recursive: true, force: true }); | |
| 90 | }); | |
| 91 | ||
| 92 | async function open(repo: Repo, user: User | undefined) { | |
| 93 | const terminal = headlessTerminal(100, 40); | |
| 94 | const { app: tui, session } = createForgeApp( | |
| 95 | app, | |
| 96 | { sshUsername: "t", remoteAddress: "127.0.0.1", ...(user ? { user } : { fingerprint: "SHA256:t" }) } as never, | |
| 97 | { terminal, exitHooks: false }, | |
| 98 | ); | |
| 99 | tui.start(); | |
| 100 | tui.ctx.push(visionScreen(session, tui.ctx, repo)); | |
| 101 | await settleApp(tui); | |
| 102 | return tui; | |
| 103 | } | |
| 104 | ||
| 105 | test("the direction is drawn under the rules: #N title, then each part in order; Enter opens the issue", async () => { | |
| 106 | const tui = await open(steered, alice); | |
| 107 | const text = tui.screen.toText(); | |
| 108 | const at = (s: string) => text.indexOf(s); | |
| 109 | expect(at("Direction")).toBeGreaterThan(at("1. Story.")); | |
| 110 | expect(at("#1 ")).toBeGreaterThan(at("Direction")); | |
| 111 | expect(at("Alpha owner words.")).toBeGreaterThan(at("#1 ")); | |
| 112 | expect(at("Beta vision block")).toBeGreaterThan(at("Alpha owner words.")); | |
| 113 | expect(at("Gamma more owner words.")).toBeGreaterThan(at("Beta vision block")); | |
| 114 | tui.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false }); | |
| 115 | await settleApp(tui); | |
| 116 | const after = tui.screen.toText(); | |
| 117 | tui.stop?.(); | |
| 118 | expect(after).not.toContain("open direction"); | |
| 119 | expect(after).not.toContain("How we build"); | |
| 120 | expect(after).toContain("#1"); | |
| 121 | expect(after).toContain("This issue carries the repository's direction"); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a repository without a direction issue, and a viewer who may not read the settings, draw no section", async () => { | |
| 125 | const none = await open(plain, alice); | |
| 126 | expect(none.screen.toText()).not.toContain("Direction"); | |
| 127 | none.stop?.(); | |
| 128 | const guest = await open(steered, undefined); | |
| 129 | const text = guest.screen.toText(); | |
| 130 | guest.stop?.(); | |
| 131 | expect(text).toContain("1. Story."); | |
| 132 | expect(text).not.toContain("Alpha owner words."); | |
| 133 | expect(text).not.toContain("Direction"); | |
| 134 | }); | |
| 135 | ||
| 136 | test("without a docs/VISION.md the no-vision line is followed by the Direction section", async () => { | |
| 137 | const tui = await open(bare, alice); | |
| 138 | const text = tui.screen.toText(); | |
| 139 | tui.stop?.(); | |
| 140 | expect(text).toContain(NO_VISION_LINE); | |
| 141 | expect(text.indexOf("Direction")).toBeGreaterThan(text.indexOf(NO_VISION_LINE)); | |
| 142 | expect(text.indexOf("Alpha owner words.")).toBeGreaterThan(text.indexOf("Direction")); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a signed-in non-admin and a closed direction issue draw no Direction section", async () => { | |
| 146 | const other = await open(steered, bob); | |
| 147 | const otherText = other.screen.toText(); | |
| 148 | other.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false }); | |
| 149 | await settleApp(other); | |
| 150 | const stayed = other.screen.toText(); | |
| 151 | other.stop?.(); | |
| 152 | expect(otherText).toContain("1. Story."); | |
| 153 | expect(otherText).not.toContain("Direction"); | |
| 154 | expect(otherText).not.toContain("Alpha owner words."); | |
| 155 | expect(stayed).toContain("1. Story."); | |
| 156 | const shut = await open(closed, alice); | |
| 157 | const shutText = shut.screen.toText(); | |
| 158 | shut.stop?.(); | |
| 159 | expect(shutText).toContain("1. Story."); | |
| 160 | expect(shutText).not.toContain("Direction"); | |
| 161 | }); |
new above
offline
This line runs to about 130 columns because the new sentence was joined onto
Read-only; offered to…. Rewrap it to the width the rest of the comment uses.