Public: anyone can read this; the crew can change it.
The terminal's new-forge screen offers the same three sizes, and the chosen size reaches the plan #5684
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5681-the-terminal-s-new-forge-screen-offers-t → main 4 commits fork 3 threads · 3 unresolved
3 files
changed +128
−5
README.md +2 −2
| @@ -456,9 +456,9 @@ own for it: the same asks are the dashboard's On you card. | ||
| 456 | 456 | A client's forge: `Ctrl+K`, then **New forge** (any signed-in person's, as the |
| 457 | 457 | page is), is `/forges/new` in the terminal — name, for whom, what it is for, |
| 458 | 458 | apps to fork (`owner/name`, comma-separated), a fresh one-line app, model key |
| 459 | (hidden as typed), API base URL, budget, how it follows the code and a size (`small` by default, not sent; `medium` or `large` is), through the same `spawnDoor` as the | |
| 459 | (hidden as typed), API base URL, budget, how it follows the code and a size (`small` by default, not sent; `medium` or `large` is; left, right or space choose), through the same `spawnDoor` as the | |
| 460 | 460 | page, `POST /api/v1/forges` and the MCP `spawn`. `Ctrl+P` shows the plan and |
| 461 | makes nothing; `enter` on the plan spawns, and the claim link is shown once, in | |
| 461 | makes nothing (it shows a Size line when one is chosen); `enter` on the plan spawns, and the claim link is shown once, in | |
| 462 | 462 | a dialog, never kept. |
| 463 | 463 | |
| 464 | 464 | The family across forges: `:` → Family in the terminal, `/family` on the web, |
src/tui/screens/newforge.ts +19 −3
| @@ -7,8 +7,8 @@ | ||
| 7 | 7 | * dialog, and kept nowhere. Any signed-in person's, as the page is. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { DEFAULT_SIZE, SIZES, type Size } from "../../spawn/sizes.ts"; | |
| 10 | 11 | import { childUrlOf, spawnDoor } from "../../spawn/door.ts"; |
| 11 | import { DEFAULT_SIZE } from "../../spawn/sizes.ts"; | |
| 12 | 12 | import { MAX_BUDGET_USD, MIN_BUDGET_USD } from "../../spawn/plan.ts"; |
| 13 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 14 | import { binding } from "../app/keys.ts"; |
| @@ -23,8 +23,12 @@ interface Spec { | ||
| 23 | 23 | readonly label: string; |
| 24 | 24 | readonly hint: string; |
| 25 | 25 | readonly secret?: boolean; |
| 26 | /** A choice, not text: left, right and space move through it. */ | |
| 27 | readonly choices?: readonly string[]; | |
| 26 | 28 | } |
| 27 | 29 | |
| 30 | const SIZE_CHOICES = Object.keys(SIZES) as Size[]; | |
| 31 | ||
| 28 | 32 | const SPECS: readonly Spec[] = [ |
| 29 | 33 | { key: "name", label: "Name", hint: "Lowercase letters, digits and dashes." }, |
| 30 | 34 | { key: "for", label: "For whom", hint: "A person or client, for your list of forges sent. Optional." }, |
| @@ -35,7 +39,7 @@ const SPECS: readonly Spec[] = [ | ||
| 35 | 39 | { key: "modelEndpoint", label: "API base URL", hint: "A gateway for the key; empty for Anthropic. Optional." }, |
| 36 | 40 | { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` }, |
| 37 | 41 | { key: "followMode", label: "Follows this forge's code", hint: "tell-me (default) or take-updates. Recorded for now: nothing tells you or takes updates yet." }, |
| 38 | { key: "size", label: "Size", hint: "small (default, not recorded), medium or large. Optional." }, | |
| 42 | { key: "size", label: "Size", hint: "small (default), medium or large: left and right choose, or type it.", choices: SIZE_CHOICES }, | |
| 39 | 43 | ]; |
| 40 | 44 | |
| 41 | 45 | /** The door's "field: reason" refusal, as the field it names and the reason. */ |
| @@ -111,6 +115,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 111 | 115 | const copied = (body["copied"] as string[] | undefined) ?? []; |
| 112 | 116 | const bytes = Number(body["bytes"] ?? 0); |
| 113 | 117 | const budget = (body["plan"] as { budgetUsd?: number | null } | undefined)?.budgetUsd ?? null; |
| 118 | const size = (body["plan"] as { size?: string } | undefined)?.size; | |
| 114 | 119 | plan = [ |
| 115 | 120 | `A forge at ${childUrlOf(f.app, val("name"))}.`, |
| 116 | 121 | `Recorded for now, not acted on: it follows this forge's code as ${val("followMode") === "" ? "tell-me" : val("followMode")}.`, |
| @@ -118,6 +123,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 118 | 123 | ...(budget === null ? [] : [`Its crew runs on your key, up to $${budget} for the key's whole life.`]), |
| 119 | 124 | "Copied into it:", |
| 120 | 125 | ...copied.map((c) => ` ${c}`), |
| 126 | ...(size === undefined ? [] : [`Size: ${size}.`]), | |
| 121 | 127 | `About ${Math.max(1, Math.round(bytes / 1024))} KB. Nothing is made until you press enter.`, |
| 122 | 128 | ...(typeof body["keyNote"] === "string" ? [body["keyNote"]] : []), |
| 123 | 129 | ]; |
| @@ -165,7 +171,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 165 | 171 | let cursor: { x: number; y: number } | null = null; |
| 166 | 172 | SPECS.slice(first, first + rows).forEach((spec, n) => { |
| 167 | 173 | const i = first + n; |
| 168 | const raw = values[spec.key] ?? ""; | |
| 174 | const raw = spec.choices === undefined ? (values[spec.key] ?? "") : (values[spec.key] || DEFAULT_SIZE); | |
| 169 | 175 | const shown = spec.secret === true ? "•".repeat([...raw].length) : raw; |
| 170 | 176 | const at = field(s, { x: area.x, y: area.y + n * 3, w: Math.min(70, area.w), h: 3 }, c, { |
| 171 | 177 | label: spec.label, |
| @@ -200,6 +206,16 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 200 | 206 | focus = (focus + 1) % SPECS.length; |
| 201 | 207 | return true; |
| 202 | 208 | } |
| 209 | const choices = SPECS[focus]!.choices; | |
Cycling stores the value, so a later typed character appends to it ("small"+"m"). Replace the value when typing on a choice field. |
||
| 210 | const step = e.key === "left" ? -1 : e.key === "right" || e.key === " " || e.key === "space" ? 1 : 0; | |
outdated
Tab and Ctrl+P are handled earlier, so returning false for other keys on this row is fine. Typing a letter does nothing here and the hint says left and right choose. Consider the hint also naming space. |
||
| 211 | if (choices !== undefined && step !== 0) { | |
| 212 | // A choice can be cycled or typed; both end in a value the door checks. | |
| 213 | const k = SPECS[focus]!.key; | |
| 214 | const at = Math.max(0, choices.indexOf(values[k] || DEFAULT_SIZE)); | |
| 215 | values[k] = choices[(at + step + choices.length) % choices.length]!; | |
| 216 | error = undefined; | |
| 217 | return true; | |
| 218 | } | |
| 203 | 219 | if (e.key === "backspace") { |
| 204 | 220 | const k = SPECS[focus]!.key; |
| 205 | 221 | values[k] = [...(values[k] ?? "")].slice(0, -1).join(""); |
test/tui-newforge-size.test.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | /** | |
| 2 | * The terminal's new-forge screen offers the web form's three sizes (#5681), | |
| 3 | * and the chosen one reaches the plan the door makes. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 12 | import { hostedPlacement } from "../src/spawn/placement.ts"; | |
| 13 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 14 | import { newForgeScreen } from "../src/tui/screens/newforge.ts"; | |
| 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let dir: string; | |
| 22 | let app: App; | |
| 23 | let admin: User; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dir = await mkdtemp(join(tmpdir(), "forge-tui-newforge-size-")); | |
| 27 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 28 | const r = await createUser(app.db, { | |
| 29 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 30 | password: "a-good-passphrase", isAdmin: true, | |
| 31 | }); | |
| 32 | if (!r.ok) throw new Error(r.error); | |
| 33 | admin = r.value; | |
| 34 | useProbe(app, () => async () => true); | |
| 35 | usePlacement(app, hostedPlacement({ host: "forge.test" })); | |
| 36 | }); | |
| 37 | afterAll(async () => { | |
| 38 | app.db.close(); | |
| 39 | await rm(dir, { recursive: true, force: true }); | |
| 40 | }); | |
| 41 | ||
| 42 | const TO_SIZE = "\t".repeat(9); // name … follow mode, then Size | |
| 43 | ||
| 44 | async function plan(keys: string): Promise<{ screen: string; size: unknown }> { | |
| 45 | const terminal = headlessTerminal(120, 40); | |
| 46 | const { app: tui, session } = createForgeApp( | |
| 47 | app, | |
| 48 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: admin }, | |
| 49 | { terminal, exitHooks: false }, | |
| 50 | ); | |
| 51 | tui.start(); | |
| 52 | tui.ctx.push(newForgeScreen(session, tui.ctx)); | |
| 53 | try { | |
| 54 | await settleApp(tui); | |
| 55 | terminal.feed(keys + "\x10"); | |
| 56 | await settleApp(tui); | |
| 57 | const screen = tui.screen.toText(); | |
| 58 | const m = /Size: (\w+)\./.exec(screen); | |
| 59 | return { screen, size: m?.[1] }; | |
| 60 | } finally { | |
| 61 | tui.stop?.(); | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | test("the screen offers small, chosen, with medium and large to move to", async () => { | |
| 66 | const terminal = headlessTerminal(120, 40); | |
| 67 | const { app: tui, session } = createForgeApp( | |
| 68 | app, | |
| 69 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: admin }, | |
| 70 | { terminal, exitHooks: false }, | |
| 71 | ); | |
| 72 | tui.start(); | |
| 73 | tui.ctx.push(newForgeScreen(session, tui.ctx)); | |
| 74 | try { | |
| 75 | await settleApp(tui); | |
| 76 | terminal.feed(TO_SIZE); | |
| 77 | await settleApp(tui); | |
| 78 | expect(tui.screen.toText()).toContain("small"); | |
| 79 | for (const [key, word] of [["\x1b[C", "medium"], ["\x1b[C", "large"], ["\x1b[C", "small"], ["\x1b[D", "large"]] as const) { | |
| 80 | terminal.feed(key); | |
| 81 | await settleApp(tui); | |
| 82 | expect(tui.screen.toText()).toContain(word); | |
| 83 | } | |
| 84 | } finally { | |
| 85 | tui.stop?.(); | |
| 86 | } | |
| 87 | }); | |
| 88 | ||
| 89 | test("choosing medium puts size medium in the plan", async () => { | |
| 90 | const r = await plan("szmed" + "\t".repeat(9) + "\x1b[C"); | |
| 91 | expect(r.screen).toContain("The plan"); | |
| 92 | expect(r.size).toBe("medium"); | |
| 93 | }); | |
| 94 | ||
| 95 | test("choosing large puts size large in the plan", async () => { | |
| 96 | const r = await plan("szlarge" + "\t".repeat(9) + "\x1b[C\x1b[C"); | |
| 97 | expect(r.size).toBe("large"); | |
| 98 | }); | |
| 99 | ||
| 100 | test("small, chosen or left alone, records no size", async () => { | |
| 101 | const left = await plan("szsmall"); | |
| 102 | expect(left.screen).toContain("The plan"); | |
| 103 | expect(left.size).toBeUndefined(); | |
| 104 | const back = await plan("szback" + "\t".repeat(9) + "\x1b[C\x1b[D"); | |
| 105 | expect(back.screen).toContain("The plan"); | |
| 106 | expect(back.size).toBeUndefined(); | |
| 107 | }); |
new above
offline
Document this row in the README paragraph on the terminal New forge screen.