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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9512-on-a-short-terminal-the-admin-s-dashboar → main 2 commits fork 1 thread · 1 unresolved
2 files
changed +81
−5
src/tui/screens/dashboard.ts +19 −2
| @@ -1063,8 +1063,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1063 | 1063 | ); |
| 1064 | 1064 | body = rest!; |
| 1065 | 1065 | } |
| 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); | |
| 1068 | 1068 | if (shown.length > 0) { |
| 1069 | 1069 | const [strip, rest] = split(body, "v", [len(shown.length), fill(1)]); |
| 1070 | 1070 | shown.forEach((line, i) => |
| @@ -1523,6 +1523,23 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1523 | 1523 | return screen; |
| 1524 | 1524 | } |
| 1525 | 1525 | |
| 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"; | |
| 1542 | ||
| 1526 | 1543 | /** Why the builders are idle, as the one line an admin's home reads; none while they are not. */ |
| 1527 | 1544 | function idleLines(app: App, now: Date): string[] { |
| 1528 | 1545 | const reason = idleReason(app, now); |
test/tui-kpis.test.ts +62 −3
| @@ -3,8 +3,12 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; | ||
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 6 | 8 | 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"; | |
| 8 | 12 | import { readVitals, vitalsLine } from "../src/flow/vitals.ts"; |
| 9 | 13 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 10 | 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -41,8 +45,8 @@ afterAll(async () => { | ||
| 41 | 45 | await rm(dir, { recursive: true, force: true }); |
| 42 | 46 | }); |
| 43 | 47 | |
| 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); | |
| 46 | 50 | const { app } = createForgeApp( |
| 47 | 51 | host, |
| 48 | 52 | { 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 | ||
| 102 | 106 | expect(text).toContain(vitalsLine(readVitals(forge.db))); |
| 103 | 107 | expect(await firstScreen(bob)).not.toContain("open pulls"); |
| 104 | 108 | }); |
| 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 | }); |
SPEND_LINE duplicates the literal that kpiLines draws. Share one exported constant so a rewording cannot silently stop protecting the line.