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

The terminal opens the board's returns: what the window's dollars bought, in the same rows as /board/returns #11039

Parked opened this
Closed without merging

scout/forge:scout/10997-the-terminal-opens-the-board-s-returns-w → main 3 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
3 files changed +214 −0
src/tui/screens/dashboard.ts +3
@@ -111,6 +111,7 @@ import { kpiLines } from "../../flow/kpi_words.ts";
111111 import { BOOTED } from "../../release/gate.ts";
112112 import { running } from "../../kernel/version.ts";
113113 import { deciderLine, deciderScreen } from "./decider.ts";
114 import { returnsScreen } from "./returns.ts";
114115 import { issueScreen } from "./issue.ts";
115116 import { listPinned } from "../../actions/board-pins.ts";
116117 import { migrateScreen } from "./migrate.ts";
@@ -1016,6 +1017,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10161017 disabled: focus !== "machine" || pins.length === 0,
10171018 }),
10181019 binding(["C"], "decider", { disabled: !f.user }),
1020 binding(["T"], "what the tokens bought", { disabled: !f.user }),
10191021 ...(clientLines.length > 0 ? [binding(["t"], "ask for something")] : []),
10201022 ...(clientLive.size > 0 ? [binding(["o"], "open the app")] : []),
10211023 binding(["n"], "new repo", { disabled: !f.signedIn }),
@@ -1243,6 +1245,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
12431245 if (e.key === "U" && f.user) return undoHand(), true;
12441246 if (e.key === "L" && f.user) return listenAgain(), true;
12451247 if (e.key === "C" && f.user) return ctx.push(deciderScreen(f, ctx)), true;
1248 if (e.key === "T" && f.user) return ctx.push(returnsScreen(f, ctx)), true;
12461249 if (e.key === "P" && f.user) return showPeople(), true;
12471250 if (f.user && e.key >= "1" && e.key <= String(KIND_CHIPS.length))
12481251 return toggleChip(Number(e.key) - 1), true;
src/tui/screens/returns.ts added +106
@@ -0,0 +1,106 @@
1 /**
2 * `/board/returns` in the terminal: what the window's dollars bought, in the
3 * page's own rows. Spent and landed first, then what did not land and why,
4 * then the costliest issues. Read through `readReturns`, the page's gate:
5 * owner facts only; anyone it refuses is told there is nothing here.
6 * `d` cycles the window through 1, 7 and 30 days.
7 */
8
9 import { readReturns, returnsRepos } from "../../actions/returns.ts";
10 import { usd } from "../../core/money.ts";
11 import { getReposByIds } from "../../core/repos.ts";
12 import type { Money } from "../../core/returns.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import { binding } from "../app/keys.ts";
15 import type { Session } from "../session.ts";
16 import { BOLD, style } from "../term/style.ts";
17 import { Scroll, drawLines, hitScroll } from "./common.ts";
18
19 /** The windows `d` cycles through, in days; 7 is where the screen opens. */
20 export const RETURNS_DAYS_CYCLE = [1, 7, 30] as const;
21
22 export const NOTHING_TO_SEE = "Nothing here is yours to see.";
23
24 const runs = (m: Money): string => `${m.runs} ${m.runs === 1 ? "run" : "runs"}`;
25 const row = (title: string, m: Money, note?: string): string =>
26 `${title} ${runs(m)}${note === undefined ? "" : ` · ${note}`} ${usd(m.usd)}`;
27
28 /** The page's rows as plain lines (head: spent, landed; then not landed; then costliest); one refusal line for a viewer the gate refuses. */
29 export function returnsLines(session: Session, days: number): { text: string; head?: boolean }[] {
30 // Someone who holds owner facts for no repository is refused outright, not shown a page of zeros.
31 if (session.user === undefined || returnsRepos(session.app, session.user).length === 0) return [{ text: NOTHING_TO_SEE }];
32 const read = readReturns(session.app, session.user, { days });
33 if (!read.ok) return [{ text: NOTHING_TO_SEE }];
34 const r = read.value;
35 const nothing = r.landedNothing;
36 const out: { text: string; head?: boolean }[] = [];
37 const h = (text: string) => out.push({ text, head: true });
38 const p = (text: string) => out.push({ text });
39 h(`What the tokens bought, ${days} ${days === 1 ? "day" : "days"}`);
40 p(
41 `${usd(r.spent.usd)} over ${runs(r.spent)} · ${r.landed} changes landed · ${
42 r.onLanded.perChange.changes === 0
43 ? ""
44 : `${usd(r.onLanded.perChange.median)} a landed change (median), ${usd(r.onLanded.usd)} in all · `
45 }${usd(nothing.closed.usd)} closed with nothing · ${usd(nothing.open.usd)} still open`,
46 );
47 h("Where it went");
48 p(row("On work that landed", r.onLanded, `${r.onLanded.perChange.changes} changes`));
49 p(row("On work closed without a merge", nothing.closed));
50 p(row("On work still open, nothing merged yet", nothing.open));
51 h("What did not land, and why");
52 p(row("Runs that failed", nothing.failed));
53 for (const f of nothing.topFailures) p(row(` of which: ${f.reason}`, f));
54 p(row("Runs cancelled", nothing.cancelled));
55 h("What cost the most");
56 const repos = getReposByIds(session.app.db, r.costliest.map((c) => c.repoId));
57 for (const c of r.costliest) {
58 const repo = repos.get(c.repoId);
59 const slug = repo ? `${repo.ownerName}/${repo.name}` : "";
60 const outcome = c.outcome === "landed" ? "landed" : c.outcome === "closed" ? "closed with nothing merged" : "nothing merged yet";
61 p(`#${c.number} ${c.title} ${slug} · ${runs(c)} · ${outcome} ${usd(c.usd)}`);
62 }
63 return out;
64 }
65
66 export function returnsScreen(session: Session, ctx: Ctx): Screen {
67 const scroll = new Scroll();
68 let at = RETURNS_DAYS_CYCLE.indexOf(7);
69 // Read when the window moves, never per frame.
70 let lines = returnsLines(session, RETURNS_DAYS_CYCLE[at]!);
71 const refused = () => lines.length === 1 && lines[0]!.text === NOTHING_TO_SEE;
72 return {
73 title: "What the tokens bought",
74 keys: [
75 binding(["d"], "window: 1, 7, 30 days", { hidden: refused() }),
76 binding(["j", "k"], "scroll"),
77 binding(["esc", "q"], "back"),
78 ],
79 render(area, s) {
80 const c = ctx.theme.c;
81 drawLines(
82 s,
83 area,
84 c,
85 scroll,
86 lines.map((l) => [{ text: l.text, style: l.head ? style(c.fg, c.bg, BOLD) : style(c.fg, c.bg) }]),
87 );
88 hitScroll(ctx, area, scroll, lines.length);
89 },
90 key(e) {
91 if (scroll.key(e, lines.length)) return true;
92 if (!e.ctrl && !e.alt && e.key === "d" && !refused()) {
93 at = (at + 1) % RETURNS_DAYS_CYCLE.length;
94 lines = returnsLines(session, RETURNS_DAYS_CYCLE[at]!);
95 scroll.to(0, lines.length);
96 ctx.invalidate();
97 return true;
98 }
99 if (e.key === "esc" || e.key === "q") {
100 ctx.pop();
101 return true;
102 }
103 return false;
104 },
105 };
106 }
test/tui-returns.test.ts added +105
@@ -0,0 +1,105 @@
1 /** The terminal's returns screen (#10997): the page's gate and the page's numbers, `d` cycles the window, `T` on the dashboard reaches it. */
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 { createApp, type App } from "../src/app.ts";
7 import { openIssue } from "../src/actions/issues.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createRun } from "../src/core/runs.ts";
10 import { createSession } from "../src/core/sessions.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { usd } from "../src/core/money.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { Session } from "../src/tui/forge.ts";
15 import type { Ctx, Screen } from "../src/tui/app/app.ts";
16 import { dashboardScreen } from "../src/tui/screens/dashboard.ts";
17 import { NOTHING_TO_SEE, returnsLines, returnsScreen } from "../src/tui/screens/returns.ts";
18 import { Screen as Surface } from "../src/tui/term/screen.ts";
19 import { theme } from "../src/tui/ui/theme.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 let dir = "";
25 let app: App;
26 let servers: Servers;
27 let cookie = "";
28 const id = (user?: ReturnType<typeof getUserByName>) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user });
29 const ctx = (pushed: Screen[] = []) =>
30 ({ theme: theme("dark"), invalidate() {}, toast() {}, push: (s: Screen) => pushed.push(s), pop() {}, copy() {}, hit() {} }) as unknown as Ctx;
31 const key = (k: string) => ({ key: k, ctrl: false, alt: false, shift: false }) as never;
32 const text = (s: Screen) => {
33 const surface = new Surface(140, 40);
34 s.render({ x: 0, y: 0, w: 140, h: 40 }, surface);
35 return surface.toText();
36 };
37
38 beforeAll(async () => {
39 dir = await mkdtemp(join(tmpdir(), "forge-tui-returns-"));
40 app = createApp({ dataDir: dir, port: 0 });
41 servers = await serve(app);
42 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
43 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
44 if (!a.ok || !b.ok) throw new Error("user");
45 cookie = `forge_session=${createSession(app.db, a.value.id).token}`;
46 const made = await createRepo(app, a.value, { name: "mill", visibility: "public" });
47 if (!made.ok) throw new Error(String(made.error));
48 const work = openIssue(app, a.value, made.value, { title: "Some work" });
49 if (!work.ok) throw new Error("issue");
50 const r = createRun(app.db, { repoId: made.value.id, kind: "agent", issueId: work.value.id, actorId: a.value.id, reason: "assigned" });
51 app.db
52 .query("UPDATE run SET state = 'failed', cost_usd = 2.5, summary = 'failed', started_at = ?2, finished_at = ?3 WHERE id = ?1")
53 .run(r.id, new Date(Date.now() - 600_000).toISOString(), new Date().toISOString());
54 });
55 afterAll(async () => {
56 servers.stop();
57 app.close();
58 await rm(dir, { recursive: true, force: true });
59 });
60
61 test("a non-owner and a signed-out visitor are refused: one line, no figures", () => {
62 for (const who of [getUserByName(app.db, "bob"), undefined]) {
63 const lines = returnsLines(new Session(app, id(who)), 7);
64 expect(lines.map((l) => l.text)).toEqual([NOTHING_TO_SEE]);
65 expect(text(returnsScreen(new Session(app, id(who)), ctx()))).not.toContain("2.50");
66 }
67 });
68
69 test("the owner's numbers equal /board/returns for the same window", async () => {
70 const alice = getUserByName(app.db, "alice");
71 const lines = returnsLines(new Session(app, id(alice)), 7).map((l) => l.text);
72 const page = await (await fetch(`${servers.url}/board/returns?days=7`, { headers: { cookie } })).text();
73 expect(lines[0]).toBe("What the tokens bought, 7 days");
74 expect(lines[1]).toContain(`${usd(2.5)} over 1 run`);
75 expect(page).toContain(usd(2.5));
76 // Order: spent and landed, then what did not land, then the costliest.
77 const at = (s: string) => lines.findIndex((l) => l.startsWith(s));
78 expect(at("What the tokens bought")).toBeLessThan(at("What did not land"));
79 expect(at("What did not land")).toBeLessThan(at("What cost the most"));
80 expect(lines.some((l) => l.startsWith("Runs that failed 1 run") && l.endsWith(usd(2.5)))).toBe(true);
81 expect(page).toContain("Runs that failed");
82 expect(lines.some((l) => l.startsWith("#1 Some work") && l.includes("alice/mill") && l.endsWith(usd(2.5)))).toBe(true);
83 expect(page).toContain("Some work");
84 // Every dollar figure on the screen is on the page.
85 for (const m of lines.join("\n").matchAll(/\$[\d,]+\.\d+/g)) expect(page).toContain(m[0]);
86 });
87
88 test("d cycles 7 → 30 → 1 → 7 days", () => {
89 const s = returnsScreen(new Session(app, id(getUserByName(app.db, "alice"))), ctx());
90 expect(text(s)).toContain("7 days");
91 s.key(key("d"));
92 expect(text(s)).toContain("30 days");
93 s.key(key("d"));
94 expect(text(s)).toContain("1 day");
95 s.key(key("d"));
96 expect(text(s)).toContain("7 days");
97 });
98
99 test("one key from the dashboard reaches it", () => {
100 const pushed: Screen[] = [];
101 const dash = dashboardScreen(new Session(app, id(getUserByName(app.db, "alice"))), ctx(pushed));
102 expect(dash.key(key("T"))).toBe(true);
103 expect(pushed).toHaveLength(1);
104 expect(pushed[0]!.title).toBe("What the tokens bought");
105 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs