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

The terminal's limbs screen says what the planner changed on a re-planned limb: each old issue and what replaced it, as the milestones page does #7470

Merged opened this

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

Merged

scout/forge:scout/7448-the-terminal-s-limbs-screen-says-what-th → main 2 commits fork

  • Merged. scout/7448-the-terminal-s-limbs-screen-says-what-th was merged into main as 9b8dd31.

Review

Sign in to review this change.
2 files changed +300 −8
src/tui/screens/limbs.ts +116 −8
@@ -22,6 +22,13 @@
2222 * one push follow. A person only; an agent is told so. A watched row reads
2323 * "watching".
2424 *
25 * Under a limb the planner has answered, the screen says what it changed
26 * (owner facts only, as the page): `Re-planned <time ago>` and a line per
27 * split, `#old title → #new, #new`. `tab` moves the cursor onto those lines,
28 * `j`/`k` scroll them (the header counts any out of view), `enter` opens the
29 * new issue, or a choice among them when the line has several, `tab` or `esc`
30 * goes back. The block takes at most half the screen below the header.
31 *
2532 * `enter` on a limb that has limbs of its own opens them, so a milestone's
2633 * sub-limbs can be moved among themselves (the `under` of the action).
2734 * Subscribed to the bus while on the stack, so a move made on the web, or in
@@ -29,7 +36,7 @@
2936 */
3037
3138 import { undoFleetOp } from "../../actions/fleet.ts";
32 import { previewReplan, replanLimb, takeBackLimb } from "../../actions/limb_replan.ts";
39 import { previewReplan, replanLimb, replanResult, takeBackLimb } from "../../actions/limb_replan.ts";
3340 import { IN_HAND } from "../../actions/plan_words.ts";
3441 import { MILESTONE_WATCH_PERSON_ONLY } from "../../actions/board_words.ts";
3542 import { watchMilestone } from "../../actions/watches.ts";
@@ -44,12 +51,16 @@ import { fill, len, split } from "../ui/layout.ts";
4451 import { style } from "../term/style.ts";
4552 import { truncate } from "../term/width.ts";
4653 import type { Session } from "../session.ts";
54 import { issueScreen } from "./issue.ts";
55 import { local } from "./where.ts";
4756 import {
4857 ListState,
58 ago,
4959 confirmScreen,
5060 drawList,
5161 hitList,
5262 live,
63 pickerScreen,
5364 plural,
5465 runAsync,
5566 type ListRow,
@@ -77,6 +88,39 @@ export function spendLines(spend: NonNullable<Milestone["spend"]>): string[] {
7788 ];
7889 }
7990
91 /** The most split lines drawn at once; `j` and `k` scroll the rest into view. */
92 const SPLITS_SHOWN = 4;
93
94 type Splits = NonNullable<ReturnType<typeof replanResult>>;
95
96 /**
97 * What the planner changed on a limb, as the lines under the list: a header,
98 * then `#old title → #new, #new` per split, a window of at most `room - 1` of
99 * them that follows `at`. The header says how many are out of view. Nothing
100 * when there is no room for a header and a line.
101 */
102 export function replanLines(
103 r: Splits,
104 room: number,
105 at = 0,
106 ): { lines: string[]; start: number; shown: number } {
107 const n = r.splits.length;
108 const shown = Math.min(n, SPLITS_SHOWN, room - 1);
109 // Too little room for a line: say so in the header rather than draw nothing.
110 if (shown < 1)
111 return {
112 lines: room < 1 ? [] : [`Re-planned ${ago(r.at)} · ${plural(n, "split")}, too little room to list`],
113 start: 0,
114 shown: 0,
115 };
116 const start = Math.max(0, Math.min(at - (shown - 1), n - shown));
117 const more = n > shown ? ` · +${n - shown} more` : "";
118 const lines = [`Re-planned ${ago(r.at)}${more}`];
119 for (const s of r.splits.slice(start, start + shown))
120 lines.push(`#${s.number} ${s.title} → ${s.into.map((p) => `#${p.number}`).join(", ")}`);
121 return { lines, start, shown };
122 }
123
80124 const line = (n: number, title: string): string => `#${n} ${title}`;
81125
82126 /** What a preview says before anything is written, as dialog lines. */
@@ -123,6 +167,12 @@ export function limbsScreen(
123167 let last: string | undefined;
124168 /** The limbs with the planner, by issue id (#761). */
125169 let withPlanner = new Set<string>();
170 /** What the planner changed on each answered limb here, by issue number; owner facts only. */
171 let replanned = new Map<number, Splits>();
172 /** The split line the cursor is on while `tab` has moved it under the list; else undefined. */
173 let splitAt: number | undefined;
174 /** How many split lines the last render had room to draw; `tab` needs one. */
175 let splitsDrawn = 0;
126176 /** The milestones here the viewer watches, by issue id (#7236). */
127177 let watching = new Set<string>();
128178 // Read at use, not at open: a repository's admin can change under a session.
@@ -134,6 +184,8 @@ export function limbsScreen(
134184 limbs = [];
135185 spends = new Map();
136186 paces = new Map();
187 replanned = new Map();
188 splitAt = undefined;
137189 loaded = true;
138190 ctx.invalidate();
139191 return;
@@ -143,8 +195,12 @@ export function limbsScreen(
143195 withPlanner = new Set(pendingReplans(f.db, repo).map((r) => r.limbId));
144196 spends = new Map();
145197 paces = new Map();
198 replanned = new Map();
146199 watching = new Set();
200 const owner = canSeeOwnerFacts(f.db, repo, f.user);
147201 for (const l of limbs) {
202 const r = owner ? replanResult(f.app, repo, l.issue.id) : undefined;
203 if (r !== undefined && r.splits.length > 0) replanned.set(l.issue.number, r);
148204 const m = plan.milestone(l.issue.number);
149205 if (m === undefined) continue;
150206 if (f.user?.kind === "user" && isWatching(f.db, f.user.id, m.issue.id))
@@ -158,9 +214,15 @@ export function limbsScreen(
158214 if (at >= 0) state.index = at;
159215 }
160216 state.clamp(limbs.length);
217 splitAt = undefined;
161218 ctx.invalidate();
162219 };
163220
221 const splitsHere = (): Splits["splits"] => {
222 const l = current();
223 return (l === undefined ? undefined : replanned.get(l.issue.number))?.splits ?? [];
224 };
225
164226 const current = (): Limb | undefined => limbs[state.index];
165227
166228 const move = (action: "raise" | "lower"): void => {
@@ -312,7 +374,8 @@ export function limbsScreen(
312374 const moves = may() && limbs.length > 1;
313375 return [
314376 binding(["j", "k"], "move cursor"),
315 binding(["enter"], "open"),
377 binding(["enter"], splitAt === undefined ? "open" : "open the new issue"),
378 binding(["tab"], splitAt === undefined ? "splits" : "limbs", { disabled: splitsDrawn === 0 }),
316379 binding(["r"], "raise", { disabled: !moves }),
317380 binding(["l"], "lower", { disabled: !moves }),
318381 binding(["p"], withPlanner.has(current()?.issue.id ?? "") ? "take back" : "re-plan", { disabled: !may() || current() === undefined }),
@@ -329,10 +392,19 @@ export function limbsScreen(
329392 const c = ctx.theme.c;
330393 const spend = current() === undefined ? undefined : spends.get(current()!.issue.number);
331394 const pace = current() === undefined ? undefined : paces.get(current()!.issue.number);
395 const answered = current() === undefined ? undefined : replanned.get(current()!.issue.number);
396 const fixed = (pace === undefined ? 0 : 1) + (spend ? 2 : 0);
397 // The foot never takes more than half of what the header leaves.
398 const block =
399 answered === undefined
400 ? { lines: [], start: 0, shown: 0 }
401 : replanLines(answered, Math.max(0, ((area.h - 1) >> 1) - fixed), splitAt);
402 splitsDrawn = block.shown;
403 if (block.shown === 0) splitAt = undefined;
332404 const [head, list, foot] = split(area, "v", [
333405 len(1),
334406 fill(1),
335 len((pace === undefined ? 0 : 1) + (spend ? 2 : 0)),
407 len(fixed + block.lines.length),
336408 ]);
337409 const note = !loaded
338410 ? "Loading…"
@@ -341,14 +413,50 @@ export function limbsScreen(
341413 : `${plural(limbs.length, "limb")} in the order the crew takes ready work${may() ? "" : " · you can read this order, not change it"}`;
342414 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
343415 drawList(s, list!, c, state, rows());
344 const lines = [...(pace === undefined ? [] : [pace]), ...(spend === undefined ? [] : spendLines(spend))];
345 if (lines.length > 0)
346 lines.forEach((text, n) =>
347 s.text(foot!.x, foot!.y + n, truncate(text, foot!.w), style(c.mutedFg, c.bg), foot!.x + foot!.w),
348 );
416 const lines = [
417 ...(pace === undefined ? [] : [pace]),
418 ...(spend === undefined ? [] : spendLines(spend)),
419 ...block.lines,
420 ];
421 // The split lines are the last ones; the header sits above them.
422 const first = lines.length - block.shown;
423 lines.forEach((text, n) => {
424 const on = splitAt !== undefined && n === first + splitAt - block.start;
425 const cell = on ? style(c.fg, c.bg, 1) : style(c.mutedFg, c.bg);
426 s.text(foot!.x, foot!.y + n, truncate(on ? `> ${text}` : text, foot!.w), cell, foot!.x + foot!.w);
427 });
349428 hitList(ctx, list!, state, limbs.length, { open });
350429 },
351430 key(e) {
431 const parts = splitsHere();
432 if (splitAt !== undefined && !e.ctrl && !e.alt) {
433 if (e.key === "j" || e.key === "down") splitAt = Math.min(parts.length - 1, splitAt + 1);
434 else if (e.key === "k" || e.key === "up") splitAt = Math.max(0, splitAt - 1);
435 else if (e.key === "enter") {
436 const into = parts[splitAt]?.into ?? [];
437 const show = (number: number): void =>
438 ctx.push(issueScreen(f, ctx, local(f, repo), number));
439 if (into.length === 1) show(into[0]!.number);
440 else if (into.length > 1)
441 ctx.push(
442 pickerScreen(ctx, screen, {
443 title: `#${parts[splitAt]!.number} became`,
444 items: into.map((p) => ({ id: String(p.number), label: `#${p.number} ${p.title}` })),
445 onPick: ([chosen]) => {
446 if (chosen !== undefined) show(Number(chosen));
447 },
448 }),
449 );
450 } else if (e.key === "tab" || e.key === "esc") splitAt = undefined;
451 else return false;
452 ctx.invalidate();
453 return true;
454 }
455 if (e.key === "tab" && !e.ctrl && !e.alt && parts.length > 0) {
456 splitAt = 0;
457 ctx.invalidate();
458 return true;
459 }
352460 if (state.key(e, limbs.length)) return true;
353461 if (e.ctrl || e.alt) return false;
354462 if (e.key === "enter") open();
test/tui-limbs-replanned.test.ts added +184
@@ -0,0 +1,184 @@
1 /**
2 * The limbs screen says what the planner changed on a re-planned limb (#7448):
3 * `Re-planned <time ago>` and one line per split, as the milestones page does,
4 * and Enter on a split line opens the new issue. Owner facts only.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { addToCrew, createAgent } from "../src/actions/crew.ts";
12 import { linkIssue } from "../src/actions/dependencies.ts";
13 import { openIssue, setIssueState } from "../src/actions/issues.ts";
14 import { replanLimb } from "../src/actions/limb_replan.ts";
15 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
16 import { setCrewRole } from "../src/core/agents.ts";
17 import { getIssueById, type Issue } from "../src/core/issues.ts";
18 import { getRepoById, type Repo } from "../src/core/repos.ts";
19 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
20 import { createUser, getUserByName, type User } from "../src/core/users.ts";
21 import { ensureDirectionIssue } from "../src/crew/autonomy.ts";
22 import { createForgeApp } from "../src/tui/forge.ts";
23 import type { TuiIdentity } from "../src/tui/session.ts";
24 import { headlessTerminal } from "../src/tui/term/terminal.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26 import { settleApp } from "./helpers/tui.ts";
27
28 quietLogs();
29
30 describe("the limbs screen after a re-plan", () => {
31 let dataDir: string;
32 let app: App;
33 let alice: User;
34 let bob: User;
35 let repo: Repo;
36 let limb: Issue;
37 let leaves: Issue[];
38 let atlas: User;
39 let fresh: () => Repo;
40 const current = (i: Issue) => getIssueById(app.db, i.id)!;
41 const open = (title: string, by: User): Issue => {
42 const made = openIssue(app, by, fresh(), { title });
43 if (!made.ok) throw new Error(String(made.error));
44 return made.value;
45 };
46
47 function boot(user?: User, rows = 32) {
48 const terminal = headlessTerminal(120, rows);
49 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
50 const { app: tui } = createForgeApp(app, identity, { terminal, exitHooks: false });
51 tui.start();
52 const press = async (...keys: string[]) => {
53 for (const key of keys) tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
54 await settleApp(tui);
55 };
56 const type = async (text: string) => press(...text.split(""));
57 const toLimbs = async () => {
58 await settleApp(tui);
59 tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
60 await settleApp(tui);
61 await type("Milestones in a repository");
62 await press("enter");
63 await type("alice/engine");
64 await press("enter");
65 };
66 return { text: () => tui.screen.toText(), press, toLimbs, stop: () => tui.stop() };
67 }
68
69 beforeAll(async () => {
70 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-replanned-"));
71 app = createApp({ dataDir, port: 0 });
72 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
73 const made = await createUser(app.db, {
74 origin: "seed",
75 trusted: true,
76 username,
77 email: `${username}@forge.local`,
78 password: "a-good-passphrase",
79 adminIfFirst,
80 });
81 if (!made.ok) throw new Error(made.error);
82 }
83 alice = getUserByName(app.db, "alice")!;
84 bob = getUserByName(app.db, "bob")!;
85 const made = await createRepo(app, alice, { name: "engine", visibility: "public" });
86 if (!made.ok) throw new Error(String(made.error));
87 repo = made.value;
88 fresh = () => getRepoById(app.db, repo.id)!;
89 const agent = await createAgent(app, alice, { username: "atlas" });
90 if (!agent.ok) throw new Error(String(agent.error));
91 expect(addToCrew(app, alice, repo, "atlas").ok).toBe(true);
92 atlas = getUserByName(app.db, "atlas")!;
93 setCrewRole(app.db, repo.id, atlas.id, "planner");
94 const set = await updateRepoSettings(app, alice, repo, {
95 autonomy: "autonomous",
96 autonomyDirection: "Keep the engine small.",
97 autonomyCeilingUsd: 100,
98 autonomyBy: alice.id,
99 });
100 if (!set.ok) throw new Error(JSON.stringify(set.error));
101 limb = open("Search", alice);
102 leaves = ["Index titles", "Index bodies", "Rank hits", "Highlight hits", "Cache hits", "Page hits"].map((t) => open(t, alice));
103 for (const leaf of leaves) linkIssue(app, alice, fresh(), current(limb), current(leaf));
104 });
105
106 afterAll(async () => {
107 app.close();
108 await rm(dataDir, { recursive: true, force: true });
109 });
110
111 test("a limb not re-planned draws no answer, and one the planner has answered does", async () => {
112 const before = boot(alice);
113 await before.toLimbs();
114 expect(before.text()).toContain("Search");
115 expect(before.text()).not.toContain("Re-planned");
116 before.stop();
117
118 const direction = ensureDirectionIssue(app, alice, fresh())!;
119 expect(replanLimb(app, alice, fresh(), limb.number).ok).toBe(true);
120 await Bun.sleep(5);
121 const larger = open("Search over titles and bodies", atlas);
122 const other = open("Search ranking", atlas);
123 const close = (i: Issue, into: number[]) =>
124 setIssueState(app, atlas, fresh(), current(i), "closed", { reason: "split", into });
125 expect(close(leaves[0]!, [larger.number, other.number]).ok).toBe(true);
126 for (const leaf of leaves.slice(1)) expect(close(leaf, [larger.number]).ok).toBe(true);
127 const run = createRun(app.db, {
128 repoId: repo.id,
129 kind: "agent",
130 issueId: direction.id,
131 actorId: atlas.id,
132 reason: "assigned",
133 });
134 claimRun(app.db, run.id);
135 finishRun(app.db, run.id, { state: "ok", summary: "re-planned" });
136
137 const t = boot(alice);
138 await t.toLimbs();
139 expect(t.text()).toMatch(/Re-planned (just now|\d+\w ago)/);
140 expect(t.text()).toContain(`#${leaves[0]!.number} Index titles → #${larger.number}, #${other.number}`);
141 expect(t.text()).toContain(`#${leaves[1]!.number} Index bodies → #${larger.number}`);
142 // Six splits, four drawn: the header says two are out of view.
143 expect(t.text()).toContain("+2 more");
144 expect(t.text()).not.toContain("Page hits →");
145 // Tab onto the split lines, down to the second, Enter opens its one new issue.
146 await t.press("tab", "j", "enter");
147 expect(t.text()).toContain("Search over titles and bodies");
148 expect(t.text()).not.toContain("Re-planned");
149 await t.press("esc");
150 // Enter on the line with two new issues asks which; the second opens.
151 expect(t.text()).toContain("Re-planned");
152 await t.press("k", "enter");
153 expect(t.text()).toContain(`#${larger.number} Search over titles and bodies`);
154 expect(t.text()).toContain(`#${other.number} Search ranking`);
155 await t.press("j", "enter");
156 expect(t.text()).toContain("Search ranking");
157 await t.press("esc");
158 // Scrolling down brings the hidden splits into view; tab goes back to the limbs.
159 await t.press("j", "j", "j", "j", "j");
160 expect(t.text()).toContain("Page hits →");
161 await t.press("tab");
162 expect(t.text()).not.toContain("> #");
163 t.stop();
164 });
165
166 test("a short terminal keeps the list and says splits are out of view", async () => {
167 const t = boot(alice, 10);
168 await t.toLimbs();
169 expect(t.text()).toContain("1. #");
170 expect(t.text()).toContain("6 splits, too little room");
171 t.stop();
172 });
173
174 test("a reader without owner facts sees none of it", async () => {
175 const t = boot(bob);
176 await t.toLimbs();
177 expect(t.text()).toContain("Search");
178 expect(t.text()).not.toContain("Re-planned");
179 expect(t.text()).not.toContain("→");
180 await t.press("tab");
181 expect(t.text()).not.toContain("> #");
182 t.stop();
183 });
184 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs