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

Merged opened this

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

Merged

scout/forge:scout/7476-the-terminal-s-vision-screen-shows-the-r → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/7476-the-terminal-s-vision-screen-shows-the-r was merged into main as de824da.

Review

Sign in to review this change.
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):
152152 (the fork screen, for those who cannot write the repository), and *Vision*
153153 (docs/VISION.md on the default branch: the purpose, each pillar numbered,
154154 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
156159 commented on from its own screen. Agents have the MCP `tell` and
157160 `make_it_yours` tools and the API's `POST …/issues`,
158161 `…/pulls/:n/comments` and `…/make-it-yours`.
src/tui/screens/vision.ts +46 −9
@@ -5,12 +5,16 @@
55 * work numbered, then the rules of how it is built. Wrapped to the width at
66 * render, never cut mid-word. A repository without the document says so in
77 * 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,
outdated

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.

11 * and read for, only someone who may read it.
912 */
1013
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";
1216 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";
1418 import type { Ctx, Screen } from "../app/app.ts";
1519 import { binding } from "../app/keys.ts";
1620 import type { Command } from "../app/palette.ts";
@@ -18,11 +22,20 @@ import { BOLD, style } from "../term/style.ts";
1822 import { displayWidth, wrap } from "../term/width.ts";
1923 import type { Session } from "../session.ts";
2024 import { Scroll, drawLines, hitScroll } from "./common.ts";
25 import { issueScreen } from "./issue.ts";
26 import { local } from "./where.ts";
2127
2228 /** What a viewer who may not read the repository sees: what the page's 404 says, nothing of the document. */
2329 export const NOT_FOUND_LINE = "No such repository, or you may not read it.";
2430 const HIDDEN: Vision = { purpose: NOT_FOUND_LINE, order: [], rules: [] };
2531
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
2639 export interface VisionLine {
2740 readonly text: string;
2841 readonly head: boolean;
@@ -36,12 +49,12 @@ function hanging(lead: string, text: string, width: number): string[] {
3649 }
3750
3851 /** 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[] {
4053 const body = (text: string): VisionLine => ({ text, head: false });
4154 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) : [];
4558 if (v.purpose !== null) out.push(...wrap(v.purpose, width).map(body));
4659 if (v.order.length > 0) {
4760 if (out.length > 0) out.push(body(""));
@@ -57,33 +70,57 @@ export function visionLines(v: Vision, width: number): VisionLine[] {
5770 out.push(head("How we build"));
5871 for (const r of v.rules) out.push(...hanging("- ", r, width).map(body));
5972 }
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 }
6082 return out;
6183 }
6284
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
6394 export function visionScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6495 const scroll = new Scroll();
6596 let vision: Vision | undefined;
97 let direction: Direction | undefined;
6698 let lines: VisionLine[] = [];
6799 return {
68100 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 enter open direction, but docs/APPS.md (the Vision entry, around line 152) doesn't mention the Direction section or Enter. Document both there. Enter working from anywhere on the screen, not only on the direction line, is fine: the screen has no line selection and has only one target.

70102 async load() {
71103 // Gated here as well as on the command: the screen is exported, and a
72104 // viewer who may not read the repository is shown nothing of it.
73105 vision = canRead(f.db, repo, f.user) ? ((await readDefaultVision(f.app.reposRoot, repo)) ?? NO_VISION) : HIDDEN;
106 direction = vision === HIDDEN ? undefined : readDirection(f, repo);
74107 ctx.invalidate();
75108 },
76109 render(area, s) {
77110 const c = ctx.theme.c;
78111 // One column for the scrollbar `drawLines` keeps, one for breathing room.
79112 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);
81114 const spans = lines.map((l) => [{ text: l.text, style: l.head ? style(c.accent, c.bg, BOLD) : style(c.fg, c.bg) }]);
82115 drawLines(s, area, c, scroll, spans);
83116 hitScroll(ctx, area, scroll, lines.length);
84117 },
85118 key(e) {
86119 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 }
87124 if (e.key === "esc" || e.key === "q") {
88125 ctx.pop();
89126 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs