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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8056-the-terminal-s-subscriptions-screen-show → main 8 commits fork 5 threads · 5 unresolved
7 files
changed +346
−74
README.md +6
| @@ -402,6 +402,12 @@ leading `Bearer ` are dropped (`normalizeToken`, `src/core/subscriptions.ts`), | ||
| 402 | 402 | and what is left is sealed if it is not empty and at most 4096 characters. Its |
| 403 | 403 | shape is not judged; the provider answers a wrong key on the first call. |
| 404 | 404 | |
| 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 | ||
| 405 | 411 | A repository in auto mode with a direction and no planner on its crew is lent |
| 406 | 412 | one (#7637): at the next planning turn the forge adds, through the same |
| 407 | 413 | `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"; | ||
| 23 | 23 | import type { Session } from "../session.ts"; |
| 24 | 24 | import { G, Screen as Surface, type Rect } from "../term/screen.ts"; |
| 25 | 25 | import { style } from "../term/style.ts"; |
| 26 | import { pad, truncate } from "../term/width.ts"; | |
| 26 | import { pad, truncate, wrap } from "../term/width.ts"; | |
| 27 | 27 | import { fill, len, split } from "../ui/layout.ts"; |
| 28 | 28 | import type { Tokens } from "../ui/theme.ts"; |
| 29 | 29 | import { icon, type IconSet } from "../ui/icons.ts"; |
| @@ -87,6 +87,25 @@ export class ListState { | ||
| 87 | 87 | return this.offset; |
| 88 | 88 | } |
| 89 | 89 | |
| 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 | ||
| 90 | 109 | /** The convergent navigation set. True when the key was ours. */ |
| 91 | 110 | key(e: KeyEvent, length: number): boolean { |
| 92 | 111 | const half = Math.max(1, this.height >> 1); |
| @@ -230,9 +249,17 @@ export interface ListRow { | ||
| 230 | 249 | readonly meta?: string; |
| 231 | 250 | /** A muted line under the row, indented past its dot; the item is then two rows tall. */ |
| 232 | 251 | 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[]; | |
| 233 | 254 | } |
| 234 | 255 | |
| 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 | */ | |
| 236 | 263 | export function drawList( |
| 237 | 264 | s: Surface, |
| 238 | 265 | r: Rect, |
| @@ -243,45 +270,36 @@ export function drawList( | ||
| 243 | 270 | ): void { |
| 244 | 271 | if (r.w <= 0 || r.h <= 0) return; |
| 245 | 272 | 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; | |
| 267 | 285 | 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]!, | |
| 273 | 290 | selected: focused && i === state.index, |
| 274 | 291 | focused: i === state.index, |
| 275 | 292 | }); |
| 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); | |
| 284 | 300 | } |
| 301 | state.heights = tall ? heights : undefined; | |
| 302 | if (tall) state.height = Math.max(1, heights.length); | |
| 285 | 303 | scrollbar(s, { x: r.x + r.w - 1, y: r.y, w: 1, h: r.h }, c, { |
| 286 | 304 | total: rows.length, |
| 287 | 305 | offset: from, |
src/tui/screens/subscriptions.ts +23 −7
| @@ -20,7 +20,10 @@ import { | ||
| 20 | 20 | scopesOf, |
| 21 | 21 | setBudget, |
| 22 | 22 | wakeSubscription, |
| 23 | type Subscription, | |
| 23 | 24 | } from "../../core/subscriptions.ts"; |
| 25 | import { windowWords } from "../../actions/standing_words.ts"; | |
| 26 | import { standings } from "../../core/standing.ts"; | |
| 24 | 27 | import { SUBSCRIPTION_WORDS, addRefusal } from "../../core/subscriptions_words.ts"; |
| 25 | 28 | import type { Ctx, Screen } from "../app/app.ts"; |
| 26 | 29 | import { binding } from "../app/keys.ts"; |
| @@ -37,16 +40,22 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 37 | 40 | const state = new ListState(); |
| 38 | 41 | let note = ""; |
| 39 | 42 | const admitted = () => f.user?.isAdmin === true && f.user.kind === "user"; |
| 40 | const items = () => (admitted() ? listSubscriptions(f.app.db) : []); | |
| 41 | 43 | |
| 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) : []; | |
| 43 | 50 | const budgets = budgetsOf(f.app.db); |
| 44 | 51 | const scopes = scopesOf(f.app.db); |
| 52 | const seen = standings(f.app.db, all.map((a) => a.id), new Date()); | |
| 45 | 53 | const now = Date.now(); |
| 46 | return items().map((s) => { | |
| 54 | listed = all.map((s) => { | |
| 47 | 55 | const b = budgets.get(s.id); |
| 48 | 56 | const resting = s.restingUntil !== null && Date.parse(s.restingUntil) > now; |
| 49 | 57 | const pays = (scopes.get(s.id) ?? []).join(", "); |
| 58 | const st = seen.get(s.id); | |
| 50 | 59 | return { |
| 51 | 60 | title: s.name, |
| 52 | 61 | tone: resting ? "attn" : "success", |
| @@ -54,9 +63,14 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 54 | 63 | meta: |
| 55 | 64 | `${b?.budgetUsd == null ? "no budget" : `budget ${money(b.budgetUsd)}`} spent ${money(b?.spentUsd ?? 0)} ` + |
| 56 | 65 | (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), | |
| 57 | 70 | }; |
| 58 | 71 | }); |
| 59 | 72 | }; |
| 73 | load(); | |
| 60 | 74 | |
| 61 | 75 | const setTo = (id: string, raw: string): string | undefined => { |
| 62 | 76 | const text = raw.trim().toLowerCase(); |
| @@ -65,6 +79,7 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 65 | 79 | const refused = budgetRefusal(f.app.db, id, usd); |
| 66 | 80 | if (refused) return refused.says.replace("or null for none", "or none"); |
| 67 | 81 | if (!setBudget(f.app.db, id, usd)) return "The budget was not saved."; |
| 82 | load(); | |
| 68 | 83 | note = usd === null ? "No budget: it spends until you stop it." : `Budget set: ${money(Math.round(usd * 100) / 100)} in all.`; |
| 69 | 84 | ctx.invalidate(); |
| 70 | 85 | return undefined; |
| @@ -92,6 +107,7 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 92 | 107 | createdBy: f.user?.id, |
| 93 | 108 | }); |
| 94 | 109 | if (!added.ok) return addRefusal(added.error, name); |
| 110 | load(); | |
| 95 | 111 | note = `${added.value.name} is in the pool.`; |
| 96 | 112 | ctx.invalidate(); |
| 97 | 113 | return undefined; |
| @@ -106,15 +122,13 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 106 | 122 | keys: [binding(["j", "k"], "move cursor"), binding(["w"], "wake"), binding(["b"], "set budget"), binding(["a"], "add"), binding(["D"], "remove"), binding(["esc"], "back")], |
| 107 | 123 | render(area, s) { |
| 108 | 124 | const c = ctx.theme.c; |
| 109 | const all = items(); | |
| 110 | 125 | const [head, body] = split(area, "v", [len(1), fill(1)]); |
| 111 | 126 | 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."; |
| 112 | 127 | 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); | |
| 114 | 129 | }, |
| 115 | 130 | key(e) { |
| 116 | 131 | note = ""; |
| 117 | const all = items(); | |
| 118 | 132 | if (state.key(e, all.length)) return true; |
| 119 | 133 | if (e.ctrl || e.alt || !admitted()) return false; |
| 120 | 134 | const sub = all[state.index]; |
| @@ -127,6 +141,7 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 127 | 141 | wakeSubscription(f.app.db, sub.id); |
| 128 | 142 | note = `${sub.name}: in use again.`; |
| 129 | 143 | } |
| 144 | load(); | |
| 130 | 145 | ctx.invalidate(); |
| 131 | 146 | } else if (e.key === "b" && sub !== undefined) { |
| 132 | 147 | ctx.push( |
| @@ -161,7 +176,8 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 161 | 176 | onChoose: (choice) => { |
| 162 | 177 | if (choice !== 0 || !admitted()) return; |
| 163 | 178 | 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); | |
| 165 | 181 | ctx.invalidate(); |
| 166 | 182 | }, |
| 167 | 183 | }), |
src/web/pages/subscriptions.ts +4 −31
| @@ -33,7 +33,6 @@ import { | ||
| 33 | 33 | headroomPct, |
| 34 | 34 | setHeadroom, |
| 35 | 35 | standings, |
| 36 | type Figure, | |
| 37 | 36 | type WindowStanding, |
| 38 | 37 | } from "../../core/standing.ts"; |
| 39 | 38 | import { getUserById } from "../../core/users.ts"; |
| @@ -57,6 +56,7 @@ import { | ||
| 57 | 56 | tokenCount, |
| 58 | 57 | userLink, |
| 59 | 58 | } from "../ui.ts"; |
| 59 | import { windowParts } from "../../actions/standing_words.ts"; | |
| 60 | 60 | import { whenText } from "../../crew/limits.ts"; |
| 61 | 61 | import { accountSections } from "./origins.ts"; |
| 62 | 62 | import { liveRegionsJs } from "../client.ts"; |
| @@ -80,37 +80,10 @@ const kindText = (s: Subscription): string => | ||
| 80 | 80 | ? `API key via ${s.apiBase}` |
| 81 | 81 | : "API key"; |
| 82 | 82 | |
| 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. */ | |
| 92 | 84 | 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}`; | |
| 114 | 87 | } |
| 115 | 88 | |
| 116 | 89 | 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 | }); |
new above
offline
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 duplicatesdrawList; givedrawListvariable row heights instead.