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

Merged opened this

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

Merged

scout/forge:scout/5681-the-terminal-s-new-forge-screen-offers-t → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/5681-the-terminal-s-new-forge-screen-offers-t was merged into main as ea714dc.

Review

Sign in to review this change.
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.
456456 A client's forge: `Ctrl+K`, then **New forge** (any signed-in person's, as the
457457 page is), is `/forges/new` in the terminal — name, for whom, what it is for,
458458 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
460460 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
462462 a dialog, never kept.
463463
464464 The family across forges: `:` → Family in the terminal, `/family` on the web,
src/tui/screens/newforge.ts +19 −3
@@ -7,8 +7,8 @@
77 * dialog, and kept nowhere. Any signed-in person's, as the page is.
88 */
99
10 import { DEFAULT_SIZE, SIZES, type Size } from "../../spawn/sizes.ts";
1011 import { childUrlOf, spawnDoor } from "../../spawn/door.ts";
11 import { DEFAULT_SIZE } from "../../spawn/sizes.ts";
1212 import { MAX_BUDGET_USD, MIN_BUDGET_USD } from "../../spawn/plan.ts";
1313 import type { Ctx, Screen } from "../app/app.ts";
1414 import { binding } from "../app/keys.ts";
@@ -23,8 +23,12 @@ interface Spec {
2323 readonly label: string;
2424 readonly hint: string;
2525 readonly secret?: boolean;
26 /** A choice, not text: left, right and space move through it. */
27 readonly choices?: readonly string[];
2628 }
2729
30 const SIZE_CHOICES = Object.keys(SIZES) as Size[];
31
2832 const SPECS: readonly Spec[] = [
2933 { key: "name", label: "Name", hint: "Lowercase letters, digits and dashes." },
3034 { 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[] = [
3539 { key: "modelEndpoint", label: "API base URL", hint: "A gateway for the key; empty for Anthropic. Optional." },
3640 { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` },
3741 { 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 },
outdated

Document this row in the README paragraph on the terminal New forge screen.

3943 ];
4044
4145 /** 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 {
111115 const copied = (body["copied"] as string[] | undefined) ?? [];
112116 const bytes = Number(body["bytes"] ?? 0);
113117 const budget = (body["plan"] as { budgetUsd?: number | null } | undefined)?.budgetUsd ?? null;
118 const size = (body["plan"] as { size?: string } | undefined)?.size;
114119 plan = [
115120 `A forge at ${childUrlOf(f.app, val("name"))}.`,
116121 `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 {
118123 ...(budget === null ? [] : [`Its crew runs on your key, up to $${budget} for the key's whole life.`]),
119124 "Copied into it:",
120125 ...copied.map((c) => ` ${c}`),
126 ...(size === undefined ? [] : [`Size: ${size}.`]),
121127 `About ${Math.max(1, Math.round(bytes / 1024))} KB. Nothing is made until you press enter.`,
122128 ...(typeof body["keyNote"] === "string" ? [body["keyNote"]] : []),
123129 ];
@@ -165,7 +171,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen {
165171 let cursor: { x: number; y: number } | null = null;
166172 SPECS.slice(first, first + rows).forEach((spec, n) => {
167173 const i = first + n;
168 const raw = values[spec.key] ?? "";
174 const raw = spec.choices === undefined ? (values[spec.key] ?? "") : (values[spec.key] || DEFAULT_SIZE);
169175 const shown = spec.secret === true ? "•".repeat([...raw].length) : raw;
170176 const at = field(s, { x: area.x, y: area.y + n * 3, w: Math.min(70, area.w), h: 3 }, c, {
171177 label: spec.label,
@@ -200,6 +206,16 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen {
200206 focus = (focus + 1) % SPECS.length;
201207 return true;
202208 }
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 }
203219 if (e.key === "backspace") {
204220 const k = SPECS[focus]!.key;
205221 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs