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

The terminal's Subscriptions screen shows each account's 5-hour and weekly window: used of cap, the share, and when it resets #8069

Merged opened this

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

Merged

mason/forge:mason/8056-the-terminal-s-subscriptions-screen-show → main 8 commits fork 5 threads · 5 unresolved

  • Merged. mason/8056-the-terminal-s-subscriptions-screen-show was merged into main as 02d4093.

Review

Sign in to review this change.
7 files changed +346 −74
README.md +6
@@ -402,6 +402,12 @@ leading `Bearer ` are dropped (`normalizeToken`, `src/core/subscriptions.ts`),
402402 and what is left is sealed if it is not empty and at most 4096 characters. Its
403403 shape is not judged; the provider answers a wrong key on the first call.
404404
405 The terminal's Crew subscriptions screen lists every account with its 5-hour
406 and weekly windows under its row: used of cap, the share, and when it resets,
407 in the very sentence Settings › Subscriptions prints (`windowWords`,
408 `src/actions/standing_words.ts`). A sentence wider than the terminal wraps
409 onto the next line, so the reset time is never cut off.
410
405411 A repository in auto mode with a direction and no planner on its crew is lent
406412 one (#7637): at the next planning turn the forge adds, through the same
407413 `addToCrew` an admin uses, the agent that plans on the most of the *owner's*
src/actions/standing_words.ts added +39
@@ -0,0 +1,39 @@
1 /**
2 * One account's window, in a sentence: `5-hour: $3.10 used of $10.00 (31%) ·
3 * 1.2k tok · resets 12:04 UTC`. A leading `~` marks a figure the ledger
4 * inferred rather than the provider said. Settings › Subscriptions and the
5 * terminal's Subscriptions screen both say it with this one function.
6 */
7
8 import { whenText } from "../crew/limits.ts";
9 import type { Figure, WindowStanding } from "../core/standing.ts";
10 import { formatTokens } from "../kernel/tokens-format.ts";
11
12 const usd = (n: number): string => `$${n.toFixed(2)}`;
13 const mark = (f: Figure<unknown>): string => (f.source === "read" ? "" : "~");
14
15 /** The sentence in three pieces, so a page can dress the token count; `windowWords` joins them. */
16 export function windowParts(w: WindowStanding): { head: string; tokens: number; tail: string } {
17 const name = w.window === "5h" ? "5-hour" : "weekly";
18 const used = `${mark(w.used)}${usd(w.used.value)} used`;
19 const of =
20 w.cap === null
21 ? ""
22 : ` of ${mark(w.cap)}${usd(w.cap.value)}${
23 w.cap.value > 0
24 ? ` (${Math.min(100, Math.round((w.used.value / w.cap.value) * 100))}%)`
25 : ""
26 }`;
27 const full =
28 w.remaining?.source === "read" && w.remaining.value === 0 ? " · full" : "";
29 const resets =
30 w.resetsAt === null
31 ? " · opens on next use"
32 : ` · resets ${mark(w.resetsAt)}${whenText(new Date(w.resetsAt.value))}`;
33 return { head: `${name}: ${used}${of}`, tokens: w.tokensIn + w.tokensOut, tail: `${full}${resets}` };
34 }
35
36 export function windowWords(w: WindowStanding): string {
37 const { head, tokens, tail } = windowParts(w);
38 return `${head}${tokens > 0 ? ` · ${formatTokens(tokens, "counted")}` : ""}${tail}`;
39 }
src/tui/screens/common.ts +54 −36
@@ -23,7 +23,7 @@ import type { ForgeEvent } from "../../core/events.ts";
2323 import type { Session } from "../session.ts";
2424 import { G, Screen as Surface, type Rect } from "../term/screen.ts";
2525 import { style } from "../term/style.ts";
26 import { pad, truncate } from "../term/width.ts";
26 import { pad, truncate, wrap } from "../term/width.ts";
2727 import { fill, len, split } from "../ui/layout.ts";
2828 import type { Tokens } from "../ui/theme.ts";
2929 import { icon, type IconSet } from "../ui/icons.ts";
@@ -87,6 +87,25 @@ export class ListState {
8787 return this.offset;
8888 }
8989
90 /**
91 * As `window`, for rows taller than one line: row `i` takes `heights[i]`
92 * lines. The cursor's row is kept whole on screen, and no room is left
93 * empty at the bottom while rows above could fill it.
94 */
95 windowRows(height: number, heights: readonly number[]): number {
96 this.height = Math.max(1, height);
97 this.clamp(heights.length);
98 const span = (from: number, to: number): number => {
99 let n = 0;
100 for (let i = from; i <= to; i++) n += heights[i] ?? 1;
101 return n;
102 };
103 if (this.index < this.offset) this.offset = this.index;
104 while (this.offset < this.index && span(this.offset, this.index) > height) this.offset++;
105 while (this.offset > 0 && span(this.offset - 1, heights.length - 1) <= height) this.offset--;
106 return this.offset;
107 }
108
90109 /** The convergent navigation set. True when the key was ours. */
91110 key(e: KeyEvent, length: number): boolean {
92111 const half = Math.max(1, this.height >> 1);
@@ -230,9 +249,17 @@ export interface ListRow {
230249 readonly meta?: string;
231250 /** A muted line under the row, indented past its dot; the item is then two rows tall. */
232251 readonly note?: string;
252 /** Muted sentences under the row, wrapped to the list's width, never cut; the item is as tall as they wrap. */
253 readonly below?: readonly string[];
233254 }
234255
235 /** A scrolling list of `row()`s with a scrollbar when it overflows. */
256 /** Lines under a row start past its caret and dot. */
257 const UNDER_INDENT = 4;
258
259 /**
260 * A scrolling list of `row()`s with a scrollbar when it overflows. A row with
261 * `note` is two lines tall; one with `below` lines, as many as they wrap to.
262 */
236263 export function drawList(
237264 s: Surface,
238265 r: Rect,
@@ -243,45 +270,36 @@ export function drawList(
243270 ): void {
244271 if (r.w <= 0 || r.h <= 0) return;
245272 const body = { x: r.x, y: r.y, w: Math.max(0, r.w - 1), h: r.h };
246 const noted = rows.some((i) => i.note !== undefined && i.note !== "");
247 const tall = (i: number): number => (noted && rows[i]!.note ? 2 : 1);
248 let from: number;
249 if (!noted) {
250 state.heights = undefined;
251 from = state.window(r.h, rows.length);
252 } else {
253 // Scroll by item, so the cursor's whole entry (row and note) is in view.
254 state.clamp(rows.length);
255 from = Math.max(0, Math.min(state.offset, state.index));
256 const span = (a: number, b: number): number => {
257 let h = 0;
258 for (let i = a; i <= b; i++) h += tall(i);
259 return h;
260 };
261 while (from < state.index && span(from, state.index) > r.h) from++;
262 // Never leave a gap under the last item when the ones above would fit.
263 while (from > 0 && span(from - 1, rows.length - 1) <= r.h) from--;
264 state.offset = from;
265 }
266 let y = 0;
273 const room = Math.max(1, body.w - UNDER_INDENT);
274 const under = rows.map((item): string[] =>
275 item.note
276 ? body.w > UNDER_INDENT ? [truncate(item.note, room)] : [""]
277 : (item.below ?? []).flatMap((line) => wrap(line, room)),
278 );
279 const tall = under.some((lines) => lines.length > 0);
280 // Scroll by item, so the cursor's whole entry (row and the lines under it) is in view.
281 const from = tall
282 ? state.windowRows(r.h, under.map((lines) => 1 + lines.length))
283 : state.window(r.h, rows.length);
284 let y = body.y;
267285 const heights: number[] = [];
268 for (let i = from; i < rows.length && y < r.h; i++) {
269 const item = rows[i]!;
270 const h = Math.min(tall(i), r.h - y);
271 row(s, { x: body.x, y: body.y + y, w: body.w, h: 1 }, c, {
272 ...item,
286 for (let i = from; i < rows.length && y < body.y + body.h; i++) {
287 const top = y;
288 row(s, { x: body.x, y, w: body.w, h: 1 }, c, {
289 ...rows[i]!,
273290 selected: focused && i === state.index,
274291 focused: i === state.index,
275292 });
276 if (item.note && h > 1 && body.w > 4)
277 s.text(body.x + 4, body.y + y + 1, truncate(item.note, body.w - 4), style(c.mutedFg, c.bg), body.x + body.w);
278 heights.push(h);
279 y += h;
280 }
281 if (noted) {
282 state.heights = heights;
283 state.height = Math.max(1, heights.length);
293 y++;
294 for (const line of under[i]!) {
295 if (y >= body.y + body.h) break;
296 s.text(body.x + UNDER_INDENT, y, line, style(c.mutedFg, c.bg), body.x + body.w);
297 y++;
298 }
299 heights.push(y - top);
284300 }
301 state.heights = tall ? heights : undefined;
302 if (tall) state.height = Math.max(1, heights.length);
285303 scrollbar(s, { x: r.x + r.w - 1, y: r.y, w: 1, h: r.h }, c, {
286304 total: rows.length,
287305 offset: from,
src/tui/screens/subscriptions.ts +23 −7
@@ -20,7 +20,10 @@ import {
2020 scopesOf,
2121 setBudget,
2222 wakeSubscription,
23 type Subscription,
2324 } from "../../core/subscriptions.ts";
25 import { windowWords } from "../../actions/standing_words.ts";
26 import { standings } from "../../core/standing.ts";
2427 import { SUBSCRIPTION_WORDS, addRefusal } from "../../core/subscriptions_words.ts";
2528 import type { Ctx, Screen } from "../app/app.ts";
2629 import { binding } from "../app/keys.ts";
@@ -37,16 +40,22 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
3740 const state = new ListState();
3841 let note = "";
3942 const admitted = () => f.user?.isAdmin === true && f.user.kind === "user";
40 const items = () => (admitted() ? listSubscriptions(f.app.db) : []);
4143
42 const rows = (): ListRow[] => {
44 // What the screen shows, read once on open and again after each change it
45 // makes, so a keypress redraws from memory and never queries the database.
46 let all: Subscription[] = [];
47 let listed: ListRow[] = [];
48 const load = (): void => {
49 all = admitted() ? listSubscriptions(f.app.db) : [];
4350 const budgets = budgetsOf(f.app.db);
4451 const scopes = scopesOf(f.app.db);
52 const seen = standings(f.app.db, all.map((a) => a.id), new Date());
4553 const now = Date.now();
46 return items().map((s) => {
54 listed = all.map((s) => {
4755 const b = budgets.get(s.id);
4856 const resting = s.restingUntil !== null && Date.parse(s.restingUntil) > now;
4957 const pays = (scopes.get(s.id) ?? []).join(", ");
58 const st = seen.get(s.id);
5059 return {
5160 title: s.name,
5261 tone: resting ? "attn" : "success",
@@ -54,9 +63,14 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
5463 meta:
5564 `${b?.budgetUsd == null ? "no budget" : `budget ${money(b.budgetUsd)}`} spent ${money(b?.spentUsd ?? 0)} ` +
5665 (resting ? `resting until ${s.restingUntil}` : "awake"),
66 below:
67 st === undefined || st.windows.every((w) => w.cap === null && w.resetsAt === null && w.used.value === 0)
68 ? ["windows not seen yet"]
69 : st.windows.map(windowWords),
5770 };
5871 });
5972 };
73 load();
6074
6175 const setTo = (id: string, raw: string): string | undefined => {
6276 const text = raw.trim().toLowerCase();
@@ -65,6 +79,7 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
6579 const refused = budgetRefusal(f.app.db, id, usd);
6680 if (refused) return refused.says.replace("or null for none", "or none");
6781 if (!setBudget(f.app.db, id, usd)) return "The budget was not saved.";
82 load();
6883 note = usd === null ? "No budget: it spends until you stop it." : `Budget set: ${money(Math.round(usd * 100) / 100)} in all.`;
6984 ctx.invalidate();
7085 return undefined;
@@ -92,6 +107,7 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
92107 createdBy: f.user?.id,
93108 });
94109 if (!added.ok) return addRefusal(added.error, name);
110 load();
95111 note = `${added.value.name} is in the pool.`;
96112 ctx.invalidate();
outdated

standings() queries the database for every account on every render. Load it when the screen opens and after each mutation, and keep the draw free of I/O. Also, this duplicates drawList; give drawList variable row heights instead.

97113 return undefined;
@@ -106,15 +122,13 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
106122 keys: [binding(["j", "k"], "move cursor"), binding(["w"], "wake"), binding(["b"], "set budget"), binding(["a"], "add"), binding(["D"], "remove"), binding(["esc"], "back")],
107123 render(area, s) {
108124 const c = ctx.theme.c;
109 const all = items();
110125 const [head, body] = split(area, "v", [len(1), fill(1)]);
111126 const text = note !== "" ? note : all.length === 0 ? "No subscription yet. a adds one." : "w wakes a resting one, b sets its budget, a adds, D removes.";
112127 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
113 drawList(s, body!, c, state, rows());
128 drawList(s, body!, c, state, listed);
114129 },
115130 key(e) {
116131 note = "";
117 const all = items();
118132 if (state.key(e, all.length)) return true;
119133 if (e.ctrl || e.alt || !admitted()) return false;
120134 const sub = all[state.index];
@@ -127,6 +141,7 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
127141 wakeSubscription(f.app.db, sub.id);
128142 note = `${sub.name}: in use again.`;
129143 }
144 load();
130145 ctx.invalidate();
131146 } else if (e.key === "b" && sub !== undefined) {
132147 ctx.push(
@@ -161,7 +176,8 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
161176 onChoose: (choice) => {
162177 if (choice !== 0 || !admitted()) return;
163178 note = deleteSubscription(f.app.db, sub.id) ? `${sub.name} is removed.` : SUBSCRIPTION_WORDS.not_found;
164 state.index = Math.max(0, Math.min(state.index, items().length - 1));
179 load();
180 state.clamp(all.length);
165181 ctx.invalidate();
166182 },
167183 }),
src/web/pages/subscriptions.ts +4 −31
@@ -33,7 +33,6 @@ import {
3333 headroomPct,
3434 setHeadroom,
3535 standings,
36 type Figure,
3736 type WindowStanding,
3837 } from "../../core/standing.ts";
3938 import { getUserById } from "../../core/users.ts";
@@ -57,6 +56,7 @@ import {
5756 tokenCount,
5857 userLink,
5958 } from "../ui.ts";
59 import { windowParts } from "../../actions/standing_words.ts";
6060 import { whenText } from "../../crew/limits.ts";
6161 import { accountSections } from "./origins.ts";
6262 import { liveRegionsJs } from "../client.ts";
@@ -80,37 +80,10 @@ const kindText = (s: Subscription): string =>
8080 ? `API key via ${s.apiBase}`
8181 : "API key";
8282
83 const usd = (n: number): string => `$${n.toFixed(2)}`;
84 const mark = (f: Figure<unknown>): string => (f.source === "read" ? "" : "~");
85
86 /**
87 * `5-hour: ~$3.20 used of ~$10.00 (32%) · 1.2M tok · resets 12:04 UTC` — one
88 * window, plainly: the dollars, the share of the cap when it is known, and
89 * beside them the tokens the crew's runs read and wrote in it, so the money
90 * and the context intuitions meet in one line.
91 */
83 /** One window's sentence (`actions/standing_words.ts`), as the terminal says it too. */
9284 function windowText(w: WindowStanding): Html {
93 const name = w.window === "5h" ? "5-hour" : "weekly";
94 const used = `${mark(w.used)}${usd(w.used.value)} used`;
95 const of =
96 w.cap === null
97 ? ""
98 : ` of ${mark(w.cap)}${usd(w.cap.value)}${
99 w.cap.value > 0
100 ? ` (${Math.min(100, Math.round((w.used.value / w.cap.value) * 100))}%)`
101 : ""
102 }`;
103 const full =
104 w.remaining?.source === "read" && w.remaining.value === 0 ? " · full" : "";
105 const resets =
106 w.resetsAt === null
107 ? " · opens on next use"
108 : ` · resets ${w.resetsAt.source === "read" ? "" : "~"}${whenText(new Date(w.resetsAt.value))}`;
109 const tokens = w.tokensIn + w.tokensOut;
110 return html`${name}: ${used}${of}${when(
111 tokens > 0,
112 () => html` · ${tokenCount(tokens, "counted")}`,
113 )}${full}${resets}`;
85 const { head, tokens, tail } = windowParts(w);
86 return html`${head}${when(tokens > 0, () => html` · ${tokenCount(tokens, "counted")}`)}${tail}`;
11487 }
11588
11689 const resting = (s: Subscription, now: Date): boolean =>
test/tui-list-below.test.ts added +47
@@ -0,0 +1,47 @@
1 /** `drawList` rows with lines under them, and the cursor arithmetic for rows taller than one line (#8056). */
2 import { describe, expect, test } from "bun:test";
3 import { ListState, drawList, type ListRow } from "../src/tui/screens/common.ts";
4 import { Screen } from "../src/tui/term/screen.ts";
5 import { theme } from "../src/tui/ui/theme.ts";
6
7 describe("ListState.windowRows", () => {
8 test("keeps the cursor's whole row on screen", () => {
9 const list = new ListState();
10 list.index = 2;
11 // rows of 3 lines in 7: rows 1 and 2 fit (6 lines), row 0 does not
12 expect(list.windowRows(7, [3, 3, 3])).toBe(1);
13 });
14
15 test("leaves no empty room at the bottom while rows above could fill it", () => {
16 const list = new ListState();
17 list.offset = 2;
18 list.index = 2;
19 expect(list.windowRows(10, [2, 2, 2])).toBe(0);
20 });
21 });
22
23 describe("drawList with below", () => {
24 const draw = (w: number, h: number, rows: ListRow[], index = 0): string[] => {
25 const s = new Screen(w, h);
26 const state = new ListState();
27 state.index = index;
28 drawList(s, { x: 0, y: 0, w, h }, theme("dark").c, state, rows);
29 return s.toText().split("\n");
30 };
31
32 test("draws each row's lines under it, indented and wrapped, not cut", () => {
33 const lines = draw(22, 6, [{ title: "one", below: ["alpha beta gamma delta"] }, { title: "two" }]);
34 expect(lines[0]).toContain("one");
35 expect(lines[1]!.trimEnd()).toBe(" alpha beta gamma");
36 expect(lines[2]!.trimEnd()).toBe(" delta");
37 expect(lines[3]).toContain("two");
38 });
39
40 test("scrolls by whole rows so the selected one shows with its lines", () => {
41 const rows = ["a", "b", "c"].map((t) => ({ title: t, below: [`${t} under`] }));
42 const lines = draw(20, 4, rows, 2);
43 expect(lines[0]).toContain("b");
44 expect(lines[2]).toContain("c");
45 expect(lines[3]).toContain("c under");
46 });
47 });
test/tui-subscriptions-windows.test.ts added +173
@@ -0,0 +1,173 @@
1 /** The terminal's Subscriptions screen says each window as Settings › Subscriptions does (#8056). */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { windowParts, windowWords } from "../src/actions/standing_words.ts";
7 import { createApp, type App as Forge } from "../src/app.ts";
8 import { createSession } from "../src/core/sessions.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { escapeHtml } from "../src/web/html.ts";
11 import { createRun } from "../src/core/runs.ts";
12 import { noteLimit, standing } from "../src/core/standing.ts";
13 import { addSubscription, getSubscriptionByName } from "../src/core/subscriptions.ts";
14 import { recordUsage } from "../src/core/usage.ts";
15 import { createUser, getUserByName } from "../src/core/users.ts";
16 import { id } from "../src/kernel/id.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import { headlessTerminal } from "../src/tui/term/terminal.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { settleApp, settleUntil } from "./helpers/tui.ts";
21
22 quietLogs();
23
24 describe("windows on the terminal's subscriptions screen", () => {
25 let dataDir: string;
26 let forge: Forge;
27 let servers: Servers;
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-subwin-"));
31 forge = createApp({ dataDir, port: 0 });
32 servers = await serve(forge);
33 const made = await createUser(forge.db, {
34 origin: "seed",
35 trusted: true,
36 username: "alice",
37 email: "[email protected]",
38 password: "a-good-passphrase",
39 adminIfFirst: true,
40 });
41 if (!made.ok) throw new Error(made.error);
42 for (const name of ["seen", "unseen"]) {
43 const added = addSubscription(forge.db, dataDir, { name, token: `sk-ant-oat01-win-${name}` });
44 if (!added.ok) throw new Error(added.error);
45 }
46 const seen = getSubscriptionByName(forge.db, "seen")!;
47 const owner = getUserByName(forge.db, "alice")!;
48 const repoId = id();
49 forge.db
50 .query(
51 `INSERT INTO repo (id, owner_id, name, visibility, description, default_branch)
52 VALUES (?1, ?2, 'r', 'private', '', 'main')`,
53 )
54 .run(repoId, owner.id);
55 const at = new Date(Date.now() - 3_600_000).toISOString();
56 recordUsage(forge.db, {
57 runId: createRun(forge.db, { repoId, kind: "agent" }).id,
58 repoId,
59 issueId: null,
60 agentId: null,
61 subscriptionId: seen.id,
62 tokensIn: 100,
63 tokensOut: 10,
64 costUsd: 3.1,
65 wallMs: 60_000,
66 outcome: "ok",
67 at,
68 });
69 noteLimit(forge.db, seen.id, {
70 window: "5h",
71 resetsAt: new Date(Date.now() + 2 * 3_600_000).toISOString(),
72 observedAt: at,
73 said: "You've hit your session limit",
74 });
75 });
76 afterAll(async () => {
77 servers.stop();
78 forge.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 async function open(cols = 160): Promise<{ text: () => string; press: (k: string) => Promise<void>; stop: () => void }> {
83 const terminal = headlessTerminal(cols, 32);
84 const user = getUserByName(forge.db, "alice")!;
85 const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false });
86 app.start();
87 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
88 key("k", true);
89 for (const ch of "Crew subscriptions") key(ch);
90 await settleUntil(app, "each account", { timeoutMs: 4_000 });
91 key("enter");
92 await settleUntil(app, "w wakes", { timeoutMs: 4_000 });
93 return {
94 text: () => app.screen.toText(),
95 press: async (k) => {
96 key(k);
97 await settleApp(app);
98 },
99 stop: () => app.stop(),
100 };
101 }
102
103 const seenWindows = () => standing(forge.db, getSubscriptionByName(forge.db, "seen")!.id, new Date())!.windows;
104 const sentences = () => seenWindows().map(windowWords);
outdated

Compare against the Settings page output too, and use an 80-column terminal as well as 160.

105
106 test("every account shows its windows, in the sentence the Settings page is built from", async () => {
107 const h = await open();
108 const lines = sentences();
outdated

The normalisation chain (including a no-op replace) makes this comparison weak and unreadable. Do one strict, explainable comparison.

109 expect(lines[0]).toMatch(/^5-hour: ~\$3\.10 used of ~\$3\.10 \(100%\)/);
110 expect(lines[0]).toContain("resets");
111 for (const l of lines) expect(h.text()).toContain(l);
112 expect(h.text()).toContain("windows not seen yet");
113 h.stop();
114 // The page says the same pieces, escaped once; only the token count between them is dressed.
115 const user = getUserByName(forge.db, "alice")!;
116 const page = await (
117 await fetch(`${servers.url}/settings/subscriptions`, { headers: { cookie: `forge_session=${createSession(forge.db, user.id).token}` } })
118 ).text();
119 for (const w of seenWindows()) {
120 const { head, tail } = windowParts(w);
outdated

This does not exercise the wrap: at 80 columns the sentences fit on one line, so it would pass without the wrap code. Make a sentence wider than the room and assert the reset text is on a continuation line.

121 expect(page).toContain(escapeHtml(head));
122 expect(page).toContain(escapeHtml(tail));
123 }
124 });
125
126 test("at 80 columns every sentence is whole on its line", async () => {
127 const h = await open(80);
128 const screen = h.text().split("\n");
129 for (const l of sentences()) expect(screen.some((line) => line.includes(l))).toBe(true);
130 h.stop();
131 });
132
133 test("where a sentence is wider than the room, the reset time wraps onto a continuation line", async () => {
134 const cols = 48;
135 const first = sentences()[0]!;
136 expect(first.length).toBeGreaterThan(cols - 3);
137 const h = await open(cols);
138 const screen = h.text().split("\n");
139 h.stop();
140 const at = screen.findIndex((line) => line.includes("5-hour:"));
141 expect(at).toBeGreaterThan(-1);
142 expect(screen[at]).not.toContain("resets");
143 const rest = screen.slice(at + 1, at + 3).map((line) => line.trim()).join(" ");
144 expect(rest).toContain(`resets ${first.slice(first.indexOf("resets ") + "resets ".length)}`);
145 });
146
147 test("moving the cursor redraws from memory: no query reaches the database", async () => {
148 const h = await open();
149 const db = forge.db as unknown as { query: (...a: unknown[]) => unknown; prepare: (...a: unknown[]) => unknown };
150 const { query, prepare } = db;
151 let calls = 0;
152 db.query = (...a) => (calls++, query.apply(forge.db, a));
153 db.prepare = (...a) => (calls++, prepare.apply(forge.db, a));
154 try {
155 await h.press("j");
156 await h.press("k");
157 } finally {
158 db.query = query;
159 db.prepare = prepare;
160 }
161 expect(calls).toBe(0);
162 expect(h.text()).toContain("windows not seen yet");
163 h.stop();
164 });
165
166 test("an account with no window seen says so, whichever row the cursor is on", async () => {
167 const h = await open();
168 await h.press("g");
169 await h.press("j");
170 expect(h.text()).toContain("windows not seen yet");
171 h.stop();
172 });
173 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs