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

On a short terminal the admin's dashboard keeps the crew spend and ceiling line, dropping lesser figures first #9520

Merged opened this

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

Merged

scout/forge:scout/9512-on-a-short-terminal-the-admin-s-dashboar → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/9512-on-a-short-terminal-the-admin-s-dashboar was merged into main as 21bd1b8.

Review

Sign in to review this change.
2 files changed +81 −5
src/tui/screens/dashboard.ts +19 −2
@@ -1063,8 +1063,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10631063 );
10641064 body = rest!;
10651065 }
1066 // A short terminal drops the last figures before it drops the list.
1067 const shown = figures.slice(0, Math.max(0, body.h - FIGURES_ROOM));
1066 // A short terminal drops lesser figures before it drops the list; the spend line goes last.
1067 const shown = keepFigures(figures, body.h - FIGURES_ROOM);
10681068 if (shown.length > 0) {
10691069 const [strip, rest] = split(body, "v", [len(shown.length), fill(1)]);
10701070 shown.forEach((line, i) =>
@@ -1523,6 +1523,23 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
15231523 return screen;
15241524 }
15251525
1526 /**
1527 * The figures a strip of `room` rows keeps, in the order they are drawn:
1528 * the crew spend line (with its ceiling words) is kept ahead of every other
1529 * figure, and the rest fill what room is left from the top (#9512).
1530 */
1531 export function keepFigures(figures: readonly string[], room: number): string[] {
1532 const n = Math.max(0, room);
1533 if (figures.length <= n) return [...figures];
1534 const first = figures.findIndex((line) => line.startsWith(SPEND_LINE));
1535 const order = first < 0 ? figures.map((_, i) => i) : [first, ...figures.map((_, i) => i).filter((i) => i !== first)];
1536 const kept = new Set(order.slice(0, n));
1537 return figures.filter((_, i) => kept.has(i));
1538 }
1539
1540 /** How the crew spend figure begins, as `kpiLines` says it. */
1541 const SPEND_LINE = "Crew spend today";

SPEND_LINE duplicates the literal that kpiLines draws. Share one exported constant so a rewording cannot silently stop protecting the line.

1542
15261543 /** Why the builders are idle, as the one line an admin's home reads; none while they are not. */
15271544 function idleLines(app: App, now: Date): string[] {
15281545 const reason = idleReason(app, now);
test/tui-kpis.test.ts +62 −3
@@ -3,8 +3,12 @@ import { afterAll, beforeAll, expect, test } from "bun:test";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
6 import { addToCrew, createAgent } from "../src/actions/crew.ts";
7 import { createRepo } from "../src/actions/repos.ts";
68 import { createApp, type App as Forge } from "../src/app.ts";
7 import { createUser, type User } from "../src/core/users.ts";
9 import { createRun } from "../src/core/runs.ts";
10 import { keepFigures } from "../src/tui/screens/dashboard.ts";
11 import { createUser, getUserByName, type User } from "../src/core/users.ts";
812 import { readVitals, vitalsLine } from "../src/flow/vitals.ts";
913 import { createForgeApp } from "../src/tui/forge.ts";
1014 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -41,8 +45,8 @@ afterAll(async () => {
4145 await rm(dir, { recursive: true, force: true });
4246 });
4347
44 async function firstScreen(user?: User, host: Forge = forge): Promise<string> {
45 const terminal = headlessTerminal(200, 32);
48 async function firstScreen(user?: User, host: Forge = forge, rows = 32): Promise<string> {
49 const terminal = headlessTerminal(200, rows);
4650 const { app } = createForgeApp(
4751 host,
4852 { sshUsername: user?.username ?? "guest", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", ...(user ? { user } : {}) },
@@ -102,3 +106,58 @@ test("an admin's first screen carries the flow vitals line; a non-admin's does n
102106 expect(text).toContain(vitalsLine(readVitals(forge.db)));
103107 expect(await firstScreen(bob)).not.toContain("open pulls");
104108 });
109
110 test("a short strip keeps the spend line ahead of lesser figures, in the order they are drawn (#9512)", () => {
111 const figures = ["Builders idle", "Week", "Shipped", "Crew spend today $1 of $40", "Vitals"];
112 expect(keepFigures(figures, 2)).toEqual(["Builders idle", "Crew spend today $1 of $40"]);
113 expect(keepFigures(figures, 1)).toEqual(["Crew spend today $1 of $40"]);
114 expect(keepFigures(figures, 0)).toEqual([]);
115 expect(keepFigures(figures, 9)).toEqual(figures);
116 expect(keepFigures(["a", "b", "c"], 2)).toEqual(["a", "b"]);
117 });
118
119 test("with since-lines, moving-lines and idle crew all drawn, the admin still sees the spend and ceiling line, at 32 rows and when trimmed (#9512)", async () => {
120 const d = await mkdtemp(join(tmpdir(), "forge-tui-kpis-full-"));
121 const full = createApp({ dataDir: d, port: 0 });
122 try {
123 const a = await seedAdmin(full, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
124 if (!a.ok) throw new Error("signup failed");
125 const admin = a.value;
126 const made = await createRepo(full, admin, { name: "svc", visibility: "private" });
127 if (!made.ok) throw new Error(String(made.error));
128 // Idle crew: an autonomous repository past its own ceiling.
129 const bot = await createAgent(full, admin, { username: "idlebot" });
130 if (!bot.ok) throw new Error(String(bot.error));
131 expect(addToCrew(full, admin, made.value, "idlebot").ok).toBe(true);
132 full.db
133 .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2, autonomy_ceiling_usd = 1, autonomy_slots = 2 WHERE id = ?1")
134 .run(made.value.id, admin.id);
135 const run = createRun(full.db, { repoId: made.value.id, kind: "agent", actorId: getUserByName(full.db, "idlebot")!.id });
136 full.db.query("UPDATE run SET cost_usd = 5, state = 'ok' WHERE id = ?1").run(run.id);
137 // Since-lines: pulls landed in the last hour.
138 for (const n of [1, 2, 3, 4]) {
139 const id = `since-${n}`;
140 full.db
141 .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?1, ?2, ?3, 'pull', ?4, ?5)")
142 .run(id, made.value.id, 100 + n, `landed ${n}`, admin.id);
143 full.db
144 .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', 'h', 'a', 'b', ?2)")
145 .run(id, new Date(Date.now() - n * 60_000).toISOString());
146 }
147 const capped = { ...full, config: { ...full.config, crewCeilingUsd: 40 } };
148 const text = await withCommit(undefined, () => firstScreen(admin, capped));
149 expect(text).toContain("Since you were here");
150 expect(text).toContain("Being built");
151 expect(text).toContain("Builders idle");
152 expect(text).toContain("Crew spend today $5.00 of $40");
153 // Shorter still, the strip is trimmed: lesser figures go, the spend line and the list stay.
154 const short = await withCommit(undefined, () => firstScreen(admin, capped, 20));
155 expect(short).toContain("Being built");
156 expect(short).toContain("Crew spend today $5.00 of $40");
157 expect(short).not.toContain("Reporting browsers");
158 expect(short).toContain("alice/svc");
159 } finally {
160 full.close();
161 await rm(d, { recursive: true, force: true });
162 }
163 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs