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

The terminal's "on you" screen leads with the week's asks as four numbers: raised, acted on, dismissed, went by themselves #8063

Merged opened this

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

Merged

scout/forge:scout/8055-the-terminal-s-on-you-screen-leads-with → main 6 commits fork 5 threads · 5 unresolved

  • Merged. scout/8055-the-terminal-s-on-you-screen-leads-with was merged into main as a7194e3.

Review

Sign in to review this change.
8 files changed +230 −22
README.md +2
@@ -81,6 +81,8 @@ A writer puts a ready issue **First**: the dispatcher starts it before the rest,
8181
8282 A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three rounds) has "Give back to the crew": one press assigns the pull, and the open issue it closes if a person holds it, to the agent that opened it, writes "Given back to the crew by @who", and the card leaves; the next tick starts its run. It is for a writer of the repository, and a pull a person opened, or by an agent gone from the crew, gets no button. In the terminal, `b` on a handed-over row of the On you pane does the same (`src/actions/give_back.ts`). The repository's planner can do it by comment: one that starts "Planner, one more round" on a handed-over crew pull emits the assignment to its builder, which starts the run (the pull is already the builder's, so nothing changes hands). It is the planner's alone; a reader's, the reviewer's or any other agent's does nothing. It is an agent's word, not a director's, so it does not end the hand-over: the card stays on `/asks` and the pull still counts as handed over during the round.
8383
84 The terminal's On you pane leads with the board's strip, the week's asks as four numbers (`Asks, 7 days: 4 raised · 2 acted on · 1 dismissed · 1 went by themselves`, your own rows only, absent when all four are 0). It is read on load and on `r`, not on every paint; `W` opens `/asks` on the web. Both doors share `askMeasureWeek` in `src/core/ask_ledger.ts`.
85
8486 Home opens with "Since you were here · 2 days": the pulls that landed in public repositories since you last loaded `/` (1 hour to 7 days back, a day on a first visit); the terminal's home shows the same lines, and MCP `feed` answers them as `digest`. See `docs/features/since-you-were-here.md`.
8587 Signed in, `/` paints its frame from memory and swaps in what a write has made stale; a viewer whose access changed is never shown the old copy, and the terminal home is unchanged: `docs/features/home-frame.md`.
8688
src/core/ask_ledger.ts +28
@@ -117,3 +117,31 @@ export function recentEnds(
117117 )
118118 .all(viewerId, repoId, limit);
119119 }
120
121 /** The window the asks measure covers. */
122 export const ASKS_WINDOW_DAYS = 7;
123
124 /** The last `ASKS_WINDOW_DAYS` days of this viewer's asks: the one window both doors read. */
125 export function askMeasureWeek(db: Database, viewerId: string, now = Date.now()): AskMeasure {

askMeasureWeek needs a direct test of the window edge (just inside and just outside 7 days).

126 return askMeasure(db, viewerId, new Date(now - ASKS_WINDOW_DAYS * 86_400_000).toISOString());
127 }
128
129 /** The four figures of a measure with their labels, in the order they are said. */
130 export function asksMeasureParts(m: AskMeasure): readonly { n: number; label: string }[] {
131 return [
132 { n: m.raised, label: "raised" },
133 { n: m.acted, label: "acted on" },
134 { n: m.dismissed, label: "dismissed" },
135 { n: m.expired, label: "went by themselves" },
136 ];
137 }
138
139 /** True when nothing has asked and nothing has ended: the line is then not shown. */
140 export function asksMeasureEmpty(m: AskMeasure): boolean {
outdated

Fine, but the board and the terminal each also recompute since from ASKS_WINDOW_DAYS; consider one askMeasureWeek(db, viewerId) here so both doors share the window computation too.

141 return m.raised + m.acted + m.dismissed + m.expired === 0;
142 }
143
144 /** "Asks, 7 days: 4 raised · 2 acted on · 1 dismissed · 1 went by themselves" */
145 export function asksMeasureWords(m: AskMeasure): string {
146 return `Asks, ${ASKS_WINDOW_DAYS} days: ${asksMeasureParts(m).map((p) => `${p.n} ${p.label}`).join(" · ")}`;
147 }
src/tui/screens/dashboard.ts +20 −1
@@ -53,6 +53,7 @@ import {
5353 } from "../../actions/asks_bulk.ts";
5454 import { reviewSetOf, type ReviewSet } from "../../web/studio/review_set.ts";
5555 import { staleWords } from "../../flow/kpi_words.ts";
56 import { askMeasureWeek, asksMeasureEmpty, asksMeasureWords } from "../../core/ask_ledger.ts";
5657 import { mutedRepos, removeMutes } from "../../core/interactions.ts";
5758 import { chipsOf, KIND_CHIPS, setChip } from "../../core/feed_chips.ts";
5859 import type { Operation } from "../../core/fleet_ops.ts";
@@ -172,6 +173,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
172173 let pins: Pin[] = [];
173174 let pinned = new Set<string>();
174175 let onYou: OnYouItem[] = [];
176 /** The week's asks line, read on reload; drawing only reads it. */
177 let asksLine: string | undefined;
175178 /** Wave proposals that wait for this person's nod, listed after the rows above. */
176179 let waves: { proposal: Proposal; repo: Repo }[] = [];
177180 /** Waves put aside with Later, which `B` brings back. */
@@ -328,6 +331,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
328331 pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>();
329332 all = listReposVisibleTo(f.db, f.user, { limit: 200 });
330333 onYou = assignedToMe();
334 asksLine = undefined;
335 if (f.user !== undefined && f.user.kind === "user") {
336 const m = askMeasureWeek(f.db, f.user.id);
337 if (!asksMeasureEmpty(m)) asksLine = asksMeasureWords(m);
338 }
331339 waitingSet = f.user === undefined ? undefined : reviewSetOf(f.db, f.user, new Date());
332340 waves = f.user === undefined ? [] : wavesFor();
333341 asideWaves = f.user === undefined ? [] : putAsideWaves(f.db, f.user);
@@ -1231,6 +1239,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
12311239 ctx.push(migrateScreen(f, ctx));
12321240 return true;
12331241 }
1242 if (e.key === "W" && asksLine !== undefined) {
1243 openOnWeb(f, ctx, "/asks", "Asks");
1244 return true;
1245 }
12341246 if (e.key === "E" && everyday?.asking === true) {
12351247 openOnWeb(f, ctx, "/settings", "Making your everyday account");
12361248 return true;
@@ -1258,11 +1270,17 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
12581270 function drawOnYou(s: Surface, r: Rect, c: Tokens, set: IconSet): void {
12591271 const on = focus === "onyou";
12601272 const footer = onYouFooter();
1261 const inner = card(s, r, c, {
1273 const frame = card(s, r, c, {
12621274 title: `${icon("person", set)} ${TITLES.onyou}`,
12631275 focused: on,
12641276 ...(footer === undefined ? {} : { footer }),
12651277 });
1278 // The week's asks lead the pane, muted; absent when all four are 0, so the list sits where it did.
1279 let inner = frame;
1280 if (asksLine !== undefined && frame.h > 1) {
1281 s.text(frame.x, frame.y, truncate(asksLine, frame.w), style(c.mutedFg, c.bg), frame.x + frame.w);
1282 inner = { ...frame, y: frame.y + 1, h: frame.h - 1 };
1283 }
12661284 if (onYou.length + waves.length === 0) {
12671285 note(s, inner, c, [
12681286 silenced.size > 0
@@ -1332,6 +1350,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
13321350 : "1-4 chips · h hide author",
13331351 );
13341352 }
1353 if (asksLine !== undefined) parts.push("W asks on the web");
13351354 if (marked.size > 0) parts.push(`${marked.size} marked`);
13361355 if (silenced.size > 0)
13371356 parts.push(
src/web/pages/board.ts +6 −9
@@ -18,7 +18,7 @@ import { usd } from "../../core/money.ts";
1818 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1919 import { sentinelNotice } from "../../crew/sentinel.ts";
2020 import { appsForRepos, type AppRecord } from "../../core/apps.ts";
21 import { askMeasure } from "../../core/ask_ledger.ts";
21 import { ASKS_WINDOW_DAYS, askMeasureWeek, asksMeasureEmpty, asksMeasureParts } from "../../core/ask_ledger.ts";
2222 import { declinedWaits, reviewWaits } from "../../actions/owner_waits.ts";
2323 import { tokenReturns } from "../../core/returns.ts";
2424 import { forgeVitals, needLine } from "../../actions/health.ts";
@@ -447,9 +447,6 @@ function reviewNotes(ctx: Ctx, scope: BoardScope): Note[] {
447447
448448 // ---- what asked you, and what came of it ----
449449
450 /** The window the asks measure covers. */
451 export const ASKS_WINDOW_DAYS = 7;
452
453450 /**
454451 * The viewer's asks over the last week, as four figures: raised, acted on,
455452 * dismissed, and gone by themselves. Many dismissed against few acted on
@@ -459,12 +456,12 @@ export const ASKS_WINDOW_DAYS = 7;
459456 function asksStrip(ctx: Ctx): Html | null {
460457 const user = ctx.auth.user;
461458 if (!user) return null;
462 const since = new Date(Date.now() - ASKS_WINDOW_DAYS * 86_400_000).toISOString();
463 const m = askMeasure(ctx.app.db, user.id, since);
464 if (m.raised + m.acted + m.dismissed + m.expired === 0) return null;
459 const m = askMeasureWeek(ctx.app.db, user.id);
460 if (asksMeasureEmpty(m)) return null;
465461 return html`<a class="muted small" id="asks-measure" href="/asks"
466 >Asks, ${ASKS_WINDOW_DAYS} days: <b>${m.raised}</b> raised · <b>${m.acted}</b> acted
467 on · <b>${m.dismissed}</b> dismissed · <b>${m.expired}</b> went by themselves</a
462 >Asks, ${ASKS_WINDOW_DAYS} days: ${asksMeasureParts(m).map(
463 (p, i) => html`${i === 0 ? "" : " · "}<b>${p.n}</b> ${p.label}`,
464 )}</a
468465 >`;
469466 }
470467
src/web/studio/asks.ts +2 −3
@@ -22,7 +22,7 @@ import { issueTitles, proposalsFor, type Proposal } from "../../core/proposals.t
2222 import { chipsOf, type Chips } from "../../core/feed_chips.ts";
2323 import { chipHides } from "../../feed/chips.ts";
2424 import { askDismissals, mutedRepos } from "../../core/interactions.ts";
25 import { askMeasure, syncAskLedger, type AskEnd } from "../../core/ask_ledger.ts";
25 import { ASKS_WINDOW_DAYS, askMeasureWeek, syncAskLedger, type AskEnd } from "../../core/ask_ledger.ts";
2626 import {
2727 getIssueById,
2828 labelsForIssues,
@@ -1145,14 +1145,13 @@ export function askSentence(count: number): string {
11451145 * and gone by themselves. `GET /api/v1/asks` and the MCP's `asks` read this.
11461146 */
11471147 export function asksAnswer(ctx: Ctx, user: User, chips?: Chips) {
1148 const since = new Date(Date.now() - 7 * 86_400_000).toISOString();
11491148 const set = reviewSet(ctx, user);
11501149 return {
11511150 // The very figures Home's line and /review show: `count` is live asks plus the forge's needs; `stale` are older than 30 days.
11521151 waiting: { count: set.count, stale: set.stale.length },
11531152 rows: askLinesFor(ctx.app.db, user, chips),
11541153 forge: forgeNeedsFor(ctx, user).map((d) => needLine(d)),
1155 measure: { days: 7, ...askMeasure(ctx.app.db, user.id, since) },
1154 measure: { days: ASKS_WINDOW_DAYS, ...askMeasureWeek(ctx.app.db, user.id) },
outdated

days: 7 should be ASKS_WINDOW_DAYS, the constant this pull introduces.

11561155 };
11571156 }
11581157
test/asks-rank.test.ts +3 −3
@@ -327,9 +327,9 @@ describe("asks that earn their place", () => {
327327 check(security, "failed");
328328 recordAskLedger(app.db);
329329 const board = await page("/board");
330 expect(board).toContain('id="asks-measure"');
331 expect(board).toContain(
332 "<b>6</b> raised · <b>1</b> acted on · <b>1</b> dismissed · <b>1</b> went by themselves",
330 // The whole strip, so a drift in its markup fails here: the link, the window, the bold figures and their separators.
331 expect(board).toMatch(
332 /<a class="muted small" id="asks-measure" href="\/asks"\s*>Asks, 7 days: <b>6<\/b> raised · <b>1<\/b> acted on · <b>1<\/b> dismissed · <b>1<\/b> went by themselves<\/a\s*>/,
333333 );
334334 const fromMcp = await mcpAsks();
335335 expect(fromMcp.measure.raised).toBe(6);
test/helpers/chrome.ts +20 −6
@@ -28,6 +28,19 @@ interface CdpMessage {
2828 error?: { message: string };
2929 }
3030
31 /**
32 * `promise`, or a rejection naming `what` after `ms`: every wait on chromium
33 * is bounded, so a stalled browser fails the test and the `finally` that
34 * kills it runs, instead of hanging the shard until it is killed.
35 */
36 function within<T>(ms: number, what: string, promise: Promise<T>): Promise<T> {
37 let timer: ReturnType<typeof setTimeout> | undefined;
38 const late = new Promise<never>((_, reject) => {
39 timer = setTimeout(() => reject(new Error(`chromium: ${what} took longer than ${ms} ms`)), ms);
40 });
41 return Promise.race([promise, late]).finally(() => clearTimeout(timer));
42 }
43
3144 /** A CDP session over one page's own devtools socket. */
3245 class Cdp {
3346 private nextId = 1;
@@ -51,13 +64,13 @@ class Cdp {
5164
5265 send(method: string, params: object = {}): Promise<any> {
5366 const id = this.nextId++;
54 return new Promise((resolve, reject) => {
67 return within(5_000, method, new Promise((resolve, reject) => {
5568 this.pending.set(id, (msg) => {
5669 if (msg.error !== undefined) reject(new Error(msg.error.message));
5770 else resolve(msg.result);
5871 });
5972 this.ws.send(JSON.stringify({ id, method, params }));
60 });
73 }));
6174 }
6275
6376 /** Resolves the next time `method` fires as an event — a one-shot wait. */
@@ -73,7 +86,8 @@ async function findPageTarget(
7386 const httpBase = debuggerWsUrl
7487 .replace(/^ws:/, "http:")
7588 .replace(/\/devtools\/browser\/.*$/, "");
76 const list = (await (await fetch(`${httpBase}/json/list`)).json()) as {
89 const res = await fetch(`${httpBase}/json/list`, { signal: AbortSignal.timeout(5_000) });
90 const list = (await res.json()) as {
7791 type: string;
7892 webSocketDebuggerUrl: string;
7993 }[];
@@ -141,12 +155,12 @@ export async function evaluateLoaded(
141155 ]);
142156 const page = await findPageTarget(browserWsUrl);
143157 ws = new WebSocket(page.webSocketDebuggerUrl);
144 await new Promise<void>((resolve, reject) => {
158 await within(5_000, "opening the devtools socket", new Promise<void>((resolve, reject) => {
145159 ws!.addEventListener("open", () => resolve());
146160 ws!.addEventListener("error", () =>
147161 reject(new Error("chromium's devtools socket refused the connection")),
148162 );
149 });
163 }));
150164 const cdp = new Cdp(ws);
151165 await cdp.send("Page.enable");
152166 await cdp.send("Network.enable");
@@ -171,7 +185,7 @@ export async function evaluateLoaded(
171185 });
172186 const loaded = cdp.once("Page.loadEventFired");
173187 await cdp.send("Page.navigate", { url });
174 await loaded;
188 await within(10_000, `loading ${url}`, loaded);
175189 // The load event can be the blank start page's, fired late on a busy
176190 // machine: the page measured is the navigated one, loaded, or none.
177191 const deadline = Date.now() + 10_000;
test/tui-yours-asks.test.ts added +149
@@ -0,0 +1,149 @@
1 /**
2 * The terminal's On you leads with the week's asks as four numbers, as the
3 * board's strip does (#8055): the viewer's own rows only, absent when all
4 * four are 0.
5 */
6
7 import { afterAll, beforeAll, describe, 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 { createRepo } from "../src/actions/repos.ts";
12 import { signUp } from "../src/actions/users.ts";
13 import { createApp, type App as Forge } from "../src/app.ts";
14 import { ASKS_WINDOW_DAYS, askMeasureWeek, asksMeasureWords } from "../src/core/ask_ledger.ts";
15 import type { User } from "../src/core/users.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp } from "./helpers/tui.ts";
20 import { fixtureSlot } from "./helpers/slot.ts";
21 import { seedAdmin } from "./helpers/admin.ts";
22
23 quietLogs();
24
25 describe("terminal On you: the week's asks", () => {
26 let dir: string;
27 let forge: Forge;
28 let alice: User;
29 let bob: User;
30 let repoId = "";
31 let n = 0;
32
33 const row = (viewer: User, how?: "acted" | "dismissed" | "expired", ageMs = 0) => {
34 const now = new Date(Date.now() - ageMs).toISOString();
35 forge.db.run(
36 "INSERT INTO ask_ledger (id, viewer_id, ask_id, repo_id, kind, raised_at, ended_at, ended_how) VALUES (?,?,?,?,?,?,?,?)",
37 [`r${++n}`, viewer.id, `a${n}`, repoId, "review", now, how === undefined ? null : now, how ?? null],
38 );
39 };
40
41 const open = async (user: User | undefined, rows = 32) => {
42 const { app } = createForgeApp(
43 forge,
44 { sshUsername: user?.username ?? "t", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", ...(user ? { user } : {}) },
45 { terminal: headlessTerminal(200, rows), exitHooks: false },
46 );
47 app.start();
48 await settleApp(app);
49 const key = async (k: string) => {
50 app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
51 await settleApp(app);
52 };
53 return { app, key, text: () => app.screen.toText() };
54 };
55
56 const screen = async (user: User | undefined, rows = 32) => {
57 const t = await open(user, rows);
58 const text = t.text();
59 t.app.stop();
60 return text;
61 };
62
63 beforeAll(async () => {
64 dir = await mkdtemp(join(tmpdir(), "forge-yours-asks-"));
65 forge = createApp({ dataDir: dir, port: 0 });
66 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
67 const b = await signUp(forge, { username: "bob", email: "[email protected]", password: "another-long-passphrase" }, { slot: fixtureSlot(forge) });
68 if (!a.ok || !b.ok) throw new Error("signup failed");
69 alice = a.value;
70 bob = b.value;
71 const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" });
72 if (!r.ok) throw new Error("repo failed");
73 repoId = r.value.id;
74 });
75
76 afterAll(async () => {
77 forge.close();
78 await rm(dir, { recursive: true, force: true });
79 });
80
81 test("an empty ledger shows no line", async () => {
82 expect(await screen(alice)).not.toContain("Asks, ");
83 });
84
85 test("askMeasureWeek counts a row just inside the window and not one just outside it", () => {
86 const now = Date.parse("2026-01-10T12:00:00.000Z");
87 const day = 86_400_000;
outdated

This test builds its own since and calls askMeasure, so it never exercises askMeasureWeek. Insert a row with raised_at 8 days back and assert it is not counted in the line.

88 const at = (ms: number) => new Date(ms).toISOString();
89 const put = (id: string, ms: number) =>
90 forge.db.run(
91 "INSERT INTO ask_ledger (id, viewer_id, ask_id, repo_id, kind, raised_at, ended_at, ended_how) VALUES (?,?,?,?,?,?,?,?)",
92 [id, bob.id, `a-${id}`, repoId, "review", at(ms), at(ms), "dismissed"],
93 );
94 put("edge-in", now - ASKS_WINDOW_DAYS * day + 1000);
95 put("edge-out", now - ASKS_WINDOW_DAYS * day - 1000);
96 expect(askMeasureWeek(forge.db, bob.id, now)).toEqual({ raised: 1, acted: 0, dismissed: 1, expired: 0 });
97 forge.db.run("DELETE FROM ask_ledger WHERE id IN ('edge-in', 'edge-out')");
98 });
99
100 test("the four numbers are the week's, never older and never another viewer's", async () => {
101 row(alice, "acted");
102 row(alice, "acted");
103 row(alice, "dismissed");
104 row(alice, "expired");
105 // Eight days old: outside the window, so the line leaves it out.
106 row(alice, "acted", 8 * 86_400_000);
107 row(bob, "dismissed");
108 row(bob);
109 const m = askMeasureWeek(forge.db, alice.id);
110 expect(m).toEqual({ raised: 4, acted: 2, dismissed: 1, expired: 1 });
111 expect(asksMeasureWords(m)).toBe(
112 "Asks, 7 days: 4 raised · 2 acted on · 1 dismissed · 1 went by themselves",
113 );
114 expect(await screen(alice)).toContain(asksMeasureWords(m));
115 });
116
117 test("drawing reads the ledger on load, not on every frame: a later row waits for r", async () => {
118 const t = await open(alice);
119 try {
120 const before = t.text();
121 expect(before).toContain("4 raised");
122 row(alice, "acted");
123 t.app.frame();
124 t.app.screen.toText();
125 expect(t.text()).toContain("4 raised");
126 await t.key("r");
127 expect(t.text()).toContain("5 raised");
128 } finally {
129 t.app.stop();
130 }
131 });
132
133 test("W offers the asks page on the web, and says so in the footer", async () => {
134 const t = await open(alice);
135 try {
136 expect(t.text()).toContain("W asks on the web");
137 await t.key("W");
138 expect(t.text()).toContain("Asks is on the web at /asks");
139 } finally {
140 t.app.stop();
141 }
142 });
143
144 test("a signed-out viewer, an agent and a three-row terminal get no line and no throw", async () => {
145 expect(await screen(undefined)).not.toContain("Asks, ");
146 expect(await screen({ ...alice, kind: "agent" })).not.toContain("Asks, ");
147 expect(await screen(alice, 3)).not.toContain("Asks, ");
148 });
149 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs