Public: anyone can read this; the crew can change it.
The site-admin palette entries live beside their screens, and AGENTS.md says a new screen adds its entry there #7743
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7732-the-site-admin-palette-entries-live-besi → main 3 commits fork
23 files
changed +369
−224
AGENTS.md +2
| @@ -7,3 +7,5 @@ README.md is capped: a new feature's how-to goes in `docs/features/<name>.md`, n | ||
| 7 | 7 | When you write an issue, end it with a `Files:` line naming the files the work touches (`Files: src/a.ts, docs/b.md`). The hot-file hold reads only that line when it is there; without it, every path the issue mentions counts. |
| 8 | 8 | |
| 9 | 9 | A new `forge` command is a new file in `src/cli/commands/` (exporting `command`) plus one line in its `index.ts`. |
| 10 | ||
| 11 | A new terminal screen offered only to site admins exports its palette `entry` from its own module and adds one line to `ENTRIES` in `src/tui/screens/entries.ts`; `after` names the commands it follows. Entries offered to everyone, and entries gated by a helper (`mayCapacity`, `mayManageFlags`, `canSeeMemory`, `telemetryReader` and the like) rather than by `isAdmin`, stay in `forgeCommands` in `src/tui/forge.ts`. |
src/tui/forge.ts +7 −220
| @@ -12,7 +12,6 @@ import type { App as Forge } from "../app.ts"; | ||
| 12 | 12 | import { search } from "../actions/search.ts"; |
| 13 | 13 | import { cleanQuery } from "../core/search.ts"; |
| 14 | 14 | import { guestCodeSearch } from "../http/strangers.ts"; |
| 15 | import { sentinelSummary } from "../crew/sentinel.ts"; | |
| 16 | 15 | import { canRead, getRepo, type Repo } from "../core/repos.ts"; |
| 17 | 16 | import { sourceFor } from "../view/source.ts"; |
| 18 | 17 | import { App, type Ctx, type Screen } from "./app/app.ts"; |
| @@ -26,18 +25,15 @@ import { | ||
| 26 | 25 | ORIGIN_MARK, |
| 27 | 26 | PULL_MARK, |
| 28 | 27 | errorText, |
| 29 | pickerScreen, | |
| 30 | 28 | promptScreen, |
| 29 | blank, | |
| 31 | 30 | openOnWeb, |
| 32 | 31 | runAsync, |
| 33 | 32 | } from "./screens/common.ts"; |
| 34 | 33 | import { chainScreen } from "./screens/chain.ts"; |
| 35 | 34 | import { canSeeMemory } from "../boards/memory.ts"; |
| 36 | import { boardsScreen } from "./screens/boards.ts"; | |
| 37 | 35 | import { askedScreen } from "./screens/asked.ts"; |
| 38 | 36 | import { deciderScreen } from "./screens/decider.ts"; |
| 39 | import { forgesScreen } from "./screens/forges.ts"; | |
| 40 | import { clientRows } from "../spawn/clients.ts"; | |
| 41 | 37 | import { familyScreen } from "./screens/family.ts"; |
| 42 | 38 | import { familyLines, familyView } from "../spawn/family.ts"; |
| 43 | 39 | import { issueScreen } from "./screens/issue.ts"; |
| @@ -45,39 +41,24 @@ import { pullScreen } from "./screens/pull.ts"; | ||
| 45 | 41 | import { blobScreen } from "./screens/repo.ts"; |
| 46 | 42 | import { dashboardScreen } from "./screens/dashboard.ts"; |
| 47 | 43 | import { mayManageFlags } from "../core/flags.ts"; |
| 48 | import { accessScreen } from "./screens/access.ts"; | |
| 49 | 44 | import { mayEditTrustedHosts } from "../origins/trusted-hosts.ts"; |
| 50 | 45 | import { trustedHostsPrompt } from "./screens/trustedhosts.ts"; |
| 51 | 46 | import { flagsScreen } from "./screens/flags.ts"; |
| 52 | import { secretsScreen } from "./screens/secrets.ts"; | |
| 53 | import { scorecardScreen } from "./screens/scorecard.ts"; | |
| 54 | import { crewSplitsScreen } from "./screens/crewsplits.ts"; | |
| 55 | import { crewHandoverScreen } from "./screens/crewhandover.ts"; | |
| 56 | 47 | import { usageScreen } from "./screens/usage.ts"; |
| 57 | 48 | import { canScreen } from "./screens/can.ts"; |
| 58 | import { peopleScreen } from "./screens/people.ts"; | |
| 59 | import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts"; | |
| 49 | import { modelScreen } from "./screens/model.ts"; | |
| 60 | 50 | import { notifyScreen } from "./screens/notify.ts"; |
| 61 | 51 | import { sshKeysScreen } from "./screens/sshkeys.ts"; |
| 62 | 52 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 63 | import { invitesScreen } from "./screens/invites.ts"; | |
| 64 | import { subscriptionsScreen } from "./screens/subscriptions.ts"; | |
| 65 | 53 | import { signInsScreen } from "./screens/signins.ts"; |
| 66 | import { limitsScreen } from "./screens/limits.ts"; | |
| 67 | import { quotasScreen } from "./screens/quotas.ts"; | |
| 68 | import { teamsScreen } from "./screens/teams.ts"; | |
| 69 | import { ENTRIES } from "./screens/entries.ts"; | |
| 54 | import { addEntries } from "./screens/entries.ts"; | |
| 70 | 55 | import { capacityScreen, mayCapacity } from "./screens/capacity.ts"; |
| 71 | import { resilienceScreen } from "./screens/resilience.ts"; | |
| 72 | 56 | import { forgeCeiling } from "../flow/ceiling.ts"; |
| 73 | 57 | import { flowLines, flowScreen } from "./screens/flow.ts"; |
| 74 | 58 | import { readHeld } from "../flow/held.ts"; |
| 75 | import { forgeLines, memoryScreen, systemLines, systemScreen } from "./screens/system.ts"; | |
| 76 | import { readHealth } from "../actions/health.ts"; | |
| 77 | import { grantable, READS_KEPT, recentReads } from "../core/telemetry.ts"; | |
| 78 | import { noteRead, setTelemetryGrant, telemetryReader } from "../actions/telemetry.ts"; | |
| 59 | import { memoryScreen } from "./screens/system.ts"; | |
| 60 | import { noteRead, telemetryReader } from "../actions/telemetry.ts"; | |
| 79 | 61 | import { queuesFor, systemFor } from "../system/telemetry.ts"; |
| 80 | import { live as liveMachine } from "../system/live.ts"; | |
| 81 | 62 | import { apiScreen } from "./screens/api.ts"; |
| 82 | 63 | import { changelogScreen } from "./screens/changelog.ts"; |
| 83 | 64 | import { releasesScreen } from "./screens/releases.ts"; |
| @@ -293,26 +274,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 293 | 274 | run: () => ctx.push(signInsScreen(session, ctx)), |
| 294 | 275 | }, |
| 295 | 276 | ); |
| 296 | // The forge's own faults, as the board's strip shows them: a site admin's. | |
| 297 | if (session.user?.isAdmin === true) | |
| 298 | commands.push({ | |
| 299 | id: "sentinel", | |
| 300 | title: "Sentinel", | |
| 301 | hint: "the forge's open faults", | |
| 302 | run: () => | |
| 303 | ctx.toast( | |
| 304 | sentinelSummary(session.db) ?? "The sentinel has not hunted yet", | |
| 305 | "default", | |
| 306 | ), | |
| 307 | }); | |
| 308 | // The boards, as the page gates them: a site admin who is a person, never an agent (#1817). | |
| 309 | if (session.user?.isAdmin === true && session.user.kind !== "agent") | |
| 310 | commands.push({ | |
| 311 | id: "boards", | |
| 312 | title: "Boards", | |
| 313 | hint: "the forge's boards and their panels", | |
| 314 | run: () => ctx.push(boardsScreen(session, ctx)), | |
| 315 | }); | |
| 316 | 277 | // The forge's RSS beside its JS heap, as /system gates it (#2151). |
| 317 | 278 | if (canSeeMemory(session.user)) |
| 318 | 279 | commands.push({ |
| @@ -340,52 +301,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 340 | 301 | ))); |
| 341 | 302 | }, |
| 342 | 303 | }); |
| 343 | // The machine, as /system says it, and which agents may read it: the owner's, a person (#1789). | |
| 344 | if (session.user?.isAdmin === true && session.user.kind !== "agent") { | |
| 345 | commands.push({ | |
| 346 | id: "system", | |
| 347 | title: "System", | |
| 348 | hint: "the machine now: CPU, memory, disks, processes", | |
| 349 | run: () => ctx.push(systemScreen(ctx, [ | |
| 350 | ...forgeLines(readHealth(session.app, session.user)), | |
| 351 | "", | |
| 352 | ...systemLines(liveMachine().read()), | |
| 353 | ], session)), | |
| 354 | }); | |
| 355 | commands.push({ | |
| 356 | id: "telemetry", | |
| 357 | title: "Telemetry", | |
| 358 | hint: "which agents may read flow and system; enter flips the grant", | |
| 359 | run: () => { | |
| 360 | const actor = session.user; | |
| 361 | const agents = grantable(session.db); | |
| 362 | if (agents.length === 0) return ctx.toast("No agent accounts yet"); | |
| 363 | const last = new Map<string, { n: number; what: string; at: string }>(); | |
| 364 | for (const r of recentReads(session.db, READS_KEPT).reverse()) { | |
| 365 | last.set(r.username, { n: (last.get(r.username)?.n ?? 0) + 1, what: r.what, at: r.at }); | |
| 366 | } | |
| 367 | const readsOf = (name: string): string => { | |
| 368 | const l = last.get(name); | |
| 369 | return l ? `${l.n} read${l.n === 1 ? "" : "s"}, last ${l.what} ${l.at}` : "no reads"; | |
| 370 | }; | |
| 371 | ctx.push( | |
| 372 | pickerScreen(ctx, blank("Telemetry"), { | |
| 373 | title: "Telemetry: telemetry:read", | |
| 374 | items: agents.map((a) => ({ id: a.username, label: a.username, hint: `${a.granted ? "granted" : "not granted"} · ${readsOf(a.username)}` })), | |
| 375 | onPick: ([name]) => { | |
| 376 | const was = agents.find((a) => a.username === name); | |
| 377 | if (!was) return; | |
| 378 | const done = setTelemetryGrant(session.app, actor, was.username, !was.granted); | |
| 379 | ctx.toast( | |
| 380 | done.ok ? `${was.username}: telemetry:read ${done.value.on ? "granted" : "revoked"}` : "Could not change the grant", | |
| 381 | done.ok ? "success" : "attn", | |
| 382 | ); | |
| 383 | }, | |
| 384 | }), | |
| 385 | ); | |
| 386 | }, | |
| 387 | }); | |
| 388 | } | |
| 389 | 304 | // The forge's capacity and guards, as MCP `capacity` gates them: a forge admin who is a person. |
| 390 | 305 | if (mayCapacity(session)) |
| 391 | 306 | commands.push({ |
| @@ -394,14 +309,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 394 | 309 | hint: "concurrency and guards, live; e changes a settable one", |
| 395 | 310 | run: () => ctx.push(capacityScreen(session, ctx)), |
| 396 | 311 | }); |
| 397 | // Where snapshots go and each THREATS.md protection's standing, as /resilience says it: a site admin's; a person admin sets or forgets the destination and the mirror here. | |
| 398 | if (session.user?.isAdmin === true) | |
| 399 | commands.push({ | |
| 400 | id: "resilience", | |
| 401 | title: "Resilience", | |
| 402 | hint: "where snapshots go and the mirror; each protection green, red or untested", | |
| 403 | run: () => ctx.push(resilienceScreen(session, ctx)), | |
| 404 | }); | |
| 405 | 312 | // The forge's API operations, as /api lists them; Enter runs a GET: for anyone. |
| 406 | 313 | commands.push({ |
| 407 | 314 | id: "api", |
| @@ -440,14 +347,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 440 | 347 | hint: "hold new work on a file, release it, or list the holds", |
| 441 | 348 | run: () => ctx.push(fileHoldsPrompt(session, ctx, blank("Held files"))), |
| 442 | 349 | }); |
| 443 | // What outside agents tried, as Settings → Access says it: a site admin's (#1768). | |
| 444 | if (session.user?.isAdmin === true && session.user.kind !== "agent") | |
| 445 | commands.push({ | |
| 446 | id: "access", | |
| 447 | title: "Access", | |
| 448 | hint: "what outside agents tried", | |
| 449 | run: () => ctx.push(accessScreen(session, ctx)), | |
| 450 | }); | |
| 451 | 350 | // Settings → Flags, as the page has it: an admin who is not an agent (#1970). |
| 452 | 351 | if (mayManageFlags(session.user)) |
| 453 | 352 | commands.push({ |
| @@ -456,14 +355,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 456 | 355 | hint: "declared flags and their live values", |
| 457 | 356 | run: () => ctx.push(flagsScreen(session, ctx)), |
| 458 | 357 | }); |
| 459 | // Settings → Secrets, as the page has it: a forge admin; names only, never a value. | |
| 460 | if (session.user?.isAdmin === true) | |
| 461 | commands.push({ | |
| 462 | id: "secrets", | |
| 463 | title: "Secrets", | |
| 464 | hint: "sealed secrets by name; set or remove one", | |
| 465 | run: () => ctx.push(secretsScreen(session, ctx)), | |
| 466 | }); | |
| 467 | 358 | // Settings → Origins' trusted internal hosts: the same admin gate as the page. |
| 468 | 359 | if (mayEditTrustedHosts(session.user)) |
| 469 | 360 | commands.push({ |
| @@ -480,30 +371,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 480 | 371 | hint: "every route, unused ones first", |
| 481 | 372 | run: () => ctx.push(usageScreen(session, ctx)), |
| 482 | 373 | }); |
| 483 | // Settings → Crew's scorecard by model and prompt version: an admin (#2682). | |
| 484 | if (session.user?.isAdmin === true) | |
| 485 | commands.push({ | |
| 486 | id: "crew-scorecard", | |
| 487 | title: "Crew scorecard", | |
| 488 | hint: "agent runs per model and per prompt version", | |
| 489 | run: () => ctx.push(crewSplitsScreen(session, ctx)), | |
| 490 | }); | |
| 491 | // Settings → Crew's hand-overs: issues parked twice, the last restore drill. | |
| 492 | if (session.user?.isAdmin === true) | |
| 493 | commands.push({ | |
| 494 | id: "crew-handover", | |
| 495 | title: "Crew hand-overs", | |
| 496 | hint: "issues parked twice, and the last restore drill", | |
| 497 | run: () => ctx.push(crewHandoverScreen(session, ctx)), | |
| 498 | }); | |
| 499 | // Settings → Crew's scorecard per agent, as the page has it: a forge admin (#2677). | |
| 500 | if (session.user?.isAdmin === true) | |
| 501 | commands.push({ | |
| 502 | id: "scorecard", | |
| 503 | title: "Crew scorecard per agent", | |
| 504 | hint: "each agent's pulls landed, review rounds, red checks, reverts, time to land", | |
| 505 | run: () => ctx.push(scorecardScreen(session, ctx)), | |
| 506 | }); | |
| 507 | 374 | // Proposal tokens for a repository you write, as the API and MCP have them (#2148). |
| 508 | 375 | if (session.user !== undefined) |
| 509 | 376 | commands.push({ |
| @@ -520,21 +387,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 520 | 387 | hint: "what your crew may spend, and add your own subscription", |
| 521 | 388 | run: () => ctx.push(modelScreen(session, ctx)), |
| 522 | 389 | }); |
| 523 | // A capped monthly grant to an account: a forge admin who is a person (#1145). | |
| 524 | if (session.user?.isAdmin === true && session.user.kind === "user") | |
| 525 | commands.push({ | |
| 526 | id: "model-grant", | |
| 527 | title: "Model grant", | |
| 528 | hint: "give an account dollars a month, or take it back", | |
| 529 | run: () => ctx.push(modelGrantPrompt(session, ctx, blank("Model grant"))), | |
| 530 | }); | |
| 531 | if (session.user?.isAdmin === true && session.user.kind === "user") | |
| 532 | commands.push({ | |
| 533 | id: "model-ceiling", | |
| 534 | title: "Model ceiling", | |
| 535 | hint: "set an account's dollars a day and a run", | |
| 536 | run: () => ctx.push(modelCeilingPrompt(session, ctx, blank("Model ceiling"))), | |
| 537 | }); | |
| 538 | 390 | // The caller's own role and row of the roles table, as /settings and `whoami_can` have it (#2304). |
| 539 | 391 | if (session.user !== undefined) |
| 540 | 392 | commands.push({ |
| @@ -543,68 +395,8 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 543 | 395 | hint: "your role and what it allows here", |
| 544 | 396 | run: () => ctx.push(canScreen(session, ctx)), |
| 545 | 397 | }); |
| 546 | // Guests, members and admins, as /settings/people has them (#2301). | |
| 547 | if (session.user?.isAdmin === true && session.user.kind === "user") | |
| 548 | commands.push({ | |
| 549 | id: "people", | |
| 550 | title: "People", | |
| 551 | hint: "who is a guest, a member or an admin; promote or demote", | |
| 552 | run: () => ctx.push(peopleScreen(session, ctx)), | |
| 553 | }); | |
| 554 | // Account quotas, as /admin/limits has them: an admin who is not an agent (#2191). | |
| 555 | if (session.user?.isAdmin === true && session.user.kind !== "agent") | |
| 556 | commands.push({ | |
| 557 | id: "quotas", | |
| 558 | title: "Account quotas", | |
| 559 | hint: "what one account may own, and who changed it", | |
| 560 | run: () => ctx.push(quotasScreen(session, ctx)), | |
| 561 | }); | |
| 562 | // The forge's limits, as MCP `limits` has them: an admin who is not an agent (#7268). | |
| 563 | if (session.user?.isAdmin === true && session.user.kind !== "agent") | |
| 564 | commands.push({ | |
| 565 | id: "limits", | |
| 566 | title: "Limits", | |
| 567 | hint: "what the forge caps, with default and bounds; change one", | |
| 568 | run: () => ctx.push(limitsScreen(session, ctx)), | |
| 569 | }); | |
| 570 | // Crew teams, as /settings/teams has them: an admin who is not an agent (#2679). | |
| 571 | if (session.user?.isAdmin === true && session.user.kind !== "agent") | |
| 572 | commands.push({ | |
| 573 | id: "teams", | |
| 574 | title: "Crew teams", | |
| 575 | hint: "each team's charter, dial and members", | |
| 576 | run: () => ctx.push(teamsScreen(session, ctx)), | |
| 577 | }); | |
| 578 | // Invite links, as /admin/limits has them: an admin who is a person (#2337). | |
| 579 | if (session.user?.isAdmin === true && session.user.kind === "user") | |
| 580 | commands.push({ | |
| 581 | id: "invites", | |
| 582 | title: "Invite links", | |
| 583 | hint: "make a single-use link, see which are open, revoke one", | |
| 584 | run: () => ctx.push(invitesScreen(session, ctx)), | |
| 585 | }); | |
| 586 | // The crew's Claude subscriptions, as MCP `subscriptions` has them: a forge admin (#7244). | |
| 587 | if (session.user?.isAdmin === true && session.user.kind === "user") | |
| 588 | commands.push({ | |
| 589 | id: "subscriptions", | |
| 590 | title: "Crew subscriptions", | |
| 591 | hint: "each account's budget and rest; wake one, set its budget, add or remove one", | |
| 592 | run: () => ctx.push(subscriptionsScreen(session, ctx)), | |
| 593 | }); | |
| 594 | // Entries that live beside their screen: cloud, domains, owner. | |
| 595 | for (const e of ENTRIES) | |
| 596 | if (e.offered(session)) commands.push({ id: e.id, title: e.title, hint: e.hint, run: () => e.run(session, ctx) }); | |
| 597 | // The forges this one sent, as /forges lists them: an admin's only. | |
| 598 | if (session.user?.isAdmin === true) | |
| 599 | commands.push({ | |
| 600 | id: "forges", | |
| 601 | title: "Forges you sent", | |
| 602 | hint: "each forge sent, claimed or not, and its apps", | |
| 603 | run: () => | |
| 604 | runAsync(ctx, async () => { | |
| 605 | ctx.push(forgesScreen(ctx, await clientRows(session.app))); | |
| 606 | }), | |
| 607 | }); | |
| 398 | // Entries that live beside their screen, each at the place it names. | |
| 399 | addEntries(commands, session, ctx); | |
| 608 | 400 | commands.push({ |
| 609 | 401 | id: "family", |
| 610 | 402 | title: "Family", |
| @@ -819,11 +611,6 @@ function countsHint( | ||
| 819 | 611 | .join(" "); |
| 820 | 612 | } |
| 821 | 613 | |
| 822 | /** A screen that draws nothing — the parent a palette-launched modal has. */ | |
| 823 | function blank(title: string): Screen { | |
| 824 | return { title, keys: [], render: () => undefined, key: () => false }; | |
| 825 | } | |
| 826 | ||
| 827 | 614 | /** A prompt with no parent to draw behind it — the palette closed already. */ |
| 828 | 615 | function askFor( |
| 829 | 616 | ctx: Ctx, |
src/tui/screens/access.ts +11
| @@ -16,6 +16,7 @@ import { style } from "../term/style.ts"; | ||
| 16 | 16 | import { truncate } from "../term/width.ts"; |
| 17 | 17 | import type { Session } from "../session.ts"; |
| 18 | 18 | import { ListState, drawList, promptScreen, type ListRow } from "./common.ts"; |
| 19 | import type { PaletteEntry } from "./entries.ts"; | |
| 19 | 20 | |
| 20 | 21 | type Filter = "since" | "ua" | "path"; |
| 21 | 22 | |
| @@ -111,3 +112,13 @@ export function accessScreen(f: Session, ctx: Ctx): Screen { | ||
| 111 | 112 | }; |
| 112 | 113 | return screen; |
| 113 | 114 | } |
| 115 | ||
| 116 | /** What outside agents tried, as Settings → Access says it: a site admin's (#1768). */ | |
| 117 | export const entry: PaletteEntry = { | |
| 118 | id: "access", | |
| 119 | title: "Access", | |
| 120 | hint: "what outside agents tried", | |
| 121 | after: ["file-holds"], | |
| 122 | offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent", | |
| 123 | run: (session, ctx) => ctx.push(accessScreen(session, ctx)), | |
| 124 | }; |
src/tui/screens/boards.ts +11
| @@ -23,6 +23,7 @@ import { emptyState } from "../ui/widgets.ts"; | ||
| 23 | 23 | import { style } from "../term/style.ts"; |
| 24 | 24 | import { truncate } from "../term/width.ts"; |
| 25 | 25 | import { ListState, Scroll, drawList, promptScreen, drawLines, hitList, hitScroll, metaOf, plural, runAsync, type ListRow } from "./common.ts"; |
| 26 | import type { PaletteEntry } from "./entries.ts"; | |
| 26 | 27 | |
| 27 | 28 | /** One line of plain text: a title or a query value may carry escapes the terminal must not obey. */ |
| 28 | 29 | export const plain = (text: string): string => |
| @@ -247,3 +248,13 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen { | ||
| 247 | 248 | }; |
| 248 | 249 | return screen; |
| 249 | 250 | } |
| 251 | ||
| 252 | /** The boards, as the page gates them: a site admin who is a person, never an agent (#1817). */ | |
| 253 | export const entry: PaletteEntry = { | |
| 254 | id: "boards", | |
| 255 | title: "Boards", | |
| 256 | hint: "the forge's boards and their panels", | |
| 257 | after: ["sentinel"], | |
| 258 | offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent", | |
| 259 | run: (session, ctx) => ctx.push(boardsScreen(session, ctx)), | |
| 260 | }; |
src/tui/screens/common.ts +5
| @@ -1072,3 +1072,8 @@ export function ago(iso: string): string { | ||
| 1072 | 1072 | } |
| 1073 | 1073 | |
| 1074 | 1074 | export { plural } from "../../kernel/plural.ts"; |
| 1075 | ||
| 1076 | /** A screen that draws nothing — the parent a palette-launched modal has. */ | |
| 1077 | export function blank(title: string): Screen { | |
| 1078 | return { title, keys: [], render: () => undefined, key: () => false }; | |
| 1079 | } |
src/tui/screens/crewhandover.ts +11
| @@ -15,6 +15,7 @@ import { fill, len, split } from "../ui/layout.ts"; | ||
| 15 | 15 | import { issueScreen } from "./issue.ts"; |
| 16 | 16 | import { local } from "./where.ts"; |
| 17 | 17 | import { ListState, drawList, type ListRow } from "./common.ts"; |
| 18 | import type { PaletteEntry } from "./entries.ts"; | |
| 18 | 19 | |
| 19 | 20 | export function crewHandoverScreen(f: Session, ctx: Ctx): Screen { |
| 20 | 21 | const state = new ListState(); |
| @@ -80,3 +81,13 @@ export function crewHandoverScreen(f: Session, ctx: Ctx): Screen { | ||
| 80 | 81 | }, |
| 81 | 82 | }; |
| 82 | 83 | } |
| 84 | ||
| 85 | /** Settings → Crew's hand-overs: issues parked twice, the last restore drill. */ | |
| 86 | export const entry: PaletteEntry = { | |
| 87 | id: "crew-handover", | |
| 88 | title: "Crew hand-overs", | |
| 89 | hint: "issues parked twice, and the last restore drill", | |
| 90 | after: ["crew-scorecard"], | |
| 91 | offered: (session) => session.user?.isAdmin === true, | |
| 92 | run: (session, ctx) => ctx.push(crewHandoverScreen(session, ctx)), | |
| 93 | }; |
src/tui/screens/crewsplits.ts +11
| @@ -13,6 +13,7 @@ import { truncate } from "../term/width.ts"; | ||
| 13 | 13 | import type { Session } from "../session.ts"; |
| 14 | 14 | import { fill, len, split } from "../ui/layout.ts"; |
| 15 | 15 | import { ListState, drawList, type ListRow } from "./common.ts"; |
| 16 | import type { PaletteEntry } from "./entries.ts"; | |
| 16 | 17 | |
| 17 | 18 | export function splitRows( |
| 18 | 19 | label: string, |
| @@ -62,3 +63,13 @@ export function crewSplitsScreen(f: Session, ctx: Ctx): Screen { | ||
| 62 | 63 | key: (e) => state.key(e, rows.length), |
| 63 | 64 | }; |
| 64 | 65 | } |
| 66 | ||
| 67 | /** Settings → Crew's scorecard by model and prompt version: an admin (#2682). */ | |
| 68 | export const entry: PaletteEntry = { | |
| 69 | id: "crew-scorecard", | |
| 70 | title: "Crew scorecard", | |
| 71 | hint: "agent runs per model and per prompt version", | |
| 72 | after: ["usage", "trusted-hosts", "secrets"], | |
| 73 | offered: (session) => session.user?.isAdmin === true, | |
| 74 | run: (session, ctx) => ctx.push(crewSplitsScreen(session, ctx)), | |
| 75 | }; |
src/tui/screens/entries.ts +67 −1
| @@ -6,18 +6,84 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { Ctx } from "../app/app.ts"; |
| 9 | import type { Command } from "../app/palette.ts"; | |
| 9 | 10 | import type { Session } from "../session.ts"; |
| 11 | import { entry as access } from "./access.ts"; | |
| 12 | import { entry as boards } from "./boards.ts"; | |
| 10 | 13 | import { entry as cloud } from "./cloud.ts"; |
| 14 | import { entry as crewHandover } from "./crewhandover.ts"; | |
| 15 | import { entry as crewSplits } from "./crewsplits.ts"; | |
| 11 | 16 | import { entry as domains } from "./domains.ts"; |
| 17 | import { entry as forges } from "./forges.ts"; | |
| 18 | import { entry as invites } from "./invites.ts"; | |
| 19 | import { entry as limits } from "./limits.ts"; | |
| 20 | import { modelCeilingEntry, modelGrantEntry } from "./model.ts"; | |
| 12 | 21 | import { entry as owner } from "./owner.ts"; |
| 22 | import { entry as people } from "./people.ts"; | |
| 23 | import { entry as quotas } from "./quotas.ts"; | |
| 24 | import { entry as resilience } from "./resilience.ts"; | |
| 25 | import { entry as scorecard } from "./scorecard.ts"; | |
| 26 | import { entry as secrets } from "./secrets.ts"; | |
| 27 | import { entry as sentinel } from "./sentinel.ts"; | |
| 28 | import { entry as subscriptions } from "./subscriptions.ts"; | |
| 29 | import { entry as system } from "./system.ts"; | |
| 30 | import { entry as teams } from "./teams.ts"; | |
| 31 | import { entry as telemetry } from "./telemetry.ts"; | |
| 13 | 32 | |
| 14 | 33 | export interface PaletteEntry { |
| 15 | 34 | readonly id: string; |
| 16 | 35 | readonly title: string; |
| 17 | 36 | readonly hint: string; |
| 37 | /** | |
| 38 | * Ids of the commands it follows, nearest first; the first one the session | |
| 39 | * has wins. Without it, or with none present, the entry goes where | |
| 40 | * `addEntries` is called. | |
| 41 | */ | |
| 42 | readonly after?: readonly string[]; | |
| 18 | 43 | /** Whether this session is offered the entry. */ |
| 19 | 44 | offered(session: Session): boolean; |
| 20 | 45 | run(session: Session, ctx: Ctx): void; |
| 21 | 46 | } |
| 22 | 47 | |
| 23 | export const ENTRIES: readonly PaletteEntry[] = [cloud, domains, owner]; | |
| 48 | /** In the order the palette offers them; an entry follows the ones it names in `after`. */ | |
| 49 | export const ENTRIES: readonly PaletteEntry[] = [ | |
| 50 | sentinel, | |
| 51 | boards, | |
| 52 | system, | |
| 53 | telemetry, | |
| 54 | resilience, | |
| 55 | access, | |
| 56 | secrets, | |
| 57 | crewSplits, | |
| 58 | crewHandover, | |
| 59 | scorecard, | |
| 60 | modelGrantEntry, | |
| 61 | modelCeilingEntry, | |
| 62 | people, | |
| 63 | quotas, | |
| 64 | limits, | |
| 65 | teams, | |
| 66 | invites, | |
| 67 | subscriptions, | |
| 68 | cloud, | |
| 69 | domains, | |
| 70 | owner, | |
| 71 | forges, | |
| 72 | ]; | |
| 73 | ||
| 74 | /** Put each entry the session is offered into `commands`, after the command it names or at the end. */ | |
| 75 | export function addEntries(commands: Command[], session: Session, ctx: Ctx): void { | |
| 76 | const placed = new Map<string, number>(); | |
| 77 | for (const e of ENTRIES) { | |
| 78 | if (!e.offered(session)) continue; | |
| 79 | const command: Command = { id: e.id, title: e.title, hint: e.hint, run: () => e.run(session, ctx) }; | |
| 80 | const anchor = e.after?.find((id) => commands.some((c) => c.id === id)); | |
| 81 | if (anchor === undefined) { | |
| 82 | commands.push(command); | |
| 83 | continue; | |
| 84 | } | |
| 85 | const before = placed.get(anchor) ?? 0; | |
| 86 | commands.splice(commands.findIndex((c) => c.id === anchor) + 1 + before, 0, command); | |
| 87 | placed.set(anchor, before + 1); | |
| 88 | } | |
| 89 | } |
src/tui/screens/forges.ts +14 −1
| @@ -10,7 +10,9 @@ import { ATTENTION_WORDS, CLAIM_WORDS, claimState, runningWords, spendWords, typ | ||
| 10 | 10 | import { useLine } from "../../spawn/usage.ts"; |
| 11 | 11 | import { timeAgo } from "../../web/ui.ts"; |
| 12 | 12 | import { card } from "../ui/widgets.ts"; |
| 13 | import { drawFacts } from "./common.ts"; | |
| 13 | import { drawFacts, runAsync } from "./common.ts"; | |
| 14 | import type { PaletteEntry } from "./entries.ts"; | |
| 15 | import { clientRows } from "../../spawn/clients.ts"; | |
| 14 | 16 | |
| 15 | 17 | /** [name, "Not reporting · for Maria · Claimed · 3 apps running"] per row; an empty line when none. */ |
| 16 | 18 | export function forgeLines(rows: readonly ClientRow[]): [string, string][] { |
| @@ -51,3 +53,14 @@ export function forgesScreen(ctx: Ctx, rows: readonly ClientRow[]): Screen { | ||
| 51 | 53 | }, |
| 52 | 54 | }; |
| 53 | 55 | } |
| 56 | ||
| 57 | /** The forges this one sent, as /forges lists them: an admin's only. */ | |
| 58 | export const entry: PaletteEntry = { | |
| 59 | id: "forges", | |
| 60 | title: "Forges you sent", | |
| 61 | hint: "each forge sent, claimed or not, and its apps", | |
| 62 | offered: (session) => session.user?.isAdmin === true, | |
| 63 | run: (session, ctx) => runAsync(ctx, async () => { | |
| 64 | ctx.push(forgesScreen(ctx, await clientRows(session.app))); | |
| 65 | }), | |
| 66 | }; |
src/tui/screens/invites.ts +11
| @@ -21,6 +21,7 @@ import { truncate } from "../term/width.ts"; | ||
| 21 | 21 | import type { Session } from "../session.ts"; |
| 22 | 22 | import { fill, len, split } from "../ui/layout.ts"; |
| 23 | 23 | import { ListState, confirmScreen, drawList, pickerScreen, promptScreen, type ListRow } from "./common.ts"; |
| 24 | import type { PaletteEntry } from "./entries.ts"; | |
| 24 | 25 | |
| 25 | 26 | export function invitesScreen(f: Session, ctx: Ctx): Screen { |
| 26 | 27 | const state = new ListState(); |
| @@ -141,3 +142,13 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen { | ||
| 141 | 142 | }; |
| 142 | 143 | return screen; |
| 143 | 144 | } |
| 145 | ||
| 146 | /** Invite links, as /admin/limits has them: an admin who is a person (#2337). */ | |
| 147 | export const entry: PaletteEntry = { | |
| 148 | id: "invites", | |
| 149 | title: "Invite links", | |
| 150 | hint: "make a single-use link, see which are open, revoke one", | |
| 151 | after: ["teams", "limits", "quotas", "people", "can"], | |
| 152 | offered: (session) => session.user?.isAdmin === true && session.user.kind === "user", | |
| 153 | run: (session, ctx) => ctx.push(invitesScreen(session, ctx)), | |
| 154 | }; |
src/tui/screens/limits.ts +11
| @@ -16,6 +16,7 @@ import { truncate } from "../term/width.ts"; | ||
| 16 | 16 | import type { Session } from "../session.ts"; |
| 17 | 17 | import { fill, len, split } from "../ui/layout.ts"; |
| 18 | 18 | import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "./common.ts"; |
| 19 | import type { PaletteEntry } from "./entries.ts"; | |
| 19 | 20 | |
| 20 | 21 | export function limitsScreen(f: Session, ctx: Ctx): Screen { |
| 21 | 22 | const state = new ListState(); |
| @@ -99,3 +100,13 @@ export function limitsScreen(f: Session, ctx: Ctx): Screen { | ||
| 99 | 100 | }; |
| 100 | 101 | return screen; |
| 101 | 102 | } |
| 103 | ||
| 104 | /** The forge's limits, as MCP `limits` has them: an admin who is not an agent (#7268). */ | |
| 105 | export const entry: PaletteEntry = { | |
| 106 | id: "limits", | |
| 107 | title: "Limits", | |
| 108 | hint: "what the forge caps, with default and bounds; change one", | |
| 109 | after: ["quotas", "people", "can"], | |
| 110 | offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent", | |
| 111 | run: (session, ctx) => ctx.push(limitsScreen(session, ctx)), | |
| 112 | }; |
src/tui/screens/model.ts +22 −1
| @@ -22,7 +22,8 @@ import { binding } from "../app/keys.ts"; | ||
| 22 | 22 | import { style } from "../term/style.ts"; |
| 23 | 23 | import { truncate } from "../term/width.ts"; |
| 24 | 24 | import type { Session } from "../session.ts"; |
| 25 | import { later, promptScreen } from "./common.ts"; | |
| 25 | import { blank, later, promptScreen } from "./common.ts"; | |
| 26 | import type { PaletteEntry } from "./entries.ts"; | |
| 26 | 27 | |
| 27 | 28 | /** The signed-in person's budget line, and `a` to add their own subscription. */ |
| 28 | 29 | export function modelScreen(f: Session, ctx: Ctx): Screen { |
| @@ -131,3 +132,23 @@ export function modelCeilingPrompt(f: Session, ctx: Ctx, parent: Screen): Screen | ||
| 131 | 132 | ), |
| 132 | 133 | }); |
| 133 | 134 | } |
| 135 | ||
| 136 | /** A capped monthly grant to an account: a forge admin who is a person (#1145). */ | |
| 137 | export const modelGrantEntry: PaletteEntry = { | |
| 138 | id: "model-grant", | |
| 139 | title: "Model grant", | |
| 140 | hint: "give an account dollars a month, or take it back", | |
| 141 | after: ["model"], | |
| 142 | offered: (session) => session.user?.isAdmin === true && session.user.kind === "user", | |
| 143 | run: (session, ctx) => ctx.push(modelGrantPrompt(session, ctx, blank("Model grant"))), | |
| 144 | }; | |
| 145 | ||
| 146 | /** An account's dollars a day and a run: a forge admin who is a person (#1145). */ | |
| 147 | export const modelCeilingEntry: PaletteEntry = { | |
| 148 | id: "model-ceiling", | |
| 149 | title: "Model ceiling", | |
| 150 | hint: "set an account's dollars a day and a run", | |
| 151 | after: ["model-grant"], | |
| 152 | offered: (session) => session.user?.isAdmin === true && session.user.kind === "user", | |
| 153 | run: (session, ctx) => ctx.push(modelCeilingPrompt(session, ctx, blank("Model ceiling"))), | |
| 154 | }; |
src/tui/screens/people.ts +11
| @@ -22,6 +22,7 @@ import type { Session } from "../session.ts"; | ||
| 22 | 22 | import { style } from "../term/style.ts"; |
| 23 | 23 | import { truncate } from "../term/width.ts"; |
| 24 | 24 | import { confirmScreen, drawList, later, ListState, promptScreen, runAsync, type ListRow } from "./common.ts"; |
| 25 | import type { PaletteEntry } from "./entries.ts"; | |
| 25 | 26 | |
| 26 | 27 | /** |
| 27 | 28 | * The accounts page's no-mail card in one line that fits 80 columns: without |
| @@ -164,3 +165,13 @@ export function peopleScreen(f: Session, ctx: Ctx): Screen { | ||
| 164 | 165 | }; |
| 165 | 166 | return screen; |
| 166 | 167 | } |
| 168 | ||
| 169 | /** Guests, members and admins, as /settings/people has them (#2301). */ | |
| 170 | export const entry: PaletteEntry = { | |
| 171 | id: "people", | |
| 172 | title: "People", | |
| 173 | hint: "who is a guest, a member or an admin; promote or demote", | |
| 174 | after: ["can"], | |
| 175 | offered: (session) => session.user?.isAdmin === true && session.user.kind === "user", | |
| 176 | run: (session, ctx) => ctx.push(peopleScreen(session, ctx)), | |
| 177 | }; |
src/tui/screens/quotas.ts +11
| @@ -17,6 +17,7 @@ import { truncate } from "../term/width.ts"; | ||
| 17 | 17 | import type { Session } from "../session.ts"; |
| 18 | 18 | import { fill, len, split } from "../ui/layout.ts"; |
| 19 | 19 | import { ListState, drawList, promptScreen, type ListRow } from "./common.ts"; |
| 20 | import type { PaletteEntry } from "./entries.ts"; | |
| 20 | 21 | |
| 21 | 22 | const limit = (v: number | string | null): string => (v === null || v === "" ? "no limit" : String(v)); |
| 22 | 23 | |
| @@ -95,3 +96,13 @@ export function quotasScreen(f: Session, ctx: Ctx): Screen { | ||
| 95 | 96 | }; |
| 96 | 97 | return screen; |
| 97 | 98 | } |
| 99 | ||
| 100 | /** Account quotas, as /admin/limits has them: an admin who is not an agent (#2191). */ | |
| 101 | export const entry: PaletteEntry = { | |
| 102 | id: "quotas", | |
| 103 | title: "Account quotas", | |
| 104 | hint: "what one account may own, and who changed it", | |
| 105 | after: ["people", "can"], | |
| 106 | offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent", | |
| 107 | run: (session, ctx) => ctx.push(quotasScreen(session, ctx)), | |
| 108 | }; |
src/tui/screens/resilience.ts +11
| @@ -38,6 +38,7 @@ import type { Session } from "../session.ts"; | ||
| 38 | 38 | import { pad, wrap } from "../term/width.ts"; |
| 39 | 39 | import { dialog } from "../ui/widgets.ts"; |
| 40 | 40 | import { confirmScreen, later, promptScreen, runAsync } from "./common.ts"; |
| 41 | import type { PaletteEntry } from "./entries.ts"; | |
| 41 | 42 | |
| 42 | 43 | const WIDTH = 72; |
| 43 | 44 | const INNER = WIDTH - 6; |
| @@ -283,3 +284,13 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 283 | 284 | }; |
| 284 | 285 | return screen; |
| 285 | 286 | } |
| 287 | ||
| 288 | /** Where snapshots go and each THREATS.md protection's standing, as /resilience says it: a site admin's; a person admin sets or forgets the destination and the mirror here. */ | |
| 289 | export const entry: PaletteEntry = { | |
| 290 | id: "resilience", | |
| 291 | title: "Resilience", | |
| 292 | hint: "where snapshots go and the mirror; each protection green, red or untested", | |
| 293 | after: ["capacity", "flow", "system-memory", "sentinel"], | |
| 294 | offered: (session) => session.user?.isAdmin === true, | |
| 295 | run: (session, ctx) => ctx.push(resilienceScreen(session, ctx)), | |
| 296 | }; |
src/tui/screens/scorecard.ts +11
| @@ -29,6 +29,7 @@ import { truncate } from "../term/width.ts"; | ||
| 29 | 29 | import type { Session } from "../session.ts"; |
| 30 | 30 | import { fill, len, split } from "../ui/layout.ts"; |
| 31 | 31 | import { ListState, drawList, type ListRow } from "./common.ts"; |
| 32 | import type { PaletteEntry } from "./entries.ts"; | |
| 32 | 33 | |
| 33 | 34 | const landed = (s: Scorecard | undefined) => |
| 34 | 35 | s === undefined ? "—" : `${s.landed} of ${s.opened}`; |
| @@ -171,3 +172,13 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen { | ||
| 171 | 172 | key: (e) => state.key(e, rows.length), |
| 172 | 173 | }; |
| 173 | 174 | } |
| 175 | ||
| 176 | /** Settings → Crew's scorecard per agent, as the page has it: a forge admin (#2677). */ | |
| 177 | export const entry: PaletteEntry = { | |
| 178 | id: "scorecard", | |
| 179 | title: "Crew scorecard per agent", | |
| 180 | hint: "each agent's pulls landed, review rounds, red checks, reverts, time to land", | |
| 181 | after: ["crew-handover"], | |
| 182 | offered: (session) => session.user?.isAdmin === true, | |
| 183 | run: (session, ctx) => ctx.push(scorecardScreen(session, ctx)), | |
| 184 | }; |
src/tui/screens/secrets.ts +11
| @@ -17,6 +17,7 @@ import { truncate } from "../term/width.ts"; | ||
| 17 | 17 | import type { Session } from "../session.ts"; |
| 18 | 18 | import { fill, len, split } from "../ui/layout.ts"; |
| 19 | 19 | import { confirmScreen, drawList, later, ListState, promptScreen } from "./common.ts"; |
| 20 | import type { PaletteEntry } from "./entries.ts"; | |
| 20 | 21 | |
| 21 | 22 | /** The action's refusals in this screen's words. */ |
| 22 | 23 | const ERRORS: Record<string, string> = { |
| @@ -170,3 +171,13 @@ export function secretsScreen(f: Session, ctx: Ctx): Screen { | ||
| 170 | 171 | }; |
| 171 | 172 | return screen; |
| 172 | 173 | } |
| 174 | ||
| 175 | /** Settings → Secrets, as the page has it: a forge admin; names only, never a value. */ | |
| 176 | export const entry: PaletteEntry = { | |
| 177 | id: "secrets", | |
| 178 | title: "Secrets", | |
| 179 | hint: "sealed secrets by name; set or remove one", | |
| 180 | after: ["flags", "access", "file-holds"], | |
| 181 | offered: (session) => session.user?.isAdmin === true, | |
| 182 | run: (session, ctx) => ctx.push(secretsScreen(session, ctx)), | |
| 183 | }; |
src/tui/screens/sentinel.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | /** The Sentinel palette entry. */ | |
| 2 | ||
| 3 | import type { PaletteEntry } from "./entries.ts"; | |
| 4 | import { sentinelSummary } from "../../crew/sentinel.ts"; | |
| 5 | ||
| 6 | /** The forge's own faults, as the board's strip shows them: a site admin's. */ | |
| 7 | export const entry: PaletteEntry = { | |
| 8 | id: "sentinel", | |
| 9 | title: "Sentinel", | |
| 10 | hint: "the forge's open faults", | |
| 11 | after: ["signins"], | |
| 12 | offered: (session) => session.user?.isAdmin === true, | |
| 13 | run: (session, ctx) => ctx.toast(sentinelSummary(session.db) ?? "The sentinel has not hunted yet", "default"), | |
| 14 | }; |
src/tui/screens/subscriptions.ts +11
| @@ -29,6 +29,7 @@ import { truncate } from "../term/width.ts"; | ||
| 29 | 29 | import type { Session } from "../session.ts"; |
| 30 | 30 | import { fill, len, split } from "../ui/layout.ts"; |
| 31 | 31 | import { ListState, confirmScreen, drawList, later, promptScreen, type ListRow } from "./common.ts"; |
| 32 | import type { PaletteEntry } from "./entries.ts"; | |
| 32 | 33 | |
| 33 | 34 | const money = (n: number): string => `$${n.toFixed(2)}`; |
| 34 | 35 | |
| @@ -171,3 +172,13 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 171 | 172 | }; |
| 172 | 173 | return screen; |
| 173 | 174 | } |
| 175 | ||
| 176 | /** The crew's Claude subscriptions, as MCP `subscriptions` has them: a forge admin (#7244). */ | |
| 177 | export const entry: PaletteEntry = { | |
| 178 | id: "subscriptions", | |
| 179 | title: "Crew subscriptions", | |
| 180 | hint: "each account's budget and rest; wake one, set its budget, add or remove one", | |
| 181 | after: ["invites", "teams", "limits", "quotas", "people", "can"], | |
| 182 | offered: (session) => session.user?.isAdmin === true && session.user.kind === "user", | |
| 183 | run: (session, ctx) => ctx.push(subscriptionsScreen(session, ctx)), | |
| 184 | }; |
src/tui/screens/system.ts +17 −1
| @@ -19,7 +19,6 @@ | ||
| 19 | 19 | |
| 20 | 20 | import type { Ctx, Screen } from "../app/app.ts"; |
| 21 | 21 | import type { SystemView } from "../../system/view.ts"; |
| 22 | import type { readHealth } from "../../actions/health.ts"; | |
| 23 | 22 | import { rolloutLine, type ForgeHealth } from "../../kernel/guard.ts"; |
| 24 | 23 | import { canSeeMemory, memoryHours } from "../../boards/memory.ts"; |
| 25 | 24 | import { telemetryReader } from "../../actions/telemetry.ts"; |
| @@ -33,6 +32,9 @@ import { style } from "../term/style.ts"; | ||
| 33 | 32 | import { pad, truncate, wrap } from "../term/width.ts"; |
| 34 | 33 | import { dialog } from "../ui/widgets.ts"; |
| 35 | 34 | import { Scroll, confirmScreen, drawLines, hitScroll, later, pickerScreen } from "./common.ts"; |
| 35 | import type { PaletteEntry } from "./entries.ts"; | |
| 36 | import { readHealth } from "../../actions/health.ts"; | |
| 37 | import { live as liveMachine } from "../../system/live.ts"; | |
| 36 | 38 | |
| 37 | 39 | const WIDTH = 72; |
| 38 | 40 | const GROUPS = 6; |
| @@ -260,3 +262,17 @@ export function memoryScreen(session: Session, ctx: Ctx): Screen { | ||
| 260 | 262 | }, |
| 261 | 263 | }; |
| 262 | 264 | } |
| 265 | ||
| 266 | /** The machine, as /system says it, and which agents may read it: the owner's, a person (#1789). */ | |
| 267 | export const entry: PaletteEntry = { | |
| 268 | id: "system", | |
| 269 | title: "System", | |
| 270 | hint: "the machine now: CPU, memory, disks, processes", | |
| 271 | after: ["flow", "system-memory", "boards"], | |
| 272 | offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent", | |
| 273 | run: (session, ctx) => ctx.push(systemScreen(ctx, [ | |
| 274 | ...forgeLines(readHealth(session.app, session.user)), | |
| 275 | "", | |
| 276 | ...systemLines(liveMachine().read()), | |
| 277 | ], session)), | |
| 278 | }; |
src/tui/screens/teams.ts +11
| @@ -15,6 +15,7 @@ import { truncate } from "../term/width.ts"; | ||
| 15 | 15 | import type { Session } from "../session.ts"; |
| 16 | 16 | import { fill, len, split } from "../ui/layout.ts"; |
| 17 | 17 | import { ListState, drawList, promptScreen, type ListRow } from "./common.ts"; |
| 18 | import type { PaletteEntry } from "./entries.ts"; | |
| 18 | 19 | |
| 19 | 20 | export function teamsScreen(f: Session, ctx: Ctx): Screen { |
| 20 | 21 | const state = new ListState(); |
| @@ -95,3 +96,13 @@ export function teamsScreen(f: Session, ctx: Ctx): Screen { | ||
| 95 | 96 | }; |
| 96 | 97 | return screen; |
| 97 | 98 | } |
| 99 | ||
| 100 | /** Crew teams, as /settings/teams has them: an admin who is not an agent (#2679). */ | |
| 101 | export const entry: PaletteEntry = { | |
| 102 | id: "teams", | |
| 103 | title: "Crew teams", | |
| 104 | hint: "each team's charter, dial and members", | |
| 105 | after: ["limits", "quotas", "people", "can"], | |
| 106 | offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent", | |
| 107 | run: (session, ctx) => ctx.push(teamsScreen(session, ctx)), | |
| 108 | }; |
src/tui/screens/telemetry.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** The Telemetry palette entry: which agents may read flow and system. */ | |
| 2 | ||
| 3 | import { setTelemetryGrant } from "../../actions/telemetry.ts"; | |
| 4 | import { grantable, READS_KEPT, recentReads } from "../../core/telemetry.ts"; | |
| 5 | import { blank, pickerScreen } from "./common.ts"; | |
| 6 | import type { PaletteEntry } from "./entries.ts"; | |
| 7 | ||
| 8 | /** Grant or revoke `telemetry:read` on an agent account: a site admin who is a person. */ | |
| 9 | export const entry: PaletteEntry = { | |
| 10 | id: "telemetry", | |
| 11 | title: "Telemetry", | |
| 12 | hint: "which agents may read flow and system; enter flips the grant", | |
| 13 | after: ["system"], | |
| 14 | offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent", | |
| 15 | run: (session, ctx) => { | |
| 16 | const actor = session.user; | |
| 17 | const agents = grantable(session.db); | |
| 18 | if (agents.length === 0) return ctx.toast("No agent accounts yet"); | |
| 19 | const last = new Map<string, { n: number; what: string; at: string }>(); | |
| 20 | for (const r of recentReads(session.db, READS_KEPT).reverse()) { | |
| 21 | last.set(r.username, { n: (last.get(r.username)?.n ?? 0) + 1, what: r.what, at: r.at }); | |
| 22 | } | |
| 23 | const readsOf = (name: string): string => { | |
| 24 | const l = last.get(name); | |
| 25 | return l ? `${l.n} read${l.n === 1 ? "" : "s"}, last ${l.what} ${l.at}` : "no reads"; | |
| 26 | }; | |
| 27 | ctx.push( | |
| 28 | pickerScreen(ctx, blank("Telemetry"), { | |
| 29 | title: "Telemetry: telemetry:read", | |
| 30 | items: agents.map((a) => ({ id: a.username, label: a.username, hint: `${a.granted ? "granted" : "not granted"} · ${readsOf(a.username)}` })), | |
| 31 | onPick: ([name]) => { | |
| 32 | const was = agents.find((a) => a.username === name); | |
| 33 | if (!was) return; | |
| 34 | const done = setTelemetryGrant(session.app, actor, was.username, !was.granted); | |
| 35 | ctx.toast( | |
| 36 | done.ok ? `${was.username}: telemetry:read ${done.value.on ? "granted" : "revoked"}` : "Could not change the grant", | |
| 37 | done.ok ? "success" : "attn", | |
| 38 | ); | |
| 39 | }, | |
| 40 | }), | |
| 41 | ); | |
| 42 | }, | |
| 43 | }; |
test/palette-entries.test.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import type { Command } from "../src/tui/app/palette.ts"; | |
| 3 | import { addEntries, ENTRIES, type PaletteEntry } from "../src/tui/screens/entries.ts"; | |
| 4 | ||
| 5 | const base = (...ids: string[]): Command[] => ids.map((id) => ({ id, title: id, run: () => undefined })); | |
| 6 | const stub = (id: string, after?: string[]): PaletteEntry => ({ | |
| 7 | id, title: id, hint: "", ...(after ? { after } : {}), offered: () => true, run: () => undefined, | |
| 8 | }); | |
| 9 | const place = (commands: Command[], entries: PaletteEntry[]): string[] => { | |
| 10 | const saved = [...ENTRIES]; | |
| 11 | (ENTRIES as PaletteEntry[]).splice(0, ENTRIES.length, ...entries); | |
| 12 | try { | |
| 13 | addEntries(commands, {} as never, {} as never); | |
| 14 | } finally { | |
| 15 | (ENTRIES as PaletteEntry[]).splice(0, ENTRIES.length, ...saved); | |
| 16 | } | |
| 17 | return commands.map((c) => c.id); | |
| 18 | }; | |
| 19 | ||
| 20 | test("entries naming one anchor stack after it in list order", () => { | |
| 21 | expect(place(base("a", "b", "c"), [stub("x", ["a"]), stub("y", ["a"])])).toEqual(["a", "x", "y", "b", "c"]); | |
| 22 | }); | |
| 23 | ||
| 24 | test("the first id of `after` the session has wins", () => { | |
| 25 | expect(place(base("a", "b", "c"), [stub("x", ["missing", "b", "a"])])).toEqual(["a", "b", "x", "c"]); | |
| 26 | }); | |
| 27 | ||
| 28 | test("with no anchor present, or none named, an entry goes at the end", () => { | |
| 29 | expect(place(base("a", "b"), [stub("x", ["missing"]), stub("y")])).toEqual(["a", "b", "x", "y"]); | |
| 30 | }); | |
| 31 | ||
| 32 | test("an entry the session is not offered is left out", () => { | |
| 33 | const hidden = { ...stub("x", ["a"]), offered: () => false }; | |
| 34 | expect(place(base("a", "b"), [hidden])).toEqual(["a", "b"]); | |
| 35 | }); |
new above
offline