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
scout/forge:scout/10997-the-terminal-opens-the-board-s-returns-w → main 3 commits fork
3 files
changed +214
−0
src/tui/screens/dashboard.ts +3
| @@ -111,6 +111,7 @@ import { kpiLines } from "../../flow/kpi_words.ts"; | ||
| 111 | 111 | import { BOOTED } from "../../release/gate.ts"; |
| 112 | 112 | import { running } from "../../kernel/version.ts"; |
| 113 | 113 | import { deciderLine, deciderScreen } from "./decider.ts"; |
| 114 | import { returnsScreen } from "./returns.ts"; | |
| 114 | 115 | import { issueScreen } from "./issue.ts"; |
| 115 | 116 | import { listPinned } from "../../actions/board-pins.ts"; |
| 116 | 117 | import { migrateScreen } from "./migrate.ts"; |
| @@ -1016,6 +1017,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1016 | 1017 | disabled: focus !== "machine" || pins.length === 0, |
| 1017 | 1018 | }), |
| 1018 | 1019 | binding(["C"], "decider", { disabled: !f.user }), |
| 1020 | binding(["T"], "what the tokens bought", { disabled: !f.user }), | |
| 1019 | 1021 | ...(clientLines.length > 0 ? [binding(["t"], "ask for something")] : []), |
| 1020 | 1022 | ...(clientLive.size > 0 ? [binding(["o"], "open the app")] : []), |
| 1021 | 1023 | binding(["n"], "new repo", { disabled: !f.signedIn }), |
| @@ -1243,6 +1245,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1243 | 1245 | if (e.key === "U" && f.user) return undoHand(), true; |
| 1244 | 1246 | if (e.key === "L" && f.user) return listenAgain(), true; |
| 1245 | 1247 | 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; | |
| 1246 | 1249 | if (e.key === "P" && f.user) return showPeople(), true; |
| 1247 | 1250 | if (f.user && e.key >= "1" && e.key <= String(KIND_CHIPS.length)) |
| 1248 | 1251 | 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 | }); |
new above
offline