Public: anyone can read this; the crew can change it.
The terminal's Usage screen, opened from the dashboard with no repository, shows the forge's spend today, in the last 24 h and each subscription's headroom #8341
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8308-the-terminal-s-usage-screen-opened-from → main 2 commits fork 4 threads · 4 unresolved
6 files
changed +191
−9
docs/features/forge-usage-terminal.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # The forge's spend in the terminal | |
| 2 | ||
| 3 | Press `:` (or Ctrl-K), type `Usage` and press enter, with no repository named. Any signed-in person or agent gets the same lines the MCP `usage` tool answers without a repo: | |
| 4 | ||
| 5 | - **Today** — dollars and runs since midnight. | |
| 6 | - **Last 24 h** — dollars and runs over the last 24 hours. | |
| 7 | - **Headroom** — one line per subscription, each window in percent free (`no limit named` when the account never said its limit). | |
| 8 | ||
| 9 | The spend is only of the apps you write: a member sees their own, an admin sees the forge's. A signed-out visitor is not offered the entry. | |
| 10 | ||
| 11 | Below the spend, an admin who is not an agent also sees every route, unused ones first; anyone else is told that list is an admin's. `[` and `]` move the Returns window, `f` opens the fleet graph. A repository's Usage (`Usage of a repository`) is unchanged and has no such lines. |
src/tui/forge.ts +3 −3
| @@ -414,12 +414,12 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 414 | 414 | hint: "internal hosts an origin may reach", |
| 415 | 415 | run: () => ctx.push(trustedHostsPrompt(session, ctx, blank("Trusted hosts"))), |
| 416 | 416 | }); |
| 417 | // Settings → Usage, as the page has it: the same gate as Flags (#1565). | |
| 418 | if (mayManageFlags(session.user)) | |
| 417 | // Usage: the forge's spend for any signed-in user (#8308); the route list stays the page's gate, the same as Flags (#1565). | |
| 418 | if (session.user !== undefined) | |
| 419 | 419 | commands.push({ |
| 420 | 420 | id: "usage", |
| 421 | 421 | title: "Usage", |
| 422 | hint: "every route, unused ones first", | |
| 422 | hint: "the forge's spend and headroom; an admin also sees every route", | |
| 423 | 423 | run: () => ctx.push(usageScreen(session, ctx)), |
| 424 | 424 | }); |
| 425 | 425 | // What the forge spent across the apps you write, as GET /api/v1/usage and MCP `usage` answer. |
src/tui/screens/usage.ts +19 −3
| @@ -2,8 +2,8 @@ | ||
| 2 | 2 | * Usage in the terminal, a forge admin's (#1565): every route pattern with |
| 3 | 3 | * its requests over the last 30 days by role and by surface, unused ones |
| 4 | 4 | * first with their last use or `never`, as Settings → Usage lists them |
| 5 | * (`routeUsage`). The gate is the page's: an admin who is not an agent; | |
| 6 | * anyone else sees nothing. | |
| 5 | * (`routeUsage`). The routes are the page's gate: an admin who is not an agent; | |
| 6 | * anyone else is told so, and still sees the spend block below. | |
| 7 | 7 | * |
| 8 | 8 | * Above it, what the crew's tokens bought (#7271), as MCP `returns` reads |
| 9 | 9 | * it (`readReturns`): dollars, runs, changes landed and cost per change over |
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | 14 | import { USAGE_ADMIN_ONLY } from "../../actions/admin_limits_words.ts"; |
| 15 | import { readForgeUsage, type ForgeUsage } from "../../actions/usage.ts"; | |
| 15 | 16 | import { readReturns } from "../../actions/returns.ts"; |
| 16 | 17 | import { mayManageFlags } from "../../core/flags.ts"; |
| 17 | 18 | import { ROLES, SURFACES } from "../../core/usage_count.ts"; |
| @@ -55,6 +56,18 @@ export function returnsLine(r: Returns, days: number): string { | ||
| 55 | 56 | }`; |
| 56 | 57 | } |
| 57 | 58 | |
| 59 | /** The forge-wide block: today, the last 24 h, then each subscription's headroom in percent on its own line. */ | |
| 60 | export function forgeSpendLines(r: ForgeUsage): string[] { | |
| 61 | const span = (x: { costUsd: number; runs: number }) => `${usd(x.costUsd)} over ${plural(x.runs, "run", "runs")}`; | |
| 62 | const room = r.subscriptions.map((s) => { | |
| 63 | const w = s.headroom.map((h) => `${h.window} ${h.pct === null ? "no limit named" : `${h.pct}%`}`).join(", "); | |
| 64 | return `${s.subscription} ${w === "" ? "no window yet" : w}`; | |
| 65 | }); | |
| 66 | // One subscription a line, so a narrow terminal truncates one, never drops the later ones. | |
| 67 | const head = room.length === 0 ? ["no subscription"] : room; | |
| 68 | return [`Today: ${span(r.today)}`, `Last 24 h: ${span(r.last24h)}`, ...head.map((x, i) => `${i === 0 ? "Headroom: " : " "}${x}`)]; | |
| 69 | } | |
| 70 | ||
| 58 | 71 | export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen { |
| 59 | 72 | const state = new ListState(); |
| 60 | 73 | const allowed = repo === undefined && mayManageFlags(f.user); |
| @@ -69,6 +82,8 @@ export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen { | ||
| 69 | 82 | return r.ok ? returnsLine(r.value, days) : undefined; |
| 70 | 83 | }; |
| 71 | 84 | let returns = read(); |
| 85 | // Forge-wide only, read once when the screen opens; readForgeUsage narrows to what this user can see. | |
| 86 | const spend = repo === undefined && f.user !== undefined ? forgeSpendLines(readForgeUsage(f.app, f.user)) : []; | |
| 72 | 87 | return { |
| 73 | 88 | title: repo === undefined ? "Usage" : `Usage · ${repo.ownerName}/${repo.name}`, |
| 74 | 89 | keys: [ |
| @@ -78,7 +93,8 @@ export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen { | ||
| 78 | 93 | ], |
| 79 | 94 | render(area, s) { |
| 80 | 95 | const c = ctx.theme.c; |
| 81 | const [top, head, list] = split(area, "v", [len(returns === undefined ? 0 : 1), len(1), fill(1)]); | |
| 96 | const [spendArea, top, head, list] = split(area, "v", [len(spend.length), len(returns === undefined ? 0 : 1), len(1), fill(1)]); | |
| 97 | spend.forEach((line, i) => s.text(spendArea!.x, spendArea!.y + i, truncate(line, spendArea!.w), style(c.fg, c.bg), spendArea!.x + spendArea!.w)); | |
| 82 | 98 | if (returns !== undefined) |
| 83 | 99 | s.text(top!.x, top!.y, truncate(returns, top!.w), style(c.fg, c.bg), top!.x + top!.w); |
| 84 | 100 | const all = rows; |
outdated
Each line is truncated to the width; with several subscriptions the Headroom line loses the later ones silently on a narrow terminal. One subscription per line, or wrap. |
test/palette-roster.test.ts +3 −1
| @@ -88,7 +88,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 88 | 88 | ["flags", "Flags", "declared flags and their live values"], |
| 89 | 89 | ["secrets", "Secrets", "sealed secrets by name; set or remove one"], |
| 90 | 90 | ["trusted-hosts", "Trusted hosts", "internal hosts an origin may reach"], |
| 91 | ["usage", "Usage", "every route, unused ones first"], | |
| 91 | ["usage", "Usage", "the forge's spend and headroom; an admin also sees every route"], | |
| 92 | 92 | ["crew-scorecard", "Crew scorecard", "agent runs per model and per prompt version"], |
| 93 | 93 | ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"], |
| 94 | 94 | ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"], |
| @@ -153,6 +153,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 153 | 153 | ["stalled", "Stalled steps", "pulls whose check is red or was never asked for; enter re-runs"], |
| 154 | 154 | ["file-holds", "Held files", "hold new work on a file, release it, or list the holds"], |
| 155 | 155 | ["secrets", "Secrets", "sealed secrets by name; set or remove one"], |
| 156 | ["usage", "Usage", "the forge's spend and headroom; an admin also sees every route"], | |
| 156 | 157 | ["crew-scorecard", "Crew scorecard", "agent runs per model and per prompt version"], |
| 157 | 158 | ["crew-handover", "Crew hand-overs", "issues parked twice, and the last restore drill"], |
| 158 | 159 | ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"], |
| @@ -205,6 +206,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 205 | 206 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
| 206 | 207 | ["stalled", "Stalled steps", "pulls whose check is red or was never asked for; enter re-runs"], |
| 207 | 208 | ["file-holds", "Held files", "hold new work on a file, release it, or list the holds"], |
| 209 | ["usage", "Usage", "the forge's spend and headroom; an admin also sees every route"], | |
| 208 | 210 | ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"], |
| 209 | 211 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 210 | 212 | ["model", "Model budget", "what your crew may spend, and add your own subscription"], |
test/tui-usage-forge.test.ts added +152
| @@ -0,0 +1,152 @@ | ||
| 1 | /** | |
| 2 | * The terminal's Usage screen with no repository says the forge's spend | |
| 3 | * (#8308), as MCP `usage` without a repo answers it: today, the last 24 h | |
| 4 | * and each subscription's headroom in percent. A repository's Usage is | |
| 5 | * unchanged: it has no such lines. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { readForgeUsage } from "../src/actions/usage.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import { noteLimit } from "../src/core/standing.ts"; | |
| 16 | import { addSubscription, EVERYONE, setGrants } from "../src/core/subscriptions.ts"; | |
| 17 | import { createRun } from "../src/core/runs.ts"; | |
| 18 | import { recordUsage } from "../src/core/usage.ts"; | |
| 19 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 20 | import { forgeSpendLines } from "../src/tui/screens/usage.ts"; | |
| 21 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 22 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | import { settleApp } from "./helpers/tui.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | let dataDir: string; | |
| 29 | let forge: Forge; | |
| 30 | ||
| 31 | function boot(username: string) { | |
| 32 | const user = getUserByName(forge.db, username)!; | |
| 33 | const { app } = createForgeApp( | |
| 34 | forge, | |
| 35 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 36 | { terminal: headlessTerminal(160, 32), exitHooks: false }, | |
| 37 | ); | |
| 38 | app.start(); | |
| 39 | const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 40 | return { | |
| 41 | text: () => app.screen.toText(), | |
| 42 | open: async (command: string, spec?: string) => { | |
| 43 | key("k", true); | |
| 44 | for (const ch of command) key(ch); | |
| 45 | await settleApp(app); | |
| 46 | key("enter"); | |
| 47 | await settleApp(app); | |
| 48 | if (spec !== undefined) { | |
| 49 | for (const ch of spec) key(ch); | |
| 50 | await settleApp(app); | |
| 51 | key("enter"); | |
| 52 | await settleApp(app); | |
| 53 | } | |
| 54 | }, | |
| 55 | stop: () => app.stop(), | |
| 56 | }; | |
| 57 | } | |
| 58 | ||
| 59 | function runCost(repo: Repo, cost: number, sub: string): void { | |
| 60 | recordUsage(forge.db, { | |
| 61 | runId: createRun(forge.db, { repoId: repo.id, kind: "agent" }).id, repoId: repo.id, issueId: null, agentId: null, | |
| 62 | subscriptionId: sub, tokensIn: 100, tokensOut: 10, costUsd: cost, wallMs: 1000, outcome: "ok", at: new Date().toISOString(), | |
| 63 | }); | |
| 64 | } | |
| 65 | ||
| 66 | beforeAll(async () => { | |
| 67 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-usage-forge-")); | |
| 68 | forge = createApp({ dataDir, port: 0 }); | |
| 69 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 70 | const made = await createUser(forge.db, { | |
| 71 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst, | |
| 72 | }); | |
| 73 | if (!made.ok) throw new Error(made.error); | |
| 74 | } | |
| 75 | const alice = getUserByName(forge.db, "alice")!; | |
| 76 | const sub = addSubscription(forge.db, dataDir, { name: "work", token: "sk-ant-api03-work-key-for-the-forge-test" }); | |
| 77 | if (!sub.ok) throw new Error("subscription"); | |
| 78 | noteLimit(forge.db, sub.value.id, { | |
| 79 | window: "5h", resetsAt: new Date(Date.now() + 3_600_000).toISOString(), observedAt: new Date().toISOString(), said: "limit", | |
| 80 | }); | |
| 81 | for (const [name, cost] of [["mill", 1.25], ["kiln", 2]] as const) { | |
| 82 | const made = await createRepo(forge, alice, { name, visibility: "public" }); | |
| 83 | if (!made.ok) throw new Error(String(made.error)); | |
| 84 | await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "README.md", content: "# x\n" }] }); | |
| 85 | setGrants(forge.db, made.value.id, EVERYONE, [sub.value.id]); | |
| 86 | runCost(made.value, cost, sub.value.id); | |
| 87 | } | |
| 88 | }, 30_000); | |
| 89 | afterAll(async () => { | |
| 90 | forge.close(); | |
| 91 | await rm(dataDir, { recursive: true, force: true }); | |
| 92 | }); | |
| 93 | ||
| 94 | test("Usage with no repository draws today, the last 24 h and each subscription's headroom", async () => { | |
| 95 | const alice = getUserByName(forge.db, "alice")!; | |
| 96 | const report = readForgeUsage(forge, alice); | |
| 97 | expect(report.apps).toBe(2); | |
| 98 | expect(report.subscriptions.length).toBeGreaterThan(0); | |
| 99 | const h = boot("alice"); | |
| 100 | await h.open("Usage"); | |
| 101 | const text = h.text(); | |
| 102 | expect(text).toContain("Today: $3.25 over 2 runs"); | |
| 103 | expect(text).toContain("Last 24 h: $3.25 over 2 runs"); | |
| 104 | expect(text).toContain("Headroom: "); | |
| 105 | for (const s of report.subscriptions) expect(text).toContain(s.subscription); | |
| 106 | expect(text).toContain("work 5h 0%"); | |
| 107 | h.stop(); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a repository's Usage has none of those lines", async () => { | |
| 111 | const h = boot("alice"); | |
| 112 | await h.open("Usage of a repository", "alice/mill"); | |
| 113 | const text = h.text(); | |
| 114 | expect(text).not.toContain("Headroom:"); | |
| 115 | expect(text).not.toContain("Last 24 h:"); | |
| 116 | h.stop(); | |
| 117 | }); | |
| 118 | ||
| 119 | test("a signed-in member reaches the lines, and not the admin's spend", async () => { | |
| 120 | const h = boot("bob"); | |
| 121 | await h.open("Usage"); | |
| 122 | const text = h.text(); | |
| 123 | expect(text).toContain("Today: $0.00 over 0 runs"); | |
| 124 | expect(text).not.toContain("$3.25"); | |
| 125 | expect(text).toContain("Headroom: "); | |
| 126 | expect(text).not.toContain("routes had no request"); | |
| 127 | h.stop(); | |
| 128 | }); | |
| 129 | ||
| 130 | test("a guest is offered no Usage and the screen draws no spend lines", () => { | |
| 131 | const { app } = createForgeApp( | |
| 132 | forge, | |
| 133 | { sshUsername: "git", remoteAddress: "127.0.0.1" }, | |
| 134 | { terminal: headlessTerminal(160, 32), exitHooks: false }, | |
| 135 | ); | |
| 136 | app.start(); | |
| 137 | app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 138 | for (const ch of "Usage") app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 139 | const text = app.screen.toText(); | |
| 140 | expect(text).not.toContain("Today:"); | |
| 141 | expect(text).not.toContain("the forge's spend and headroom"); | |
| 142 | app.stop(); | |
| 143 | }); | |
| 144 | ||
| 145 | test("each subscription's headroom has its own line", () => { | |
| 146 | const w = { window: "5h", pct: 40 }; | |
| 147 | const sub = (subscription: string) => ({ subscription, headroom: [w] }); | |
| 148 | const lines = forgeSpendLines({ | |
| 149 | today: { costUsd: 0, runs: 0 }, last24h: { costUsd: 0, runs: 0 }, subscriptions: [sub("a"), sub("b"), sub("c")], | |
| 150 | } as never); | |
| 151 | expect(lines.slice(2)).toEqual(["Headroom: a 5h 40%", " b 5h 40%", " c 5h 40%"]); | |
| 152 | }); |
test/tui-usage.test.ts +3 −2
| @@ -72,11 +72,12 @@ test("an admin's terminal lists unused routes first, with never, and the count l | ||
| 72 | 72 | h.stop(); |
| 73 | 73 | }); |
| 74 | 74 | |
| 75 | test("a non-admin and an admin agent have no command", async () => { | |
| 75 | test("a non-admin and an admin agent see the spend and are told the routes are an admin's", async () => { | |
| 76 | 76 | for (const who of ["bob", "carol"]) { |
| 77 | 77 | const h = boot(who); |
| 78 | 78 | await h.palette("Usage"); |
| 79 | expect(h.text()).not.toContain("every route, unused ones first"); | |
| 79 | await h.enter(); | |
| 80 | expect(h.text()).not.toContain("routes had no request"); | |
Non-blocking: also |
||
| 80 | 81 | h.stop(); |
| 81 | 82 | } |
| 82 | 83 | }); |
new above
offline
Non-blocking:
spendis read once at open and never refreshed, so a terminal left open shows stale dollars. Re-read it wherereturnsis re-read.