Public: anyone can read this; the crew can change it.
The terminal's decider screen says, per app, what one shipped change cost this week, as the board's Today line does #8079
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8057-the-terminal-s-decider-screen-says-per-a → main 2 commits fork 2 threads · 2 unresolved
7 files
changed +183
−16
docs/ASKS.md +5 −2
| @@ -26,8 +26,11 @@ its ledger rows (`/board?ledger=closed|deferred|asked`; any other value is | ||
| 26 | 26 | ignored, `?apps=` is kept). A deferral is the decider's own `stop`, never the |
| 27 | 27 | rule's. The ledger view lists up to 200 rows and says when it left some out. |
| 28 | 28 | In the terminal the same line sits above the dashboard, `C` (or the palette's |
| 29 | Decider) opens the rows, and `1`, `2`, `3` switch pile. Both are for the | |
| 30 | repositories you own only: a reader sees nothing. | |
| 29 | Decider) opens the rows, and `1`, `2`, `3` switch pile. Under the line, one | |
| 30 | muted line per app that ran or shipped this week says what one shipped change | |
| 31 | cost, in the board's words ("$3.00 per shipped change this week"); at most | |
| 32 | three, then `+N more apps`. Both are for the repositories you own only: a | |
| 33 | reader sees nothing. | |
| 31 | 34 | |
| 32 | 35 | **Give back to the crew** is on a pull a pass gave up on and assigned to you |
| 33 | 36 | (finishing, requested, sweep, picker): its row says the pass's reason in one |
src/core/efficiency.ts +11
| @@ -77,3 +77,14 @@ export function efficiencyForRepos( | ||
| 77 | 77 | } |
| 78 | 78 | return out; |
| 79 | 79 | } |
| 80 | ||
| 81 | /** An app's spend per shipped change in a few words: | |
| 82 | * `$1.37 per shipped change`, or what is true when there is no ratio. */ | |
| 83 | export function perShippedWords(e: Efficiency | undefined): string { | |
| 84 | const dollars = (n: number): string => `$${n.toFixed(2)}`; | |
| 85 | if (e === undefined || (e.spentUsd === 0 && e.shipped === 0)) | |
| 86 | return "nothing spent or shipped"; | |
| 87 | if (e.perShippedUsd === null) | |
| 88 | return `${dollars(e.spentUsd)} spent, nothing shipped`; | |
| 89 | return `${dollars(e.perShippedUsd)} per shipped change`; | |
| 90 | } |
src/tui/screens/decider.ts +23 −1
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | listDeciderPile, |
| 16 | 16 | type DeciderPile, |
| 17 | 17 | } from "../../core/decisions.ts"; |
| 18 | import { efficiencyForRepos, perShippedWords } from "../../core/efficiency.ts"; | |
| 18 | 19 | import { deciderSentence } from "../../story/words.ts"; |
| 19 | 20 | import { getIssuesByIds } from "../../core/issues.ts"; |
| 20 | 21 | import { getReposByIds } from "../../core/repos.ts"; |
| @@ -46,6 +47,24 @@ export function deciderLine(app: App, viewer: User | undefined, now = new Date() | ||
| 46 | 47 | return n.closed + n.deferred + n.asked === 0 ? null : deciderSentence((pile) => n[pile]).join(""); |
| 47 | 48 | } |
| 48 | 49 | |
| 50 | const COST_LINES = 3; | |
| 51 | ||
| 52 | /** One muted line per owned app that ran or shipped this week, at most three, then `+N more apps`. */ | |
| 53 | export function efficiencyLines(app: App, viewer: User | undefined, now = new Date()): string[] { | |
| 54 | const repos = viewer === undefined ? [] : returnsRepos(app, viewer); | |
| 55 | const week = efficiencyForRepos( | |
| 56 | app.db, | |
| 57 | repos.map((r) => r.id), | |
| 58 | new Date(now.getTime() - 7 * 86_400_000).toISOString(), | |
| 59 | ); | |
| 60 | const lines = repos.flatMap((r) => { | |
| 61 | const e = week.get(r.id); | |
| 62 | return e === undefined ? [] : [`${r.name}: ${perShippedWords(e)} this week`]; | |
| 63 | }); | |
| 64 | const more = lines.length - COST_LINES; | |
| 65 | return more <= 0 ? lines : [...lines.slice(0, COST_LINES), `+${more} more app${more === 1 ? "" : "s"}`]; | |
| 66 | } | |
| 67 | ||
| 49 | 68 | /** The ledger rows behind one pile's count today, newest first. */ |
| 50 | 69 | export function deciderRows(app: App, viewer: User | undefined, pile: DeciderPile, now = new Date()): DeciderRow[] { |
| 51 | 70 | const db: Database = app.db; |
| @@ -74,8 +93,10 @@ export function deciderScreen(f: Session, ctx: Ctx, first: DeciderPile = "closed | ||
| 74 | 93 | let pile: DeciderPile = first; |
| 75 | 94 | let rows: DeciderRow[] = []; |
| 76 | 95 | let line: string | null = null; |
| 96 | let costs: string[] = []; | |
| 77 | 97 | const read = (): void => { |
| 78 | 98 | line = deciderLine(f.app, f.user); |
| 99 | costs = efficiencyLines(f.app, f.user); | |
| 79 | 100 | rows = deciderRows(f.app, f.user, pile); |
| 80 | 101 | state.clamp(rows.length); |
| 81 | 102 | }; |
| @@ -100,9 +121,10 @@ export function deciderScreen(f: Session, ctx: Ctx, first: DeciderPile = "closed | ||
| 100 | 121 | load: async () => read(), |
| 101 | 122 | render(area, s) { |
| 102 | 123 | const c = ctx.theme.c; |
| 103 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 124 | const [head, body] = split(area, "v", [len(1 + costs.length), fill(1)]); | |
| 104 | 125 | const sentence = `${line ?? "The decider has done nothing today."} [${pile}]`; |
| 105 | 126 | s.text(head!.x, head!.y, truncate(sentence, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 127 | costs.forEach((t, i) => s.text(head!.x, head!.y + 1 + i, truncate(t, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w)); | |
| 106 | 128 | if (rows.length === 0) { |
| 107 | 129 | emptyState(s, body!, c, { title: PILE_EMPTY[pile], body: "Press 1, 2 or 3 for another pile." }); |
| 108 | 130 | return; |
src/web/pages/board.ts +1 −1
| @@ -95,8 +95,8 @@ import { | ||
| 95 | 95 | type Tone, |
| 96 | 96 | } from "../ui.ts"; |
| 97 | 97 | import { voiceFor } from "../studio/voice.ts"; |
| 98 | import { perShippedWords } from "../../core/efficiency.ts"; | |
| 98 | 99 | import { |
| 99 | perShippedWords, | |
| 100 | 100 | windowWords, |
| 101 | 101 | type FigureWords, |
| 102 | 102 | } from "../studio/words.ts"; |
src/web/studio/words.ts −11
| @@ -19,7 +19,6 @@ import { | ||
| 19 | 19 | import type { AppRecord } from "../../core/apps.ts"; |
| 20 | 20 | import { canEnterApp, getRepoById, type Repo } from "../../core/repos.ts"; |
| 21 | 21 | import type { User } from "../../core/users.ts"; |
| 22 | import type { Efficiency } from "../../core/efficiency.ts"; | |
| 23 | 22 | import type { WindowStanding } from "../../core/standing.ts"; |
| 24 | 23 | import { whenText } from "../../crew/limits.ts"; |
| 25 | 24 | import { reviewWaitsFor, waitsForReviewer } from "../../crew/review.ts"; |
| @@ -603,13 +602,3 @@ export function windowWords(w: WindowStanding): WindowWords { | ||
| 603 | 602 | usedPct, |
| 604 | 603 | }; |
| 605 | 604 | } |
| 606 | ||
| 607 | /** An app's spend per shipped change (`core/efficiency.ts`) in a few words: | |
| 608 | * `$1.37 per shipped change`, or what is true when there is no ratio. */ | |
| 609 | export function perShippedWords(e: Efficiency | undefined): string { | |
| 610 | if (e === undefined || (e.spentUsd === 0 && e.shipped === 0)) | |
| 611 | return "nothing spent or shipped"; | |
| 612 | if (e.perShippedUsd === null) | |
| 613 | return `${dollars(e.spentUsd)} spent, nothing shipped`; | |
| 614 | return `${dollars(e.perShippedUsd)} per shipped change`; | |
| 615 | } |
test/board-windows.test.ts +2 −1
| @@ -25,7 +25,8 @@ import { recordUsage } from "../src/core/usage.ts"; | ||
| 25 | 25 | import { createUser, type User } from "../src/core/users.ts"; |
| 26 | 26 | import { serve, type Servers } from "../src/http/server.ts"; |
| 27 | 27 | import { id } from "../src/kernel/id.ts"; |
| 28 | import { perShippedWords, windowWords } from "../src/web/studio/words.ts"; | |
| 28 | import { perShippedWords } from "../src/core/efficiency.ts"; | |
| 29 | import { windowWords } from "../src/web/studio/words.ts"; | |
| 29 | 30 | |
| 30 | 31 | /** Count the statements a block of work runs, through `db.query`. */ |
| 31 | 32 | async function statements(db: Database, work: () => Promise<void> | void) { |
test/tui-decider-efficiency.test.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | /** | |
| 2 | * The decider's per-app cost line: what one shipped change cost this week, | |
| 3 | * in the board's own words, only for someone holding owner facts. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { openIssue } from "../src/actions/issues.ts"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { efficiencyForRepos, perShippedWords } from "../src/core/efficiency.ts"; | |
| 13 | import type { Repo } from "../src/core/repos.ts"; | |
| 14 | import { createRun } from "../src/core/runs.ts"; | |
| 15 | import { recordUsage } from "../src/core/usage.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 18 | import { deciderScreen, efficiencyLines } from "../src/tui/screens/decider.ts"; | |
| 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { settleApp } from "./helpers/tui.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | /** A repository made by the admin and handed to `owner`, who is not a member. */ | |
| 26 | async function ownedBy(owner: User, name: string): Promise<Repo> { | |
| 27 | const made = await createRepo(app, alice, { name, visibility: "public" }); | |
| 28 | if (!made.ok) throw new Error(String(made.error)); | |
| 29 | app.db.query("UPDATE repo SET owner_id = ?1 WHERE id = ?2").run(owner.id, made.value.id); | |
| 30 | return made.value; | |
| 31 | } | |
| 32 | ||
| 33 | let dir: string; | |
| 34 | let app: App; | |
| 35 | let alice: User; | |
| 36 | let bob: User; | |
| 37 | let repo: Repo; | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | dir = await mkdtemp(join(tmpdir(), "forge-tui-decider-eff-")); | |
| 41 | app = createApp({ dataDir: dir, port: 0 }); | |
| 42 | const mk = async (username: string, first = false) => { | |
| 43 | const r = await createUser(app.db, { | |
| 44 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 45 | password: "a-good-passphrase", adminIfFirst: first, | |
| 46 | }); | |
| 47 | if (!r.ok) throw new Error(r.error); | |
| 48 | return r.value; | |
| 49 | }; | |
| 50 | alice = await mk("alice", true); | |
| 51 | bob = await mk("bob"); | |
| 52 | const made = await createRepo(app, alice, { name: "notes", visibility: "public" }); | |
| 53 | if (!made.ok) throw new Error(String(made.error)); | |
| 54 | repo = made.value; | |
| 55 | const at = new Date().toISOString(); | |
| 56 | for (let n = 0; n < 2; n++) | |
| 57 | recordUsage(app.db, { | |
| 58 | runId: createRun(app.db, { repoId: repo.id, kind: "agent", actorId: alice.id }).id, repoId: repo.id, issueId: null, agentId: null, subscriptionId: null, | |
| 59 | tokensIn: 1, tokensOut: 1, costUsd: 1.5, wallMs: 1, outcome: "ok", at, | |
| 60 | }); | |
| 61 | const issue = openIssue(app, alice, repo, { title: "Shipped" }); | |
| 62 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 63 | app.db | |
| 64 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, 'main', 'b', '', '', ?2, ?3)`) | |
| 65 | .run(issue.value.id, repo.id, at); | |
| 66 | }); | |
| 67 | afterAll(async () => { | |
| 68 | app.close(); | |
| 69 | await rm(dir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | async function screenText(user: User): Promise<string> { | |
| 73 | const terminal = headlessTerminal(120, 30); | |
| 74 | const { app: tui, session } = createForgeApp( | |
| 75 | app, | |
| 76 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 77 | { terminal, exitHooks: false }, | |
| 78 | ); | |
| 79 | tui.start(); | |
| 80 | tui.ctx.push(deciderScreen(session, tui.ctx)); | |
| 81 | await settleApp(tui); | |
| 82 | const text = tui.screen.toText(); | |
| 83 | tui.stop?.(); | |
| 84 | return text; | |
| 85 | } | |
| 86 | ||
| 87 | test("the owner's screen says what a shipped change cost, as the board does", async () => { | |
| 88 | const week = efficiencyForRepos(app.db, [repo.id], new Date(Date.now() - 7 * 86_400_000).toISOString()); | |
| 89 | const words = perShippedWords(week.get(repo.id)); | |
| 90 | expect(words).toBe("$3.00 per shipped change"); | |
| 91 | expect(efficiencyLines(app, alice)).toEqual([`notes: ${words} this week`]); | |
| 92 | expect(await screenText(alice)).toContain(`notes: ${words} this week`); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a reader with no owner facts sees no line", async () => { | |
| 96 | expect(efficiencyLines(app, bob)).toEqual([]); | |
| 97 | expect(efficiencyLines(app, undefined)).toEqual([]); | |
| 98 | expect(await screenText(bob)).not.toContain("per shipped change"); | |
| 99 | }); | |
| 100 | ||
| 101 | test("an owner whose app neither ran nor shipped gets no line", async () => { | |
| 102 | const carol = await createUser(app.db, { | |
| 103 | origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase", | |
| 104 | }); | |
| 105 | if (!carol.ok) throw new Error(carol.error); | |
| 106 | await ownedBy(carol.value, "idle"); | |
| 107 | expect(efficiencyLines(app, carol.value)).toEqual([]); | |
| 108 | }); | |
| 109 | ||
| 110 | test("many apps: three lines then +N more apps, and the pile rows stay on a short terminal", async () => { | |
| 111 | const dave = await createUser(app.db, { | |
| 112 | origin: "seed", trusted: true, username: "dave", email: "[email protected]", password: "a-good-passphrase", | |
| 113 | }); | |
| 114 | if (!dave.ok) throw new Error(dave.error); | |
| 115 | const at = new Date().toISOString(); | |
| 116 | for (let n = 0; n < 5; n++) { | |
| 117 | const made = { value: await ownedBy(dave.value, `app${n}`) }; | |
| 118 | recordUsage(app.db, { | |
| 119 | runId: createRun(app.db, { repoId: made.value.id, kind: "agent", actorId: dave.value.id }).id, repoId: made.value.id, issueId: null, agentId: null, subscriptionId: null, | |
| 120 | tokensIn: 1, tokensOut: 1, costUsd: 1, wallMs: 1, outcome: "ok", at, | |
| 121 | }); | |
| 122 | } | |
| 123 | const lines = efficiencyLines(app, dave.value); | |
| 124 | expect(lines).toHaveLength(4); | |
| 125 | expect(lines[3]).toBe("+2 more apps"); | |
| 126 | ||
| 127 | const terminal = headlessTerminal(80, 12); | |
| 128 | const { app: tui, session } = createForgeApp( | |
| 129 | app, | |
| 130 | { sshUsername: "dave", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: dave.value }, | |
| 131 | { terminal, exitHooks: false }, | |
| 132 | ); | |
| 133 | tui.start(); | |
| 134 | tui.ctx.push(deciderScreen(session, tui.ctx)); | |
| 135 | await settleApp(tui); | |
| 136 | const text = tui.screen.toText(); | |
| 137 | tui.stop?.(); | |
| 138 | expect(text).toContain("+2 more apps"); | |
| 139 | expect(text).toContain("The decider has done nothing today."); | |
| 140 | expect(text).toContain("Press 1, 2 or 3 for another pile."); | |
| 141 | }); |
new above
offline
Unbounded flatMap: cap the lines (3 + a '+N more' line) so a tall head cannot starve the body on a short terminal.