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

The terminal's Usage screen opens the fleet graph with `f`: your repositories' spend and runs per hour over 24 h and per day over 14 d #8059

Merged opened this

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

Merged

torque/forge:torque/8048-the-terminal-s-usage-screen-opens-the-fl → main 3 commits fork 4 threads · 4 unresolved

  • Merged. torque/8048-the-terminal-s-usage-screen-opens-the-fl was merged into main as a9e9a94.

Review

Sign in to review this change.
4 files changed +295 −1
README.md +1 −1
@@ -501,7 +501,7 @@ This page is the front door. Everything else is a file of its own under
501501 - [`docs/DESIGN.md`](docs/DESIGN.md) — the design system's spine; its sections are under `docs/design/sections/`.
502502 - [`docs/SPIKE-CLOUDFLARE.md`](docs/SPIKE-CLOUDFLARE.md), [`docs/AUDIT-MANY-HANDS.md`](docs/AUDIT-MANY-HANDS.md), [`docs/BACKLOG.md`](docs/BACKLOG.md) and [`docs/research/`](docs/research/) — a spike, an audit, a backlog and research notes, kept as written.
503503
504 What the crew's tokens bought is on the board (`/board/returns`), in MCP `returns`, and in the terminal: the palette's Usage carries a Returns line (dollars, runs, changes landed, cost per landed change; `[`/`]` move between 1, 7, 30 and 90 days), and Usage of a repository (`owner/name`) narrows it to one app. Owner facts only (`src/actions/returns.ts`); see [`docs/OPERATING.md`](docs/OPERATING.md).
504 What the crew's tokens bought is on the board (`/board/returns`), in MCP `returns`, and in the terminal: the palette's Usage carries a Returns line (dollars, runs, changes landed, cost per landed change; `[`/`]` move between 1, 7, 30 and 90 days), and Usage of a repository (`owner/name`) narrows it to one app. On the forge-wide Usage, `f` opens the fleet graph (what `/fleet/graph` shows): one sparkline row per repository for runs and for spend, each with its total at the right, over 24 h by hour or 14 d by day (`w` toggles); past eight series the smallest share "N others", and a short terminal ends with a "+N more rows" line. Who may see spend is the action's rule (`src/actions/fleet-graph.ts`), not the screen's. Owner facts only (`src/actions/returns.ts`); see [`docs/OPERATING.md`](docs/OPERATING.md).
505505
506506 The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do, and on a token revokes it after a confirm (Keep is the default) through the same `deleteToken` action as Settings and the API, so it stops working at once (`docs/CREW.md`, "Where you are signed in").
507507
src/tui/screens/fleetgraph.ts added +87
@@ -0,0 +1,87 @@
1 /**
2 * The fleet graph in the terminal: your repositories' runs and spend, one
3 * sparkline row per series (`FLEET_GRAPH_MAX_SERIES` at most, the rest folded
4 * as "N others"), over the last 24 hours by hour or 14 days by day, `w`
5 * toggling. It reads `readFleetGraph`, the page's, CSV's and API's own data,
6 * so who may see spend is that action's rule, never this screen's.
7 */
8
9 import { FLEET_GRAPH_MAX_SERIES, readFleetGraph, type FleetWindow, type FleetWindowName } from "../../actions/fleet-graph.ts";
10 import { usd } from "../../core/money.ts";
11 import { spark } from "../../system/format.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
14 import type { Session } from "../session.ts";
15 import { style } from "../term/style.ts";
16 import { pad, truncate } from "../term/width.ts";
17 import { fill, len, split } from "../ui/layout.ts";
18
19 /** The graph is never narrower than this many columns. */
20 const MIN_GRAPH = 10;
21 /** A name is never padded past this, so the rows stay together on a wide terminal. */
22 const NAME_MAX = 32;
23
24 /** At most `n` readings, each the sum of its stretch, so a narrow terminal still shows the whole window. */
25 function shrink(xs: readonly number[], n: number): number[] {
26 if (xs.length <= n) return [...xs];
27 return Array.from({ length: n }, (_, i) =>
28 xs.slice(Math.floor((i * xs.length) / n), Math.floor(((i + 1) * xs.length) / n)).reduce((a, b) => a + b, 0),
29 );
30 }
31
32 /** One row: name, sparkline scaled to its own peak, total at the right; the graph keeps `MIN_GRAPH` columns, the name gives way. */
33 export function graphRow(label: string, xs: readonly number[], total: string, width: number): string {
34 const totalW = total.length;
35 const graphW = Math.max(MIN_GRAPH, Math.min(xs.length, width - totalW - 2 - 8));
36 const nameW = Math.max(0, Math.min(NAME_MAX, width - graphW - totalW - 2));
37 const cells = shrink(xs, graphW);
38 const peak = Math.max(0, ...cells);
39 const line = spark(cells.map((x) => (peak > 0 ? (x / peak) * 100 : 0)));
40 return `${pad(truncate(label, nameW), nameW)} ${pad(line, graphW)} ${total}`;
41 }
42
43 /** The screen's lines for one window: runs, then spend; or the empty sentence. */
44 export function fleetLines(w: FleetWindow, labels: readonly string[], width: number): { runs: string[]; spend: string[] } | undefined {
45 const any = w.totals.runs.some((n) => n > 0) || w.totals.costUsd.some((n) => n > 0);
46 if (!any) return undefined;
47 const rows = (pick: "runs" | "costUsd", say: (n: number) => string): string[] => {
48 const totals = labels.map((_, i) => say(w.totals[pick][i]!));
49 const wide = Math.max(0, ...totals.map((t) => t.length));
50 return labels.map((label, i) => graphRow(label, w.buckets.map((b) => b[pick][i]!), pad(totals[i]!, wide, "right"), width));
51 };
52 return { runs: rows("runs", (n) => `${n} ${n === 1 ? "run" : "runs"}`), spend: rows("costUsd", usd) };
53 }
54
55 export function fleetGraphScreen(f: Session, ctx: Ctx): Screen {
56 let which: FleetWindowName = "24h";
57 // Read once when the screen opens; a frame or a key never re-runs the ledger scan.
58 const graph = f.user === undefined ? undefined : readFleetGraph(f.app, f.user, null);
59 const labels = (graph?.series ?? []).slice(0, FLEET_GRAPH_MAX_SERIES).map((s) => s.label);
60 return {
61 title: "Fleet graph",
62 keys: [binding(["w"], "24 h / 14 d")],
63 render(area, s) {
64 const c = ctx.theme.c;
65 const w = graph?.windows[which === "24h" ? 0 : 1];
66 const [head, body] = split(area, "v", [len(1), fill(1)]);
67 const unit = which === "24h" ? "per hour over 24 h" : "per day over 14 d";
68 s.text(head!.x, head!.y, truncate(`Runs and spend ${unit} (w: 24 h / 14 d), UTC`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
69 const lines = w === undefined ? undefined : fleetLines(w, labels, body!.w);
70 const out: [string, boolean][] =
71 lines === undefined
outdated

Rows beyond body.h are dropped silently on a short terminal; add an overflow line or scrolling.

72 ? [["Nothing spent in this window.", false]]
73 : [["Runs", true], ...lines.runs.map((l): [string, boolean] => [l, false]), ["", false], ["Spend", true], ...lines.spend.map((l): [string, boolean] => [l, false])];
74 // A short terminal says what it cut rather than dropping rows silently.
75 const shown: [string, boolean][] =
76 out.length <= body!.h ? out : [...out.slice(0, Math.max(0, body!.h - 1)), [`+${out.length - Math.max(0, body!.h - 1)} more rows (make the terminal taller)`, false]];
77 shown.forEach(([text, heading], i) => {
78 s.text(body!.x, body!.y + i, truncate(text, body!.w), style(heading ? c.fg : c.mutedFg, c.bg), body!.x + body!.w);
79 });
80 },
81 key(e) {
82 if (e.ctrl || e.alt || e.key !== "w") return false;
83 which = which === "24h" ? "14d" : "24h";
84 return true;
85 },
86 };
87 }
src/tui/screens/usage.ts +6
@@ -26,6 +26,7 @@ import { style } from "../term/style.ts";
2626 import { truncate } from "../term/width.ts";
2727 import type { Session } from "../session.ts";
2828 import { fill, len, split } from "../ui/layout.ts";
29 import { fleetGraphScreen } from "./fleetgraph.ts";
2930 import { ListState, drawList, type ListRow } from "./common.ts";
3031
3132 const part = (counts: Readonly<Record<string, number>>, keys: readonly string[]) =>
@@ -73,6 +74,7 @@ export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen {
7374 keys: [
7475 binding(["j", "k"], "move cursor", { hidden: rows.length === 0 }),
7576 binding(["[", "]"], "returns window", { hidden: returns === undefined }),
77 binding(["f"], "fleet graph", { hidden: repo !== undefined }),

Hidden on a repository's Usage, but no test proves f does nothing there. Add one, and document f in the README.

7678 ],
7779 render(area, s) {
7880 const c = ctx.theme.c;
@@ -91,6 +93,10 @@ export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen {
9193 if (repo === undefined) drawList(s, list!, c, state, usageRows(all));
9294 },
9395 key: (e) => {
96 if (repo === undefined && !e.ctrl && !e.alt && e.key === "f") {
97 ctx.push(fleetGraphScreen(f, ctx));
98 return true;
99 }
94100 if (returns !== undefined && !e.ctrl && !e.alt && (e.key === "[" || e.key === "]")) {
95101 const next = Math.max(0, Math.min(RETURNS_SPANS.length - 1, span + (e.key === "]" ? 1 : -1)));
96102 if (next === span) return true;
test/tui-fleetgraph.test.ts added +201
@@ -0,0 +1,201 @@
1 /**
2 * The terminal's fleet graph (#8048): `f` on Usage opens it; with usage seeded in two
3 * repositories the screen names both and their totals are `readFleetGraph`'s; `w` moves
4 * between 24 h and 14 d; an empty ledger, or a viewer who may not see the spend, reads
5 * "Nothing spent in this window."
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 { readFleetGraph } from "../src/actions/fleet-graph.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { createApp, type App as Forge } from "../src/app.ts";
14 import { usd } from "../src/core/money.ts";
15 import { createRun } from "../src/core/runs.ts";
16 import { recordUsage } from "../src/core/usage.ts";
17 import { createUser, getUserByName } from "../src/core/users.ts";
18 import { createForgeApp } from "../src/tui/forge.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 let dataDir: string;
26 let forge: Forge;
27 const ids: Record<string, string> = {};
28
29 function boot(username: string, cols = 120, rows = 32, on: Forge = forge) {
30 const user = getUserByName(on.db, username)!;
31 const { app } = createForgeApp(
32 on,
33 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
34 { terminal: headlessTerminal(cols, rows), exitHooks: false },
35 );
36 app.start();
37 const key = async (k: string, ctrl = false) => {
38 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
39 await settleApp(app);
40 };
41 return {
42 text: () => app.screen.toText(),
43 key,
44 open: async () => {
45 await key("k", true);
46 for (const ch of "Usage") await key(ch);
47 await key("enter");
48 await key("f");
49 },
50 /** The palette's "Usage of a repository" for `spec`. */
51 openRepoUsage: async (spec: string) => {
52 await key("k", true);
53 for (const ch of "Usage of a repository") await key(ch);
54 await key("enter");
55 for (const ch of spec) await key(ch);
56 await key("enter");
57 },
58 stop: () => app.stop(),
59 };
60 }
61
62 function spend(repo: string, usdSpent: number, msAgo: number): void {
63 recordUsage(forge.db, {
64 runId: createRun(forge.db, { repoId: ids[repo]!, kind: "agent" }).id,
65 repoId: ids[repo]!, issueId: null, agentId: null, subscriptionId: null,
66 tokensIn: 1, tokensOut: 1, costUsd: usdSpent, wallMs: 1, outcome: "ok",
67 at: new Date(Date.now() - msAgo).toISOString(),
68 });
69 }
70
71 beforeAll(async () => {
72 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-fleetgraph-"));
73 forge = createApp({ dataDir, port: 0 });
74 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
75 const made = await createUser(forge.db, {
76 origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst,
77 });
78 if (!made.ok) throw new Error(made.error);
79 }
80 const alice = getUserByName(forge.db, "alice")!;
81 for (const name of ["mill", "kiln"]) {
82 const made = await createRepo(forge, alice, { name, visibility: "public" });
83 if (!made.ok) throw new Error(String(made.error));
84 ids[name] = made.value.id;
85 }
86 spend("mill", 2.5, 60_000);
87 spend("mill", 1, 60_000);
88 spend("kiln", 4, 60_000);
89 spend("kiln", 0.25, 3 * 86_400_000); // only in the 14-day window
90 }, 30_000);
91 afterAll(async () => {
92 forge.close();
93 await rm(dataDir, { recursive: true, force: true });
94 });
95
96 test("f opens the graph: both repositories, totals as readFleetGraph answers, w moves to 14 d", async () => {
97 const graph = readFleetGraph(forge, getUserByName(forge.db, "alice")!, null);
98 const h = boot("alice");
99 await h.open();
100 const day = h.text();
101 expect(day).toContain("alice/mill");
102 expect(day).toContain("alice/kiln");
103 graph.series.forEach((s, i) => {
104 expect(day).toContain(usd(graph.windows[0].totals.costUsd[i]!));
105 expect(day).toContain(`${graph.windows[0].totals.runs[i]} ${graph.windows[0].totals.runs[i] === 1 ? "run" : "runs"}`);
106 expect(s.label).toBeTruthy();
107 });
108 expect(day).toContain("$4.00");
109 expect(day).toContain("$3.50");
outdated

Vacuous: bob cannot reach Usage, so these assertions hold with or without the feature. Test a path that reaches the empty-state sentence, or assert bob never gets the screen.

110 expect(day).toContain("over 24 h");
111 await h.key("w");
112 const fortnight = h.text();
113 expect(fortnight).toContain("over 14 d");
114 expect(fortnight).toContain("$4.25");
115 expect(fortnight).toContain("2 runs");
116 await h.key("w");
117 expect(h.text()).toContain("over 24 h");
118 h.stop();
119 });
120
121 test("someone who may not see the spend gets nothing: the action's rule", () => {
122 const graph = readFleetGraph(forge, getUserByName(forge.db, "bob")!, null);
123 expect(graph.repos).toEqual([]);
outdated

Only checks one glyph; assert every line is <= 44 columns and the name is truncated.

124 expect(graph.windows[0].totals.costUsd).toEqual([]);
125 });
126
127 test("f does nothing on a repository's Usage", async () => {
128 const h = boot("alice");
129 await h.openRepoUsage("alice/mill");
130 expect(h.text()).toContain("Usage · alice/mill");
131 expect(h.text()).not.toContain("fleet graph");
132 await h.key("f");
133 expect(h.text()).toContain("Usage · alice/mill");
134 expect(h.text()).not.toContain("over 24 h");
135 h.stop();
136 });
137
138 /** A fresh forge with an admin `alice` and `n` repositories, each having spent a little. */
139 async function fresh(n: number, stem = "app"): Promise<{ on: Forge; done: () => Promise<void> }> {
140 const dir = await mkdtemp(join(tmpdir(), "forge-tui-fleetgraph-x-"));
141 const on = createApp({ dataDir: dir, port: 0 });
142 const made = await createUser(on.db, {
143 origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true,
144 });
145 if (!made.ok) throw new Error(made.error);
146 const alice = getUserByName(on.db, "alice")!;
147 for (let i = 0; i < n; i++) {
148 const repo = await createRepo(on, alice, { name: `${stem}${i}`, visibility: "public" });
149 if (!repo.ok) throw new Error(String(repo.error));
150 recordUsage(on.db, {
151 runId: createRun(on.db, { repoId: repo.value.id, kind: "agent" }).id,
152 repoId: repo.value.id, issueId: null, agentId: null, subscriptionId: null,
153 tokensIn: 1, tokensOut: 1, costUsd: 1 + i, wallMs: 1, outcome: "ok",
154 at: new Date(Date.now() - 60_000).toISOString(),
155 });
156 }
157 return { on, done: async () => { on.close(); await rm(dir, { recursive: true, force: true }); } };
158 }
159
160 test("an empty ledger reads Nothing spent in this window.", async () => {
161 const { on, done } = await fresh(0);
162 const h = boot("alice", 120, 32, on);
163 await h.open();
164 expect(h.text()).toContain("Nothing spent in this window.");
165 h.stop();
166 await done();
167 });
168
169 test("past eight series the smallest share \"N others\"", async () => {
170 const { on, done } = await fresh(10);
171 const h = boot("alice", 120, 40, on);
172 await h.open();
173 expect(h.text()).toContain("3 others");
174 expect(h.text()).toContain("alice/app9");
175 expect(h.text()).not.toContain("alice/app0");
176 h.stop();
177 await done();
178 });
179
180 test("a short terminal says how many rows it cut", async () => {
181 const { on, done } = await fresh(10);
182 const h = boot("alice", 120, 14, on);
183 await h.open();
184 expect(h.text()).toMatch(/\+\d+ more rows/);
185 h.stop();
186 await done();
187 });
188
189 test("at 44 columns every line fits and the long name is cut", async () => {
190 const { on, done } = await fresh(2, "a-rather-long-repository-name-");
191 const h = boot("alice", 44, 32, on);
192 await h.open();
193 const lines = h.text().split("\n");
194 for (const l of lines) expect([...l].length).toBeLessThanOrEqual(44);
195 const rows = lines.filter((l) => l.includes("▁") || l.includes("█"));
196 expect(rows.length).toBeGreaterThan(0);
197 expect(h.text()).toContain("…");
198 expect(h.text()).not.toContain("a-rather-long-repository-name-1");
199 h.stop();
200 await done();
201 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs